From b0071c6617fe9372bb4a6671acd25989e2604286 Mon Sep 17 00:00:00 2001 From: xiaji Date: Thu, 14 May 2026 17:28:40 +0800 Subject: [PATCH] =?UTF-8?q?=E5=8A=9F=E8=83=BD=E6=9B=B4=E6=96=B0:=20?= =?UTF-8?q?=E7=9B=AE=E5=BD=95=E7=82=B9=E5=87=BB=E8=B7=B3=E8=BD=AC=E3=80=81?= =?UTF-8?q?=E7=BF=BB=E9=A1=B5=E6=8C=89=E9=92=AE=E7=A7=BB=E8=87=B3=E5=BA=95?= =?UTF-8?q?=E9=83=A8=E5=8F=B3=E4=BE=A7=E3=80=81=E6=89=80=E6=9C=89=E6=8C=89?= =?UTF-8?q?=E9=92=AE=E6=B7=BB=E5=8A=A0=E6=82=AC=E5=81=9C=E6=8F=90=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .gitignore | 8 ++ src/app.rs | 37 +++++++++- src/reader.rs | 120 +++++++++++++++++------------- src/theme.rs | 197 +++++++++++++++++++++++++++++++++++++++++++++++--- 4 files changed, 297 insertions(+), 65 deletions(-) diff --git a/.gitignore b/.gitignore index b83d222..4cace9f 100644 --- a/.gitignore +++ b/.gitignore @@ -1 +1,9 @@ /target/ + +# IDE +.idea/ +.vscode/ + +# Windows +Thumbs.db +*.lnk \ No newline at end of file diff --git a/src/app.rs b/src/app.rs index 1573a63..72f705c 100644 --- a/src/app.rs +++ b/src/app.rs @@ -19,6 +19,33 @@ pub struct AppState { pub error_message: Option, } +impl AppState { + pub fn prev_page(&mut self) { + if let Some(ref book) = self.book { + if self.current_page > 0 { + self.current_page -= 1; + } else if self.current_section > 0 { + self.current_section -= 1; + self.current_page = book.sections[self.current_section] + .pages.len().saturating_sub(2); + } + } + } + + pub fn next_page(&mut self) { + if let Some(ref book) = self.book { + if self.current_page + 1 < book.sections[self.current_section] + .pages.len().saturating_sub(1) + { + self.current_page += 1; + } else if self.current_section + 1 < book.sections.len() { + self.current_section += 1; + self.current_page = 0; + } + } + } +} + impl App { pub fn new(cc: &eframe::CreationContext<'_>) -> Self { let settings_dir = persistence::settings_dir(); @@ -174,10 +201,18 @@ impl eframe::App for App { if action.toggle_theme { self.settings.theme = match self.settings.theme { theme::Theme::Light => theme::Theme::Dark, - theme::Theme::Dark => theme::Theme::Light, + theme::Theme::Dark => theme::Theme::Sepia, + theme::Theme::Sepia => theme::Theme::Light, }; ctx.set_style(theme::create_style(&self.settings.theme)); } + + if action.page_prev { + self.state.prev_page(); + } + if action.page_next { + self.state.next_page(); + } }); self.save_reading_position(); diff --git a/src/reader.rs b/src/reader.rs index 3af9ad7..5c9a5e7 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -1,6 +1,6 @@ use eframe::egui; use crate::book::Book; -use crate::theme::Theme; +use crate::theme::{self, Theme}; pub fn recalculate_pages(book: &mut Book, font_size: f32, panel_width: f32, panel_height: f32) { let char_width = font_size * 0.6; @@ -25,6 +25,8 @@ pub struct ReaderAction { pub go_back: bool, pub toggle_theme: bool, pub toggle_bookmark: bool, + pub page_next: bool, + pub page_prev: bool, } pub fn reading_view( @@ -41,11 +43,15 @@ pub fn reading_view( go_back: false, toggle_theme: false, toggle_bookmark: false, + page_next: false, + page_prev: false, }; let panel_size = ui.available_size(); recalculate_pages(book, *font_size, panel_size.x, panel_size.y); + let colors = theme::reader_colors(theme); + // --- Sidebar (TOC) --- if *sidebar_open { egui::SidePanel::left("toc_sidebar") @@ -54,7 +60,7 @@ pub fn reading_view( .show_inside(ui, |ui| { ui.heading("目录"); ui.separator(); - render_toc(ui, &book.toc, &book.sections, current_section); + render_toc(ui, &book.toc, current_section, current_page); }); } @@ -62,29 +68,30 @@ pub fn reading_view( egui::TopBottomPanel::top("reader_toolbar") .show_inside(ui, |ui| { ui.horizontal(|ui| { - if ui.button("← 返回").clicked() { + if ui.button("← 返回").on_hover_text("返回书架").clicked() { action.go_back = true; } ui.separator(); ui.label(&book.title); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - let theme_icon = match theme { - Theme::Dark => "☀️", - Theme::Light => "🌙", + let (theme_icon, theme_hint) = match theme { + Theme::Dark => ("🌞", "切换到浅色主题"), + Theme::Light => ("🌙", "切换到夜间主题"), + Theme::Sepia => ("📜", "切换到棕褐色主题"), }; - if ui.button(theme_icon).clicked() { + if ui.button(theme_icon).on_hover_text(theme_hint).clicked() { action.toggle_theme = true; } - if ui.button("🔖").clicked() { + if ui.button("🔖").on_hover_text("添加/移除书签").clicked() { action.toggle_bookmark = true; } - if ui.button("A⁻").clicked() { + if ui.button("A⁻").on_hover_text("缩小字体").clicked() { *font_size = (*font_size - 2.0).max(10.0); } - if ui.button("A⁺").clicked() { + if ui.button("A⁺").on_hover_text("放大字体").clicked() { *font_size = (*font_size + 2.0).min(48.0); } - if ui.button("☰").clicked() { + if ui.button("☰").on_hover_text("打开/关闭目录").clicked() { *sidebar_open = !*sidebar_open; } }); @@ -105,6 +112,36 @@ pub fn reading_view( .unwrap_or(""); ui.label(section_title); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + // Page turn buttons (right side of bottom bar) + let prev_enabled = *current_page > 0 || *current_section > 0; + let next_enabled = (*current_page + 1 < book.sections[*current_section] + .pages.len().saturating_sub(1)) + || (*current_section + 1 < book.sections.len()); + + let next_btn = egui::Button::new( + egui::RichText::new("下一页 ▶").size(13.0) + ).min_size(egui::vec2(100.0, 28.0)); + + let prev_btn = egui::Button::new( + egui::RichText::new("◀ 上一页").size(13.0) + ).min_size(egui::vec2(100.0, 28.0)); + + if !next_enabled { + ui.add_enabled(false, next_btn); + } else if ui.add(next_btn).on_hover_text("下一页 (→键)").clicked() { + action.page_next = true; + } + + ui.add_space(8.0); + + if !prev_enabled { + ui.add_enabled(false, prev_btn); + } else if ui.add(prev_btn).on_hover_text("上一页 (←键)").clicked() { + action.page_prev = true; + } + + ui.separator(); + let label = if total_pages > 0 { format!("{}/{}", *current_page + 1, total_pages) } else { @@ -131,13 +168,11 @@ pub fn reading_view( let end = section.pages[*current_page + 1]; let text: String = section.content.chars().skip(start).take(end - start).collect(); ui.put(rect, |ui: &mut egui::Ui| { - let color = match theme { - Theme::Dark => egui::Color32::WHITE, - Theme::Light => egui::Color32::BLACK, - }; ui.add( egui::Label::new( - egui::RichText::new(&text).size(*font_size).color(color) + egui::RichText::new(&text) + .size(*font_size) + .color(colors.text) ).wrap() ) }); @@ -149,45 +184,19 @@ pub fn reading_view( if let Some(click_pos) = response.interact_pointer_pos() { let x_ratio = (click_pos.x - rect.min.x) / rect.width(); if x_ratio < 0.3 { - if *current_page > 0 { - *current_page -= 1; - } else if *current_section > 0 { - *current_section -= 1; - *current_page = book.sections[*current_section] - .pages.len().saturating_sub(2); - } + action.page_prev = true; } else if x_ratio > 0.7 { - if *current_page + 1 < book.sections[*current_section] - .pages.len().saturating_sub(1) - { - *current_page += 1; - } else if *current_section + 1 < book.sections.len() { - *current_section += 1; - *current_page = 0; - } + action.page_next = true; } } } // Keyboard navigation if ui.input(|i| i.key_pressed(egui::Key::ArrowRight)) { - if *current_page + 1 < book.sections[*current_section] - .pages.len().saturating_sub(1) - { - *current_page += 1; - } else if *current_section + 1 < book.sections.len() { - *current_section += 1; - *current_page = 0; - } + action.page_next = true; } if ui.input(|i| i.key_pressed(egui::Key::ArrowLeft)) { - if *current_page > 0 { - *current_page -= 1; - } else if *current_section > 0 { - *current_section -= 1; - *current_page = book.sections[*current_section] - .pages.len().saturating_sub(2); - } + action.page_prev = true; } }); @@ -197,22 +206,29 @@ pub fn reading_view( fn render_toc( ui: &mut egui::Ui, entries: &[crate::book::TocEntry], - _sections: &[crate::book::Section], current_section: &mut usize, + current_page: &mut usize, ) { for entry in entries { let is_current = entry.section == *current_section; - let response = if is_current { - ui.colored_label(egui::Color32::YELLOW, &entry.label) + let label_text = if is_current { + egui::RichText::new(&entry.label).color(egui::Color32::YELLOW).strong() } else { - ui.label(&entry.label) + egui::RichText::new(&entry.label) }; + let response = ui.add( + egui::Button::new(label_text) + .frame(false) + .wrap() + ); if response.clicked() { *current_section = entry.section; + *current_page = 0; } + response.on_hover_text(format!("跳转到: {}", entry.label)); if !entry.children.is_empty() { ui.indent(&entry.label, |ui| { - render_toc(ui, &entry.children, _sections, current_section); + render_toc(ui, &entry.children, current_section, current_page); }); } } @@ -281,4 +297,4 @@ mod tests { let pages = calculate_pages("test", 0); assert_eq!(pages, vec![0]); } -} +} \ No newline at end of file diff --git a/src/theme.rs b/src/theme.rs index 4f983dd..28241b0 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -1,11 +1,12 @@ use eframe::egui; -use eframe::egui::Style; +use eframe::egui::{Color32, CornerRadius, Stroke, Style, Visuals}; use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum Theme { Light, Dark, + Sepia, } impl Default for Theme { @@ -14,16 +15,154 @@ impl Default for Theme { } } +#[allow(dead_code)] +pub struct ReaderColors { + pub bg: Color32, + pub text: Color32, + pub panel_bg: Color32, +} + +pub fn reader_colors(theme: &Theme) -> ReaderColors { + match theme { + Theme::Light => ReaderColors { + bg: Color32::from_rgb(245, 245, 245), + text: Color32::from_rgb(33, 33, 33), + panel_bg: Color32::from_rgb(255, 255, 255), + }, + Theme::Dark => ReaderColors { + bg: Color32::from_rgb(18, 18, 18), + text: Color32::from_rgb(224, 224, 224), + panel_bg: Color32::from_rgb(30, 30, 30), + }, + Theme::Sepia => ReaderColors { + bg: Color32::from_rgb(244, 236, 216), + text: Color32::from_rgb(91, 70, 54), + panel_bg: Color32::from_rgb(251, 244, 226), + }, + } +} + +fn corner_radius_4() -> CornerRadius { + CornerRadius::same(4) +} + +fn widget_visuals( + bg_fill: Color32, + weak_bg_fill: Color32, + bg_stroke: Stroke, + fg_stroke: Stroke, + expansion: f32, +) -> egui::style::WidgetVisuals { + egui::style::WidgetVisuals { + bg_fill, + weak_bg_fill, + bg_stroke, + corner_radius: corner_radius_4(), + fg_stroke, + expansion, + } +} + pub fn create_style(theme: &Theme) -> Style { match theme { - Theme::Light => Style { - visuals: egui::Visuals::light(), - ..Default::default() - }, - Theme::Dark => Style { - visuals: egui::Visuals::dark(), - ..Default::default() - }, + Theme::Light => { + let mut visuals = Visuals::light(); + visuals.widgets.noninteractive.bg_fill = Color32::from_rgb(250, 250, 250); + visuals.window_fill = Color32::from_rgb(250, 250, 250); + visuals.panel_fill = Color32::from_rgb(250, 250, 250); + visuals.faint_bg_color = Color32::from_rgb(245, 245, 245); + visuals.extreme_bg_color = Color32::from_rgb(224, 224, 224); + visuals.window_corner_radius = corner_radius_4(); + visuals.widgets.noninteractive.corner_radius = corner_radius_4(); + visuals.widgets.inactive.corner_radius = corner_radius_4(); + visuals.widgets.hovered.corner_radius = corner_radius_4(); + visuals.widgets.active.corner_radius = corner_radius_4(); + visuals.selection.bg_fill = Color32::from_rgb(187, 222, 251); + Style { + visuals, + ..Default::default() + } + } + Theme::Dark => { + let mut visuals = Visuals::dark(); + visuals.widgets.noninteractive.bg_fill = Color32::from_rgb(18, 18, 18); + visuals.window_fill = Color32::from_rgb(18, 18, 18); + visuals.panel_fill = Color32::from_rgb(18, 18, 18); + visuals.faint_bg_color = Color32::from_rgb(24, 24, 24); + visuals.extreme_bg_color = Color32::from_rgb(40, 40, 40); + visuals.window_corner_radius = corner_radius_4(); + visuals.widgets.noninteractive.corner_radius = corner_radius_4(); + visuals.widgets.inactive.corner_radius = corner_radius_4(); + visuals.widgets.hovered.corner_radius = corner_radius_4(); + visuals.widgets.active.corner_radius = corner_radius_4(); + visuals.selection.bg_fill = Color32::from_rgb(30, 85, 135); + Style { + visuals, + ..Default::default() + } + } + Theme::Sepia => { + let visuals = Visuals { + dark_mode: false, + override_text_color: Some(Color32::from_rgb(91, 70, 54)), + window_fill: Color32::from_rgb(244, 236, 216), + panel_fill: Color32::from_rgb(244, 236, 216), + faint_bg_color: Color32::from_rgb(235, 225, 200), + extreme_bg_color: Color32::from_rgb(220, 208, 180), + code_bg_color: Color32::from_rgb(235, 225, 200), + warn_fg_color: Color32::from_rgb(180, 120, 60), + error_fg_color: Color32::from_rgb(200, 60, 40), + hyperlink_color: Color32::from_rgb(139, 69, 19), + selection: egui::style::Selection { + bg_fill: Color32::from_rgb(210, 180, 140), + stroke: Stroke::new(1.0, Color32::from_rgb(180, 150, 110)), + }, + widgets: egui::style::Widgets { + noninteractive: widget_visuals( + Color32::from_rgb(251, 244, 226), + Color32::from_rgb(244, 236, 216), + Stroke::new(1.0, Color32::from_rgb(180, 160, 130)), + Stroke::new(1.0, Color32::from_rgb(91, 70, 54)), + 0.0, + ), + inactive: widget_visuals( + Color32::from_rgb(210, 180, 140), + Color32::from_rgb(200, 175, 135), + Stroke::new(1.0, Color32::from_rgb(160, 130, 100)), + Stroke::new(1.0, Color32::from_rgb(91, 70, 54)), + 0.0, + ), + hovered: widget_visuals( + Color32::from_rgb(220, 190, 150), + Color32::from_rgb(210, 185, 145), + Stroke::new(1.0, Color32::from_rgb(170, 140, 110)), + Stroke::new(1.5, Color32::from_rgb(70, 50, 35)), + 1.0, + ), + active: widget_visuals( + Color32::from_rgb(190, 160, 120), + Color32::from_rgb(180, 150, 110), + Stroke::new(1.0, Color32::from_rgb(140, 110, 80)), + Stroke::new(2.0, Color32::from_rgb(50, 35, 20)), + 1.0, + ), + open: widget_visuals( + Color32::from_rgb(200, 170, 130), + Color32::from_rgb(190, 160, 125), + Stroke::new(1.0, Color32::from_rgb(150, 120, 90)), + Stroke::new(2.0, Color32::from_rgb(50, 35, 20)), + 0.0, + ), + }, + window_corner_radius: corner_radius_4(), + window_shadow: egui::epaint::Shadow::NONE, + ..Visuals::light() + }; + Style { + visuals, + ..Default::default() + } + } } } @@ -88,14 +227,48 @@ mod tests { fn test_create_style_light_vs_dark() { let light = create_style(&Theme::Light); let dark = create_style(&Theme::Dark); - // Light and dark should have different window fills assert_ne!(light.visuals.window_fill, dark.visuals.window_fill); - // Dark mode should have darker window assert!(dark.visuals.window_fill.r() < light.visuals.window_fill.r()); assert!(dark.visuals.window_fill.g() < light.visuals.window_fill.g()); assert!(dark.visuals.window_fill.b() < light.visuals.window_fill.b()); } + #[test] + fn test_create_style_sepia() { + let sepia = create_style(&Theme::Sepia); + let light = create_style(&Theme::Light); + assert_ne!(sepia.visuals.window_fill, light.visuals.window_fill); + } + + #[test] + fn test_reader_colors() { + let light_colors = reader_colors(&Theme::Light); + assert!(light_colors.bg.r() > 200); + assert!(light_colors.text.r() < 50); + + let dark_colors = reader_colors(&Theme::Dark); + assert!(dark_colors.bg.r() < 30); + + let sepia_colors = reader_colors(&Theme::Sepia); + assert!(sepia_colors.bg.r() > 200); + assert!(sepia_colors.bg.r() < 250); + } + + #[test] + fn test_theme_cycle_all() { + let themes = [Theme::Light, Theme::Dark, Theme::Sepia]; + let json = serde_json::to_string(&Theme::Sepia).unwrap(); + assert_eq!(json, "\"Sepia\""); + let restored: Theme = serde_json::from_str(&json).unwrap(); + assert_eq!(restored, Theme::Sepia); + let styles: Vec<_> = themes.iter().map(|t| create_style(t).visuals.window_fill).collect(); + for i in 0..styles.len() { + for j in (i + 1)..styles.len() { + assert_ne!(styles[i], styles[j], "themes {i} and {j} should differ"); + } + } + } + #[test] fn test_theme_serialize() { let json = serde_json::to_string(&Theme::Dark).unwrap(); @@ -103,4 +276,4 @@ mod tests { let restored: Theme = serde_json::from_str(&json).unwrap(); assert_eq!(restored, Theme::Dark); } -} +} \ No newline at end of file