use crate::book::Book; use crate::persistence; use crate::style::StyleProfile; use crate::theme::{self, BgType, Settings}; use eframe::egui; use std::path::PathBuf; pub struct App { pub state: AppState, settings: Settings, settings_dir: std::path::PathBuf, kraft_texture: Option, manuscript_texture: Option, composition_texture: Option, custom_texture: Option, } pub struct AppState { pub book: Option, pub current_section: usize, pub current_page: usize, pub sidebar_open: bool, pub file_path: Option, pub error_message: Option, pub pending_anchor: 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] .page_block_ranges.len().saturating_sub(1); } } } pub fn next_page(&mut self) { if let Some(ref book) = self.book { if self.current_page + 1 < book.sections[self.current_section] .page_block_ranges.len() { 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(); let settings = persistence::load_settings(&settings_dir).unwrap_or_default(); cc.egui_ctx.set_style(theme::create_style(&settings.theme)); crate::font::setup_fonts(&cc.egui_ctx, &settings.font_name); Self { state: AppState { book: None, current_section: 0, current_page: 0, sidebar_open: false, file_path: None, error_message: None, pending_anchor: None, }, settings, settings_dir, kraft_texture: Some(crate::texture::generate_kraft_paper(&cc.egui_ctx)), manuscript_texture: Some(crate::texture::generate_manuscript(&cc.egui_ctx)), composition_texture: Some(crate::texture::generate_composition(&cc.egui_ctx)), custom_texture: None, } } pub fn open_file(&mut self, path: PathBuf) { match crate::book::load_epub(&path) { Ok(book) => { let path_str = path.to_string_lossy().to_string(); let pos = self.settings.reading_positions.get(&path_str).cloned(); let mut recent = Vec::new(); recent.push(path_str.clone()); for f in &self.settings.recent_files { if *f != path_str { recent.push(f.clone()); if recent.len() >= 10 { break; } } } self.settings.recent_files = recent; self.state.book = Some(book); self.state.current_section = pos.as_ref().map(|p| p.section).unwrap_or(0); self.state.current_page = pos.as_ref().map(|p| p.page).unwrap_or(0); self.state.pending_anchor = pos; self.state.sidebar_open = false; self.state.file_path = Some(path); self.state.error_message = None; self.save_settings(); } Err(e) => { self.state.error_message = Some(e); } } } fn save_settings(&self) { let _ = persistence::save_settings(&self.settings_dir, &self.settings); } fn active_profile(&self) -> &StyleProfile { let idx = self.settings.profiles.iter().position(|p| p.name == self.settings.active_profile); let idx = idx.unwrap_or(0); &self.settings.profiles[idx] } fn save_reading_position(&mut self) { if let Some(ref path) = self.state.file_path { let path_str = path.to_string_lossy().to_string(); let (block_index, text_snippet) = if let Some(ref book) = self.state.book { let section = &book.sections[self.state.current_section]; if let Some(&(start, _end)) = section.page_block_ranges.get(self.state.current_page) { let first_block = start; let snippet = section.blocks.get(first_block) .map(|b| b.text.chars().take(30).collect()) .unwrap_or_default(); (Some(first_block), Some(snippet)) } else { (None, None) } } else { (None, None) }; self.settings.reading_positions.insert( path_str, theme::ReadingPosition { section: self.state.current_section, page: self.state.current_page, block_index, text_snippet, }, ); } } fn welcome_view(&mut self, ctx: &egui::Context) { egui::CentralPanel::default().show(ctx, |ui| { ui.vertical_centered(|ui| { ui.add_space(150.0); ui.heading("ePub Reader"); ui.add_space(20.0); if ui .add(egui::Button::new("📂 打开 ePub 文件").min_size(egui::vec2(200.0, 40.0))) .clicked() { if let Some(path) = rfd::FileDialog::new() .add_filter("ePub", &["epub"]) .pick_file() { self.open_file(path); } } ui.add_space(30.0); if !self.settings.recent_files.is_empty() { ui.label("最近阅读:"); ui.separator(); let mut to_open: Option = None; let mut to_remove: Option = None; for (i, path) in self.settings.recent_files.iter().enumerate() { let name = std::path::Path::new(path) .file_name() .map(|n| n.to_string_lossy().to_string()) .unwrap_or_else(|| path.clone()); if ui.button(&name).clicked() { let p = std::path::PathBuf::from(path); if p.exists() { to_open = Some(p); } else { to_remove = Some(i); } } } if let Some(path) = to_open { self.open_file(path); } if let Some(i) = to_remove { self.settings.recent_files.remove(i); self.save_settings(); } } if let Some(ref msg) = self.state.error_message { ui.add_space(20.0); ui.colored_label(egui::Color32::RED, msg); } }); }); } } impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { let bg_type = self.settings.bg_type.clone(); let has_bg = bg_type.has_background(); // 有背景时面板设为透明 if has_bg { ctx.style_mut(|s| { s.visuals.panel_fill = egui::Color32::TRANSPARENT; s.visuals.window_fill = egui::Color32::TRANSPARENT; }); } // 全窗口背景纹理 let viewport_rect = ctx.input(|i| i.screen_rect()); match bg_type { BgType::Kraft => { if let Some(tex) = &self.kraft_texture { crate::texture::draw_tiled_bg( &ctx.layer_painter(egui::LayerId::background()), viewport_rect, tex, ); } } BgType::Manuscript => { if let Some(tex) = &self.manuscript_texture { crate::texture::draw_tiled_bg( &ctx.layer_painter(egui::LayerId::background()), viewport_rect, tex, ); } } BgType::Composition => { if let Some(tex) = &self.composition_texture { crate::texture::draw_tiled_bg( &ctx.layer_painter(egui::LayerId::background()), viewport_rect, tex, ); } } BgType::Custom(ref path) if !path.is_empty() => { if self.custom_texture.is_none() { match image::ImageReader::open(path) { Ok(reader) => match reader.decode() { Ok(img) => { let size = [img.width() as usize, img.height() as usize]; let pixels: Vec = img .to_rgba8() .pixels() .map(|p| { egui::Color32::from_rgba_unmultiplied(p[0], p[1], p[2], p[3]) }) .collect(); let image = egui::ColorImage { size, pixels }; self.custom_texture = Some( ctx.load_texture("custom_bg", image, Default::default()), ); } Err(_) => {} }, Err(_) => {} } } if let Some(tex) = &self.custom_texture { let painter = &ctx.layer_painter(egui::LayerId::background()); painter.image( tex.id(), viewport_rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), egui::Color32::WHITE, ); } } _ => {} } if self.state.book.is_none() { self.welcome_view(ctx); return; } let file_path = self.state.file_path .as_ref() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); let mut style = self.active_profile().clone(); let theme_copy = self.settings.theme; let profile_names: Vec = self.settings.profiles.iter().map(|p| p.name.clone()).collect(); let bookmarks = self.settings.bookmarks.get(&file_path).cloned().unwrap_or_default(); let mut jump_to_bookmark: Option = None; egui::CentralPanel::default().show(ctx, |ui| { let book = self.state.book.as_mut().unwrap(); let current_font = self.settings.font_name.clone(); let (action, jump) = crate::reader::reading_view( ui, book, &mut self.state.current_section, &mut self.state.current_page, &mut self.state.sidebar_open, &mut style, &theme_copy, bg_type.clone(), &file_path, &profile_names, &bookmarks, ¤t_font, ); jump_to_bookmark = jump; if action.go_back { self.save_reading_position(); self.state.book = None; self.state.current_section = 0; self.state.current_page = 0; } // Switch profile immediately if let Some(ref target) = action.switch_to_profile { if let Some(profile) = self.settings.profiles.iter() .find(|p| p.name == *target) { style.alignment = profile.alignment; style.line_spacing = profile.line_spacing; style.paragraph_spacing = profile.paragraph_spacing; style.first_line_indent = profile.first_line_indent; style.font_size = profile.font_size; style.name = profile.name.clone(); self.settings.active_profile = profile.name.clone(); } } if let Some(ref new_font) = action.switch_font { if new_font != &self.settings.font_name { let actual = crate::font::setup_fonts(ui.ctx(), new_font); self.settings.font_name = actual; } } if let Some(new_bg) = action.switch_bg { self.settings.bg_type = new_bg; // 切换背景时,如果选自定义图片则弹出文件选择器 if let BgType::Custom(_) = &self.settings.bg_type { if let Some(p) = rfd::FileDialog::new() .add_filter("Images", &["png", "jpg", "jpeg", "bmp", "webp"]) .pick_file() { self.settings.bg_type = BgType::Custom(p.to_string_lossy().to_string()); self.custom_texture = None; } else { self.settings.bg_type = BgType::None; } } // 切换背景时,如果从自定义图片切走,清除纹理缓存 if !self.settings.bg_type.has_background() || !matches!(self.settings.bg_type, BgType::Custom(_)) { self.custom_texture = None; } } if action.toggle_theme { self.settings.theme = match self.settings.theme { theme::Theme::Light => theme::Theme::Dark, theme::Theme::Dark => theme::Theme::Sepia, theme::Theme::Sepia => theme::Theme::Light, }; ctx.set_style(theme::create_style(&self.settings.theme)); } if action.toggle_sidebar { self.state.sidebar_open = !self.state.sidebar_open; } if let Some(section) = action.jump_to_section { self.state.current_section = section; self.state.current_page = 0; self.state.pending_anchor = None; if let Some(ref anchor) = action.jump_to_anchor { if let Some(ref book) = self.state.book { if section < book.sections.len() { if let Some(page) = crate::reader::find_page_for_anchor(&book.sections[section], anchor) { self.state.current_page = page; } } } } } if action.page_prev { self.state.prev_page(); } if action.page_next { self.state.next_page(); } if action.toggle_bookmark { let file_path = file_path.clone(); let section = self.state.current_section; let page = self.state.current_page; let book = self.state.book.as_ref().unwrap(); let section_title = book.sections.get(section) .map(|s| s.title.clone()) .unwrap_or_else(|| format!("第{}章", section + 1)); let bookmarks = self.settings.bookmarks.entry(file_path) .or_insert_with(Vec::new); let existing_idx = bookmarks.iter() .position(|b: &theme::Bookmark| b.section == section && b.page == page); if let Some(idx) = existing_idx { bookmarks.remove(idx); } else { use std::time::{SystemTime, UNIX_EPOCH}; let timestamp = SystemTime::now() .duration_since(UNIX_EPOCH) .map(|d| d.as_secs() as i64) .unwrap_or(0); bookmarks.push(theme::Bookmark { section, page, label: format!("{} - 第{}页", section_title, page + 1), timestamp, }); } } }); if let Some(idx) = jump_to_bookmark { if let Some(bookmarks) = self.settings.bookmarks.get(&file_path) { if let Some(bm) = bookmarks.get(idx) { self.state.current_section = bm.section; self.state.current_page = bm.page; } } } if let Some(anchor) = self.state.pending_anchor.take() { if let Some(ref book) = self.state.book { if anchor.section < book.sections.len() { let section = &book.sections[anchor.section]; let restored = if let Some(block_idx) = anchor.block_index { crate::reader::find_page_for_block(section, block_idx) } else if let Some(ref snippet) = anchor.text_snippet { crate::reader::find_page_by_snippet(section, snippet) } else { None }; if let Some(page) = restored { self.state.current_page = page; } } } } // Sync style changes back to active profile (outside closure) if let Some(p) = self.settings.profiles.iter_mut() .find(|p| p.name == self.settings.active_profile) { p.font_size = style.font_size; p.alignment = style.alignment; p.line_spacing = style.line_spacing; p.paragraph_spacing = style.paragraph_spacing; p.first_line_indent = style.first_line_indent; } self.settings.font_size = style.font_size; self.save_reading_position(); self.save_settings(); } }