647 lines
18 KiB
Rust
647 lines
18 KiB
Rust
//! UI 模块
|
||
//!
|
||
//! ReadFlow Dioxus GUI 界面
|
||
|
||
#![allow(non_snake_case)]
|
||
|
||
use dioxus::prelude::*;
|
||
use crate::config::{ThemeMode, load};
|
||
use crate::library::{Library, LibraryItem};
|
||
use crate::core::document::DocumentEngine;
|
||
use std::path::PathBuf;
|
||
|
||
/// 选中的文件类型
|
||
#[derive(Debug, Clone, Copy, PartialEq, Default)]
|
||
enum FilterType {
|
||
#[default]
|
||
All,
|
||
Recent,
|
||
Pdf,
|
||
Epub,
|
||
Mobi,
|
||
Text,
|
||
}
|
||
|
||
/// 主应用组件
|
||
#[component]
|
||
fn App() -> Element {
|
||
// 加载配置
|
||
let config = load();
|
||
|
||
// 书库路径
|
||
let library_path = dirs::home_dir()
|
||
.unwrap_or_else(|| PathBuf::from("."))
|
||
.join("ReadFlow");
|
||
|
||
// 初始化书库(创建时自动扫描)
|
||
let mut library = use_signal(|| Library::new(library_path));
|
||
|
||
// 选中状态
|
||
let selected = use_signal(|| None::<String>);
|
||
|
||
// 过滤器
|
||
let mut filter = use_signal(|| FilterType::All);
|
||
|
||
// 搜索关键词
|
||
let mut query = use_signal(|| String::new());
|
||
|
||
// 设置面板显示
|
||
let mut show_settings = use_signal(|| false);
|
||
|
||
// 阅读器视图:显示打开的文档
|
||
let opened_document = use_signal(|| None::<String>);
|
||
|
||
// 主题
|
||
let is_dark = config.theme.mode == ThemeMode::Dark;
|
||
|
||
let theme_class = if is_dark { "dark" } else { "light" };
|
||
|
||
rsx! {
|
||
div {
|
||
class: "app-container {theme_class}",
|
||
|
||
// 侧边栏
|
||
div { class: "sidebar",
|
||
div { class: "sidebar-header",
|
||
h1 { "ReadFlow" }
|
||
div { class: "header-actions",
|
||
button {
|
||
class: "open-file-btn",
|
||
onclick: move |_| {
|
||
// 打开文件选择对话框
|
||
spawn(async move {
|
||
open_local_file(library, opened_document).await;
|
||
});
|
||
},
|
||
"📂 打开文件"
|
||
}
|
||
button {
|
||
class: "settings-btn",
|
||
onclick: move |_| show_settings.set(!show_settings()),
|
||
"⚙️"
|
||
}
|
||
}
|
||
}
|
||
|
||
// 搜索框
|
||
div { class: "search-box",
|
||
input {
|
||
r#type: "text",
|
||
placeholder: "搜索文件...",
|
||
value: "{query}",
|
||
oninput: move |e| query.set(e.value().to_string()),
|
||
}
|
||
}
|
||
|
||
// 过滤器
|
||
div { class: "filter-buttons",
|
||
button {
|
||
class: if filter() == FilterType::All { "active" },
|
||
onclick: move |_| filter.set(FilterType::All),
|
||
"全部"
|
||
}
|
||
button {
|
||
class: if filter() == FilterType::Recent { "active" },
|
||
onclick: move |_| filter.set(FilterType::Recent),
|
||
"最近"
|
||
}
|
||
button {
|
||
class: if filter() == FilterType::Pdf { "active" },
|
||
onclick: move |_| filter.set(FilterType::Pdf),
|
||
"PDF"
|
||
}
|
||
button {
|
||
class: if filter() == FilterType::Epub { "active" },
|
||
onclick: move |_| filter.set(FilterType::Epub),
|
||
"EPUB"
|
||
}
|
||
button {
|
||
class: if filter() == FilterType::Mobi { "active" },
|
||
onclick: move |_| filter.set(FilterType::Mobi),
|
||
"MOBI"
|
||
}
|
||
}
|
||
|
||
// 文件列表
|
||
div { class: "file-list",
|
||
// 获取并过滤文件列表(在渲染前准备好数据)
|
||
FileList {
|
||
items: get_filtered_items(library, filter(), query()),
|
||
selected: selected
|
||
}
|
||
}
|
||
}
|
||
|
||
// 主内容区
|
||
div { class: "main-content",
|
||
if let Some(doc_path) = opened_document() {
|
||
div { class: "reader",
|
||
h2 { "正在阅读:{doc_path}" }
|
||
DocumentViewer { path: doc_path }
|
||
}
|
||
} else if let Some(path) = selected() {
|
||
div { class: "reader",
|
||
h2 { "正在阅读: {path}" }
|
||
p { "阅读器功能开发中..." }
|
||
}
|
||
} else {
|
||
div { class: "welcome",
|
||
h2 { "欢迎使用 ReadFlow" }
|
||
p { "从左侧选择一个文件,或点击「📂 打开文件」按钮" }
|
||
p { "支持格式:PDF, EPUB, MOBI, TXT, Markdown, 代码文件" }
|
||
}
|
||
}
|
||
}
|
||
|
||
// 设置面板
|
||
if show_settings() {
|
||
div { class: "modal-overlay",
|
||
div { class: "settings-panel",
|
||
h2 { "设置" }
|
||
button { class: "close-btn", onclick: move |_| show_settings.set(false), "×" }
|
||
div { class: "setting-item",
|
||
label { "主题" }
|
||
select {
|
||
value: "{config.theme.mode}",
|
||
option { value: "light", "浅色" }
|
||
option { value: "dark", "深色" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 文件列表组件
|
||
#[component]
|
||
fn FileList(items: Vec<LibraryItem>, selected: Signal<Option<String>>) -> Element {
|
||
rsx! {
|
||
if items.is_empty() {
|
||
p { class: "empty", "暂无文件" }
|
||
} else {
|
||
for item in items {
|
||
FileItem { item: item, selected: selected }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 单个文件项组件
|
||
#[component]
|
||
fn FileItem(item: crate::library::LibraryItem, selected: Signal<Option<String>>) -> Element {
|
||
let is_selected = selected().as_ref().map(|s| s == &item.path).unwrap_or(false);
|
||
let item_path = item.path.clone();
|
||
|
||
rsx! {
|
||
div {
|
||
class: if is_selected { "file-item selected" } else { "file-item" },
|
||
onclick: move |_| {
|
||
selected.set(Some(item_path.clone()));
|
||
},
|
||
div { class: "file-icon", "{get_file_icon(&item.format)}" }
|
||
div { class: "file-info",
|
||
div { class: "file-title", "{item.title}" }
|
||
div { class: "file-meta",
|
||
span { class: "file-format", "{item.format}" }
|
||
span { "{item.format_file_size()}" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 获取过滤后的文件列表
|
||
fn get_filtered_items(library: Signal<Library>, filter: FilterType, query: String) -> Vec<LibraryItem> {
|
||
let library_guard = library.read();
|
||
let all_items: Vec<LibraryItem> = library_guard.get_all_items().into_iter().cloned().collect();
|
||
|
||
// 搜索过滤
|
||
let filtered: Vec<_> = if query.is_empty() {
|
||
all_items
|
||
} else {
|
||
let q = query.to_lowercase();
|
||
all_items.into_iter()
|
||
.filter(|item| item.title.to_lowercase().contains(&q) || item.path.to_lowercase().contains(&q))
|
||
.collect()
|
||
};
|
||
|
||
// 类型过滤
|
||
match filter {
|
||
FilterType::All => filtered,
|
||
FilterType::Recent => {
|
||
let recent: Vec<&LibraryItem> = library_guard.get_recent(20);
|
||
let recent_paths: Vec<&str> = recent.iter().map(|i| i.path.as_str()).collect();
|
||
filtered.into_iter().filter(|item| recent_paths.contains(&item.path.as_str())).collect()
|
||
}
|
||
FilterType::Pdf => filtered.into_iter().filter(|item| item.format == "PDF").collect(),
|
||
FilterType::Epub => filtered.into_iter().filter(|item| item.format == "EPUB").collect(),
|
||
FilterType::Mobi => filtered.into_iter().filter(|item| item.format == "MOBI" || item.format == "AZW3").collect(),
|
||
FilterType::Text => filtered.into_iter().filter(|item| item.format == "TXT" || item.format == "Markdown").collect(),
|
||
}
|
||
}
|
||
|
||
/// 获取文件图标
|
||
fn get_file_icon(format: &str) -> &'static str {
|
||
match format {
|
||
"PDF" => "📕",
|
||
"EPUB" => "📙",
|
||
"MOBI" | "AZW3" => "📘",
|
||
"TXT" | "Markdown" => "📄",
|
||
_ => "📁",
|
||
}
|
||
}
|
||
|
||
/// 获取应用 CSS
|
||
fn get_app_css() -> &'static str {
|
||
r#"
|
||
* {
|
||
box-sizing: border-box;
|
||
margin: 0;
|
||
padding: 0;
|
||
}
|
||
|
||
body {
|
||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
|
||
}
|
||
|
||
.app-container {
|
||
display: flex;
|
||
height: 100vh;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.app-container.light {
|
||
background: #f7fafc;
|
||
color: #1a202c;
|
||
}
|
||
|
||
.app-container.dark {
|
||
background: #0f172a;
|
||
color: #f1f5f9;
|
||
}
|
||
|
||
.sidebar {
|
||
width: 280px;
|
||
background: #1e293b;
|
||
color: #ffffff;
|
||
display: flex;
|
||
flex-direction: column;
|
||
border-right: 1px solid #475569;
|
||
}
|
||
|
||
.app-container.light .sidebar {
|
||
background: #ffffff;
|
||
color: #1a202c;
|
||
border-right: #e2e8f0;
|
||
}
|
||
|
||
.sidebar-header {
|
||
padding: 20px;
|
||
display: flex;
|
||
justify-content: space-between;
|
||
align-items: center;
|
||
border-bottom: 1px solid #475569;
|
||
}
|
||
|
||
.settings-btn {
|
||
background: none;
|
||
border: none;
|
||
font-size: 20px;
|
||
cursor: pointer;
|
||
padding: 5px;
|
||
}
|
||
|
||
.search-box {
|
||
padding: 10px 20px;
|
||
}
|
||
|
||
.search-box input {
|
||
width: 100%;
|
||
padding: 8px 12px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: #475569;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.app-container.light .search-box input {
|
||
background: #f0f0f0;
|
||
color: #1a202c;
|
||
}
|
||
|
||
.filter-buttons {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
gap: 5px;
|
||
padding: 0 10px 10px;
|
||
}
|
||
|
||
.filter-buttons button {
|
||
padding: 5px 10px;
|
||
border: none;
|
||
border-radius: 4px;
|
||
background: #475569;
|
||
color: #ffffff;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
}
|
||
|
||
.app-container.light .filter-buttons button {
|
||
background: #e0e0e0;
|
||
color: #1a202c;
|
||
}
|
||
|
||
.filter-buttons button.active {
|
||
background: #e94560;
|
||
}
|
||
|
||
.file-list {
|
||
flex: 1;
|
||
overflow-y: auto;
|
||
padding: 10px;
|
||
}
|
||
|
||
.file-item {
|
||
display: flex;
|
||
align-items: center;
|
||
padding: 10px;
|
||
margin-bottom: 5px;
|
||
border-radius: 6px;
|
||
cursor: pointer;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.file-item:hover {
|
||
background: #475569;
|
||
}
|
||
|
||
.app-container.light .file-item:hover {
|
||
background: #f0f0f0;
|
||
}
|
||
|
||
.file-item.selected {
|
||
background: #e94560;
|
||
}
|
||
|
||
.file-icon {
|
||
font-size: 24px;
|
||
margin-right: 10px;
|
||
}
|
||
|
||
.file-info {
|
||
flex: 1;
|
||
overflow: hidden;
|
||
}
|
||
|
||
.file-title {
|
||
font-weight: 500;
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
|
||
.file-meta {
|
||
font-size: 12px;
|
||
opacity: 0.7;
|
||
display: flex;
|
||
gap: 10px;
|
||
}
|
||
|
||
.main-content {
|
||
flex: 1;
|
||
padding: 40px;
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.welcome, .reader {
|
||
text-align: center;
|
||
}
|
||
|
||
.welcome h2, .reader h2 {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.empty {
|
||
text-align: center;
|
||
opacity: 0.5;
|
||
padding: 20px;
|
||
}
|
||
|
||
.modal-overlay {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: 0;
|
||
background: rgba(0,0,0,0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
}
|
||
|
||
.settings-panel {
|
||
background: #1e293b;
|
||
color: #ffffff;
|
||
padding: 30px;
|
||
border-radius: 12px;
|
||
min-width: 300px;
|
||
position: relative;
|
||
}
|
||
|
||
.app-container.light .settings-panel {
|
||
background: #ffffff;
|
||
color: #1a202c;
|
||
}
|
||
|
||
.settings-panel h2 {
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.close-btn {
|
||
position: absolute;
|
||
top: 10px;
|
||
right: 10px;
|
||
background: none;
|
||
border: none;
|
||
color: #ffffff;
|
||
font-size: 24px;
|
||
cursor: pointer;
|
||
}
|
||
|
||
.setting-item {
|
||
margin-bottom: 15px;
|
||
}
|
||
|
||
.setting-item label {
|
||
display: block;
|
||
margin-bottom: 5px;
|
||
}
|
||
|
||
.setting-item select {
|
||
width: 100%;
|
||
padding: 8px;
|
||
border-radius: 4px;
|
||
border: 1px solid #475569;
|
||
background: #475569;
|
||
color: #ffffff;
|
||
}
|
||
|
||
.app-container.light .setting-item select {
|
||
background: #f0f0f0;
|
||
color: #1a202c;
|
||
border-color: #e2e8f0;
|
||
}
|
||
|
||
.header-actions {
|
||
display: flex;
|
||
gap: 10px;
|
||
align-items: center;
|
||
}
|
||
|
||
.open-file-btn {
|
||
padding: 8px 16px;
|
||
border: none;
|
||
border-radius: 6px;
|
||
background: #e94560;
|
||
color: #ffffff;
|
||
cursor: pointer;
|
||
font-size: 14px;
|
||
transition: background 0.2s;
|
||
}
|
||
|
||
.open-file-btn:hover {
|
||
background: #ff6b6b;
|
||
}
|
||
|
||
.document-viewer {
|
||
width: 100%;
|
||
max-width: 900px;
|
||
text-align: left;
|
||
}
|
||
|
||
.doc-header {
|
||
margin-bottom: 30px;
|
||
padding-bottom: 20px;
|
||
border-bottom: 2px solid #e94560;
|
||
}
|
||
|
||
.doc-header h3 {
|
||
font-size: 24px;
|
||
margin-bottom: 10px;
|
||
}
|
||
|
||
.doc-meta {
|
||
font-size: 14px;
|
||
opacity: 0.7;
|
||
}
|
||
|
||
.doc-content {
|
||
background: var(--bg-secondary);
|
||
border-radius: 8px;
|
||
padding: 30px;
|
||
min-height: 400px;
|
||
}
|
||
|
||
.error-viewer {
|
||
text-align: center;
|
||
padding: 40px;
|
||
background: rgba(233, 69, 96, 0.1);
|
||
border-radius: 8px;
|
||
border: 1px solid #e94560;
|
||
}
|
||
|
||
.error-viewer h3 {
|
||
color: #e94560;
|
||
margin-bottom: 10px;
|
||
}
|
||
"#
|
||
}
|
||
|
||
/// 打开本地文件选择对话框并加载文件
|
||
async fn open_local_file(mut library: Signal<Library>, mut opened_document: Signal<Option<String>>) {
|
||
use rfd::FileDialog;
|
||
|
||
// 使用 rfd (Rust File Dialog) 打开文件选择器
|
||
let file = FileDialog::new()
|
||
.add_filter("文档", &["pdf", "epub", "mobi", "azw3", "txt", "md"])
|
||
.add_filter("代码文件", &["rs", "py", "js", "ts", "go", "java", "c", "cpp", "h", "css", "html", "json", "xml", "yaml", "yml", "toml", "sql", "sh", "bash", "zsh"])
|
||
.set_title("选择要打开的文件")
|
||
.pick_file();
|
||
|
||
if let Some(path) = file {
|
||
let path_str = path.to_string_lossy().to_string();
|
||
|
||
// 添加到书库(如果尚未存在)
|
||
let path_obj = std::path::Path::new(&path_str);
|
||
let _ = library.write().add_file(path_obj);
|
||
|
||
// 打开文档
|
||
opened_document.set(Some(path_str.clone()));
|
||
}
|
||
}
|
||
|
||
/// 文档查看器组件
|
||
#[component]
|
||
fn DocumentViewer(path: String) -> Element {
|
||
// 尝试加载文档
|
||
let engine = DocumentEngine::new();
|
||
|
||
match engine.open(&path) {
|
||
Ok(doc) => {
|
||
let format_str = format!("{:?}", doc.format);
|
||
let page_count = doc.metadata.page_count;
|
||
let size_str = format_size(doc.metadata.file_size);
|
||
|
||
rsx! {
|
||
div { class: "document-viewer",
|
||
div { class: "doc-header",
|
||
h3 { "{doc.title}" }
|
||
p { class: "doc-meta",
|
||
"格式:{format_str} | 页数:{page_count} | 大小:{size_str}"
|
||
}
|
||
}
|
||
div { class: "doc-content",
|
||
p { "文档已加载,阅读器渲染功能开发中..." }
|
||
p { "文件路径:{path}" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
Err(e) => {
|
||
let error_msg = e.to_string();
|
||
rsx! {
|
||
div { class: "error-viewer",
|
||
h3 { "❌ 打开文件失败" }
|
||
p { "{error_msg}" }
|
||
}
|
||
}
|
||
}
|
||
}
|
||
}
|
||
|
||
/// 格式化文件大小
|
||
fn format_size(size: u64) -> String {
|
||
const KB: u64 = 1024;
|
||
const MB: u64 = KB * 1024;
|
||
const GB: u64 = MB * 1024;
|
||
|
||
if size >= GB {
|
||
format!("{:.2} GB", size as f64 / GB as f64)
|
||
} else if size >= MB {
|
||
format!("{:.2} MB", size as f64 / MB as f64)
|
||
} else if size >= KB {
|
||
format!("{:.2} KB", size as f64 / KB as f64)
|
||
} else {
|
||
format!("{} B", size)
|
||
}
|
||
}
|
||
|
||
/// 启动 GUI
|
||
pub fn run() {
|
||
// 使用 Dioxus 启动
|
||
dioxus::launch(App);
|
||
} |