# ePub Reader Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Goal:** Build a standalone Windows ePub reader with egui, Kindle-like UI, Chinese font support. **Architecture:** Single-exe Rust application using `eframe` (egui) for UI, `epub` crate for ePub parsing, bundled Noto Sans SC font. App dispatches between Welcome screen and Reading view based on whether a book is open. The `App` struct holds all state; the book is parsed into a plain-text model with pre-computed page offsets. **Tech Stack:** Rust 1.94+ (x86_64-pc-windows-gnu), eframe 0.31, epub 1.2, rfd 0.15, serde/serde_json, Noto Sans SC font **Build Env:** MSYS2 MINGW64 terminal (`C:\msys64\mingw64.exe`) — all `cargo` commands must run there. --- ### Task 1: Create project scaffold **Files:** - Create: `Cargo.toml` - Create: `.cargo/config.toml` - Create: `src/main.rs` - [ ] **Step 1: Create directory structure** ```bash mkdir -p epub-read/src epub-read/.cargo cd epub-read ``` - [ ] **Step 2: Write Cargo.toml** ```toml [package] name = "epub-read" version = "0.1.0" edition = "2021" description = "ePub reader with egui" [[bin]] name = "epub-read" path = "src/main.rs" [dependencies] eframe = "0.31" epub = "1.2" rfd = "0.15" serde = { version = "1", features = ["derive"] } serde_json = "1" [profile.release] opt-level = "z" lto = true codegen-units = 1 strip = "symbols" ``` - [ ] **Step 3: Write .cargo/config.toml for static MinGW linking** ```toml [target.x86_64-pc-windows-gnu] rustflags = ["-C", "target-feature=+crt-static"] ``` - [ ] **Step 4: Write src/main.rs** ```rust #![windows_subsystem = "windows"] mod app; mod book; mod font; mod persistence; mod reader; mod theme; fn main() -> eframe::Result { let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([900.0, 700.0]) .with_min_inner_size([600.0, 400.0]), ..Default::default() }; eframe::run_native( "ePub Reader", native_options, Box::new(|cc| { font::setup_fonts(&cc.egui_ctx); Ok(Box::new(app::App::new(cc))) }), ) } ``` - [ ] **Step 5: Write placeholder module files** ```bash echo "" > src/app.rs echo "" > src/book.rs echo "" > src/font.rs echo "" > src/persistence.rs echo "" > src/reader.rs echo "" > src/theme.rs ``` - [ ] **Step 6: Verify cargo compiles** Run in MINGW64: ```bash cd /path/to/epub-read cargo check ``` Expected: Compilation succeeds (warnings about unused code OK). --- ### Task 2: Define data models **Files:** - Create: `src/book.rs` (Book, Section, TocEntry, strip_html + tests) - Create: `src/theme.rs` (Theme, Settings + tests) - [ ] **Step 1: Write tests for strip_html** Append to `src/book.rs`: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_strip_html_plain_text() { assert_eq!(strip_html("Hello World"), "Hello World"); } #[test] fn test_strip_html_simple_tags() { assert_eq!(strip_html("

Hello

"), "Hello"); } #[test] fn test_strip_html_nested_tags() { assert_eq!( strip_html("

Hello World

"), "Hello World" ); } #[test] fn test_strip_html_html_entities() { assert_eq!(strip_html("Hello & World"), "Hello & World"); assert_eq!(strip_html("Hello World"), "Hello World"); } #[test] fn test_strip_html_empty() { assert_eq!(strip_html(""), ""); } } ``` - [ ] **Step 2: Run tests to verify they fail** ```bash cd /path/to/epub-read cargo test --lib 2>&1 | head -20 ``` Expected: Compile error — `strip_html` not defined. - [ ] **Step 3: Write strip_html + data models into book.rs** ```rust pub fn strip_html(input: &str) -> String { let mut result = String::with_capacity(input.len()); let mut in_tag = false; let mut in_entity = false; let mut entity = String::new(); for c in input.chars() { match c { '<' => in_tag = true, '>' if in_tag => in_tag = false, '&' if !in_tag => { in_entity = true; entity.clear(); } ';' if in_entity => { in_entity = false; let decoded = match entity.as_str() { "amp" => "&", "lt" => "<", "gt" => ">", "quot" => "\"", "nbsp" => " ", _ => "", }; result.push_str(decoded); } c if !in_tag && !in_entity => result.push(c), c if in_entity => entity.push(c), _ => {} } } result } #[derive(Debug, Clone)] pub struct TocEntry { pub label: String, pub section: usize, pub children: Vec, } #[derive(Debug, Clone)] pub struct Section { pub title: String, pub content: String, pub pages: Vec, } #[derive(Debug, Clone)] pub struct Book { pub title: String, pub author: String, pub cover: Option>, pub sections: Vec
, pub toc: Vec, } ``` - [ ] **Step 4: Write Theme + Settings into theme.rs** ```rust use serde::{Deserialize, Serialize}; #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] pub enum Theme { Light, Dark, } impl Default for Theme { fn default() -> Self { Theme::Light } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Settings { pub font_size: f32, pub theme: Theme, pub recent_files: Vec, pub reading_positions: std::collections::HashMap, pub bookmarks: std::collections::HashMap>, pub window_size: Option<(f32, f32)>, } impl Default for Settings { fn default() -> Self { Self { font_size: 20.0, theme: Theme::Light, recent_files: Vec::new(), reading_positions: std::collections::HashMap::new(), bookmarks: std::collections::HashMap::new(), window_size: None, } } } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReadingPosition { pub section: usize, pub page: usize, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Bookmark { pub section: usize, pub page: usize, pub label: String, pub timestamp: i64, } ``` Write tests for Settings round-trip into theme.rs: ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_settings_default() { let s = Settings::default(); assert_eq!(s.font_size, 20.0); assert_eq!(s.theme, Theme::Light); } #[test] fn test_settings_round_trip() { let s = Settings::default(); let json = serde_json::to_string(&s).unwrap(); let restored: Settings = serde_json::from_str(&json).unwrap(); assert_eq!(restored.font_size, s.font_size); assert_eq!(restored.theme, s.theme); } #[test] fn test_theme_serialize() { let json = serde_json::to_string(&Theme::Dark).unwrap(); assert_eq!(json, "\"Dark\""); let restored: Theme = serde_json::from_str(&json).unwrap(); assert_eq!(restored, Theme::Dark); } } ``` - [ ] **Step 5: Run tests to verify they pass** ```bash cargo test ``` Expected: All strip_html tests pass, all Settings/Theme round-trip tests pass. --- ### Task 3: Implement EpubLoader **Files:** - Modify: `src/book.rs` - [ ] **Step 1: Write test for EpubLoader (skips if no sample epub)** Append to `src/book.rs` tests: ```rust #[test] fn test_epub_loader_nonexistent_file() { let result = load_epub("nonexistent.epub"); assert!(result.is_err()); } ``` - [ ] **Step 2: Run the test to verify it fails** ```bash cargo test --lib test_epub_loader -- --nocapture ``` Expected: Compile error — `load_epub` not defined. - [ ] **Step 3: Implement EpubLoader in book.rs** Add the loader and a public `load_epub` function: ```rust use std::path::Path; use std::time::{SystemTime, UNIX_EPOCH}; pub fn load_epub(path: impl AsRef) -> Result { let path = path.as_ref(); let mut doc = epub::doc::EpubDoc::new(path) .map_err(|e| format!("无法打开文件: {}", e))?; let title = doc.mdata("title").unwrap_or_else(|| "未知标题".to_string()); let author = doc.mdata("creator").unwrap_or_else(|| "未知作者".to_string()); let cover = doc.get_cover().ok(); let spine = doc.spine.clone(); let raw_toc = doc.toc.clone(); let mut sections = Vec::new(); for (i, href) in spine.iter().enumerate() { let (_, raw_html) = doc.get_resource_str(href) .map_err(|e| format!("读取章节失败: {}", e))?; let text = strip_html(&raw_html); let title = extract_title(&raw_html) .unwrap_or_else(|| format!("第{}章", i + 1)); sections.push(Section { title, content: text, pages: Vec::new(), }); } let toc = build_toc(&raw_toc, &spine); Ok(Book { title, author, cover, sections, toc }) } fn extract_title(html: &str) -> Option { if let Some(start) = html.find("") { let rest = &html[start + 7..]; if let Some(end) = rest.find("") { return Some(strip_html(&rest[..end]).trim().to_string()); } } if let Some(start) = html.find("') { let inner = &rest[content_start + 1..]; if let Some(end) = inner.find("") { return Some(strip_html(&inner[..end]).trim().to_string()); } } } None } fn build_toc( entries: &[epub::doc::TocEntry], spine: &[String], ) -> Vec { entries .iter() .map(|e| { let section = spine .iter() .position(|s| e.content.contains(s.trim_end_matches('/'))) .unwrap_or(0); TocEntry { label: e.label.clone(), section, children: build_toc(&e.children, spine), } }) .collect() } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash cargo test ``` Expected: All existing tests pass (new test covers error case only). --- ### Task 4: Implement pagination algorithm **Files:** - Modify: `src/reader.rs` - [ ] **Step 1: Write pagination tests** ```rust #[cfg(test)] mod tests { use super::*; #[test] fn test_pagination_empty() { let pages = calculate_pages("", 100); assert_eq!(pages, vec![0]); } #[test] fn test_pagination_shorter_than_page() { let pages = calculate_pages("Hello World", 100); assert_eq!(pages, vec![0, 11]); } #[test] fn test_pagination_exact_fit() { let pages = calculate_pages("ABCD", 4); assert_eq!(pages, vec![0, 4]); } #[test] fn test_pagination_multiple_pages() { let text = "A".repeat(100); let pages = calculate_pages(&text, 30); assert_eq!(pages, vec![0, 30, 60, 90, 100]); } #[test] fn test_pagination_single_char() { let pages = calculate_pages("A", 1); assert_eq!(pages, vec![0, 1]); } } ``` - [ ] **Step 2: Run to verify failure** ```bash cargo test --lib tests::test_pagination_empty -- --nocapture ``` Expected: Compile error — `calculate_pages` not defined. - [ ] **Step 3: Implement calculate_pages in reader.rs** ```rust pub fn calculate_pages(text: &str, chars_per_page: usize) -> Vec { let mut pages = Vec::new(); pages.push(0); if text.is_empty() || chars_per_page == 0 { return pages; } let total_chars = text.chars().count(); if total_chars <= chars_per_page { pages.push(total_chars); return pages; } let mut pos = 0; while pos < total_chars { pos = (pos + chars_per_page).min(total_chars); pages.push(pos); } pages } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash cargo test ``` Expected: All pagination tests pass. --- ### Task 5: Implement font loading **Files:** - Modify: `src/font.rs` - [ ] **Step 1: Download Noto Sans SC font** ```bash # Download Noto Sans SC Regular from GitHub mkdir -p fonts curl -L -o fonts/NotoSansSC-Regular.ttf \ "https://github.com/googlefonts/noto-cjk/releases/download/Sans2.004/03_NotoSansCJKsc.zip" ``` Note: If the above URL fails, download manually from https://fonts.google.com/download?family=Noto+Sans+SC and place `NotoSansSC-Regular.ttf` in `fonts/`. - [ ] **Step 2: Implement font.rs** ```rust use eframe::egui; pub fn setup_fonts(ctx: &egui::Context) { let mut fonts = egui::FontDefinitions::default(); // Try to load bundled Chinese font let font_data = include_bytes!("../fonts/NotoSansSC-Regular.ttf"); fonts.font_data.insert( "NotoSansSC".to_string(), egui::FontData::from_static(font_data), ); // Priority: NotoSansSC for CJK, then default proportional/ monospace if let Some(proportional) = fonts.families.get_mut(&egui::FontFamily::Proportional) { proportional.insert(0, "NotoSansSC".to_string()); } if let Some(monospace) = fonts.families.get_mut(&egui::FontFamily::Monospace) { monospace.insert(0, "NotoSansSC".to_string()); } ctx.set_fonts(fonts); } ``` --- ### Task 6: Implement settings persistence **Files:** - Create: `src/persistence.rs` - [ ] **Step 1: Write round-trip tests** ```rust #[cfg(test)] mod tests { use super::*; use crate::theme::{Settings, Theme}; #[test] fn test_save_and_load_settings() { let dir = std::env::temp_dir().join("epub-read-test"); let _ = std::fs::create_dir_all(&dir); let mut s = Settings::default(); s.font_size = 24.0; s.theme = Theme::Dark; save_settings(&dir, &s).unwrap(); let loaded = load_settings(&dir).unwrap(); assert_eq!(loaded.font_size, 24.0); assert_eq!(loaded.theme, Theme::Dark); let _ = std::fs::remove_dir_all(&dir); } #[test] fn test_load_settings_nonexistent() { let dir = std::env::temp_dir().join("epub-read-test-nonexistent"); let _ = std::fs::remove_dir_all(&dir); let result = load_settings(&dir); assert!(result.is_err()); } } ``` - [ ] **Step 2: Run to verify failure** ```bash cargo test --lib persistence::tests -- --nocapture ``` Expected: Compile error — functions not defined. - [ ] **Step 3: Implement persistence.rs** ```rust use crate::theme::Settings; use std::path::Path; const SETTINGS_FILE: &str = "settings.json"; pub fn settings_dir() -> std::path::PathBuf { // Use exe directory for portable settings if let Ok(exe) = std::env::current_exe() { if let Some(dir) = exe.parent() { return dir.to_path_buf(); } } std::path::PathBuf::from(".") } pub fn save_settings(dir: &Path, settings: &Settings) -> Result<(), String> { let path = dir.join(SETTINGS_FILE); let json = serde_json::to_string_pretty(settings) .map_err(|e| format!("序列化设置失败: {}", e))?; std::fs::write(&path, json) .map_err(|e| format!("保存设置失败: {}", e))?; Ok(()) } pub fn load_settings(dir: &Path) -> Result { let path = dir.join(SETTINGS_FILE); let json = std::fs::read_to_string(&path) .map_err(|e| format!("读取设置失败: {}", e))?; serde_json::from_str(&json) .map_err(|e| format!("解析设置失败: {}", e)) } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash cargo test ``` Expected: All persistence tests pass. --- ### Task 7: Implement theme visuals **Files:** - Modify: `src/theme.rs` - [ ] **Step 1: Write test for theme creation** ```rust #[test] fn test_create_style_light() { let style = create_style(&Theme::Light); assert_eq!(style.visuals.window_fill, egui::Color32::from_rgb(255, 255, 255)); } ``` - [ ] **Step 2: Run to verify failure** ```bash cargo test --lib theme::tests::test_create_style_light -- --nocapture ``` Expected: Compile error — `create_style` not defined. - [ ] **Step 3: Implement create_style** Add imports at top of theme.rs: ```rust use eframe::egui; use eframe::egui::{Color32, Style, Visuals}; ``` Add function: ```rust pub fn create_style(theme: &Theme) -> Style { match theme { Theme::Light => Style { visuals: Visuals::light(), ..Default::default() }, Theme::Dark => Style { visuals: Visuals::dark(), ..Default::default() }, } } ``` - [ ] **Step 4: Run tests to verify they pass** ```bash cargo test ``` Expected: All tests pass. --- ### Task 8: Implement Welcome screen **Files:** - Create: `src/app.rs` - [ ] **Step 1: Write welcome_view skeleton** app.rs: ```rust use crate::book::Book; use crate::persistence; use crate::reader; use crate::theme::{self, Settings, Theme}; use eframe::egui; use std::collections::HashMap; use std::path::PathBuf; pub struct App { pub state: AppState, settings: Settings, settings_dir: std::path::PathBuf, } 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, } 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)); Self { state: AppState { book: None, current_section: 0, current_page: 0, sidebar_open: false, file_path: None, error_message: None, }, settings, settings_dir, } } } ``` - [ ] **Step 2: Implement open_file method** ```rust impl App { 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(); // Restore reading position let pos = self.settings.reading_positions.get(&path_str).copied(); // Update recent files 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.map(|p| p.section).unwrap_or(0); self.state.current_page = pos.map(|p| p.page).unwrap_or(0); 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 save_reading_position(&mut self) { if let Some(ref path) = self.state.file_path { let path_str = path.to_string_lossy().to_string(); self.settings.reading_positions.insert( path_str, theme::ReadingPosition { section: self.state.current_section, page: self.state.current_page, }, ); } } } ``` - [ ] **Step 3: Implement welcome_view + main update** Append to app.rs: ```rust impl App { 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.button("📂 打开 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); // Recent files if !self.settings.recent_files.is_empty() { ui.label("最近阅读:"); ui.separator(); 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() { self.open_file(p); } else { to_remove = Some(i); } } } if let Some(i) = to_remove { self.settings.recent_files.remove(i); self.save_settings(); } } // Error dialog if let Some(ref msg) = self.state.error_message { ui.add_space(20.0); ui.colored_label( egui::Color32::RED, msg, ); } }); }); } } ``` --- ### Task 9: Implement Reading view **Files:** - Modify: `src/reader.rs` - [ ] **Step 1: Write the full reading view** ```rust use crate::book::Book; use crate::theme::{self, Theme}; use eframe::egui; pub fn calculate_pages(text: &str, chars_per_page: usize) -> Vec { let mut pages = Vec::new(); pages.push(0); if text.is_empty() || chars_per_page == 0 { return pages; } let total_chars = text.chars().count(); if total_chars <= chars_per_page { pages.push(total_chars); return pages; } let mut pos = 0; while pos < total_chars { pos = (pos + chars_per_page).min(total_chars); pages.push(pos); } pages } pub fn recalculate_pages(book: &mut Book, font_size: f32, panel_width: f32, panel_height: f32) { let char_width = font_size * 0.6; let line_height = font_size * 1.5; let chars_per_line = if char_width > 0.0 { (panel_width / char_width).max(1) as usize } else { 1 }; let lines_per_page = if line_height > 0.0 { (panel_height / line_height).max(1) as usize } else { 1 }; let chars_per_page = chars_per_line * lines_per_page; for section in &mut book.sections { section.pages = calculate_pages(§ion.content, chars_per_page); } } pub fn reading_view( ui: &mut egui::Ui, book: &mut Book, current_section: &mut usize, current_page: &mut usize, sidebar_open: &mut bool, font_size: &mut f32, theme: &Theme, file_path: &str, ) { let panel_size = ui.available_size(); recalculate_pages(book, *font_size, panel_size.x, panel_size.y); // --- Sidebar (TOC) --- if *sidebar_open { egui::SidePanel::left("toc_sidebar") .resizable(true) .default_width(200.0) .show_inside(ui, |ui| { ui.heading("目录"); ui.separator(); render_toc(ui, &book.toc, &book.sections, current_section); }); } // --- Top toolbar --- egui::TopBottomPanel::top("reader_toolbar") .show_inside(ui, |ui| { ui.horizontal(|ui| { if ui.button("← 返回").clicked() { // Return to welcome screen is handled by caller } ui.separator(); ui.label(&book.title); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { if ui.button(if *theme == Theme::Dark { "☀️" } else { "🌙" }).clicked() { // Theme toggle handled by caller } if ui.button("🔖").clicked() { // Bookmark toggle handled by caller } if ui.button("A⁻").clicked() { *font_size = (*font_size - 2.0).max(10.0); } if ui.button("A⁺").clicked() { *font_size = (*font_size + 2.0).min(48.0); } if ui.button("☰").clicked() { *sidebar_open = !*sidebar_open; } }); }); }); // --- Bottom progress bar --- let total_pages = if *current_section < book.sections.len() { let p = &book.sections[*current_section].pages; if p.len() > 1 { p.len() - 1 } else { 0 } } else { 0 }; egui::TopBottomPanel::bottom("reader_progress") .show_inside(ui, |ui| { ui.horizontal(|ui| { let section_title = book.sections.get(*current_section) .map(|s| s.title.as_str()) .unwrap_or(""); ui.label(section_title); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let label = if total_pages > 0 { format!("{}/{}", *current_page + 1, total_pages) } else { "1/1".to_string() }; ui.label(label); let mut progress = if total_pages > 0 { *current_page as f32 / total_pages as f32 } else { 0.0 }; ui.add(egui::Slider::new(&mut progress, 0.0..=1.0).text("")); if total_pages > 0 { *current_page = (progress * total_pages as f32) as usize; } }); }); }); // --- Center text area --- egui::CentralPanel::default().show_inside(ui, |ui| { let (rect, response) = ui.allocate_space(ui.available_size(), egui::Sense::click()); // Render text if let Some(section) = book.sections.get(*current_section) { if *current_page < section.pages.len().saturating_sub(1) { let start = section.pages[*current_page]; 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| { ui.add( egui::Label::new( egui::RichText::new(&text) .size(*font_size) .color(if *theme == Theme::Dark { egui::Color32::WHITE } else { egui::Color32::BLACK }) ) .wrap(true) ); }); } } // Handle click navigation if response.clicked() { 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 { // Previous page 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); } } else if x_ratio > 0.7 { // Next page 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; } } } } // 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; } } 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); } } }); } fn render_toc( ui: &mut egui::Ui, entries: &[crate::book::TocEntry], sections: &[crate::book::Section], current_section: &mut usize, ) { for entry in entries { let label = &entry.label; let is_current = entry.section == *current_section; let response = if is_current { ui.colored_label(egui::Color32::YELLOW, label) } else { ui.label(label) }; if response.clicked() { *current_section = entry.section; } if !entry.children.is_empty() { ui.indent(label, |ui| { render_toc(ui, &entry.children, sections, current_section); }); } } } ``` --- ### Task 10: Wire App and verify build **Files:** - Modify: `src/app.rs` (full App with eframe::App impl) - [ ] **Step 1: Implement eframe::App for App** Add to `src/app.rs`: ```rust impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { if self.state.book.is_none() { self.welcome_view(ctx); } else { let mut go_back = false; let mut toggle_theme = false; let mut toggle_bookmark = false; // Extract book reference let book = self.state.book.as_mut().unwrap(); let file_path = self.state.file_path.as_ref() .map(|p| p.to_string_lossy().to_string()) .unwrap_or_default(); egui::CentralPanel::default().show(ctx, |ui| { crate::reader::reading_view( ui, book, &mut self.state.current_section, &mut self.state.current_page, &mut self.state.sidebar_open, &mut self.settings.font_size, &self.settings.theme, &file_path, ); }); // Handle navigation from toolbar (simulated via state flags) // For now, toolbar buttons in reading_view return through the book reference // Theme toggle is handled here if toggle_theme { self.settings.theme = match self.settings.theme { theme::Theme::Light => theme::Theme::Dark, theme::Theme::Dark => theme::Theme::Light, }; ctx.set_style(theme::create_style(&self.settings.theme)); self.save_settings(); } // Auto-save reading position on each frame (throttled) self.save_reading_position(); self.save_settings(); } } } ``` Wait, the toolbar buttons (theme toggle, back) are inside `reading_view` and can't directly set App's state. I need to redesign this. Let me use return values from `reading_view`. - [ ] **Step 2: Refactor reading_view to use return values** Change `reading_view` signature in `src/reader.rs`: ```rust pub struct ReaderAction { pub go_back: bool, pub toggle_theme: bool, pub toggle_bookmark: bool, } pub fn reading_view( ui: &mut egui::Ui, book: &mut Book, current_section: &mut usize, current_page: &mut usize, sidebar_open: &mut bool, font_size: &mut f32, theme: &Theme, file_path: &str, ) -> ReaderAction { let mut action = ReaderAction { go_back: false, toggle_theme: false, toggle_bookmark: false, }; // --- Sidebar (TOC) --- if *sidebar_open { egui::SidePanel::left("toc_sidebar") .resizable(true) .default_width(200.0) .show_inside(ui, |ui| { ui.heading("目录"); ui.separator(); render_toc(ui, &book.toc, &book.sections, current_section); }); } // --- Top toolbar --- egui::TopBottomPanel::top("reader_toolbar") .show_inside(ui, |ui| { ui.horizontal(|ui| { if ui.button("← 返回").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 => "🌙", }; if ui.button(theme_icon).clicked() { action.toggle_theme = true; } if ui.button("🔖").clicked() { action.toggle_bookmark = true; } if ui.button("A⁻").clicked() { *font_size = (*font_size - 2.0).max(10.0); } if ui.button("A⁺").clicked() { *font_size = (*font_size + 2.0).min(48.0); } if ui.button("☰").clicked() { *sidebar_open = !*sidebar_open; } }); }); }); // --- Bottom progress bar --- let total_pages = if *current_section < book.sections.len() { let p = &book.sections[*current_section].pages; if p.len() > 1 { p.len() - 1 } else { 0 } } else { 0 }; egui::TopBottomPanel::bottom("reader_progress") .show_inside(ui, |ui| { ui.horizontal(|ui| { let section_title = book.sections.get(*current_section) .map(|s| s.title.as_str()) .unwrap_or(""); ui.label(section_title); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let label = if total_pages > 0 { format!("{}/{}", *current_page + 1, total_pages) } else { "1/1".to_string() }; ui.label(label); let mut progress = if total_pages > 0 { *current_page as f32 / total_pages as f32 } else { 0.0 }; let slider = egui::Slider::new(&mut progress, 0.0..=1.0).text(""); if ui.add(slider).changed() && total_pages > 0 { *current_page = (progress * total_pages as f32).round() as usize; } }); }); }); // --- Center text area --- egui::CentralPanel::default().show_inside(ui, |ui| { let (rect, response) = ui.allocate_space(ui.available_size(), egui::Sense::click()); // Render text if let Some(section) = book.sections.get(*current_section) { if *current_page < section.pages.len().saturating_sub(1) { let start = section.pages[*current_page]; 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) ) .wrap(true) ); }); } } // Handle click navigation if response.clicked() { 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); } } 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; } } } } // 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; } } 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 } ``` - [ ] **Step 3: Update App::update to use ReaderAction** ```rust impl eframe::App for App { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { 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(); egui::CentralPanel::default().show(ctx, |ui| { let book = self.state.book.as_mut().unwrap(); let action = crate::reader::reading_view( ui, book, &mut self.state.current_section, &mut self.state.current_page, &mut self.state.sidebar_open, &mut self.settings.font_size, &self.settings.theme, &file_path, ); if action.go_back { self.save_reading_position(); self.state.book = None; self.state.current_section = 0; self.state.current_page = 0; } if action.toggle_theme { self.settings.theme = match self.settings.theme { theme::Theme::Light => theme::Theme::Dark, theme::Theme::Dark => theme::Theme::Light, }; ctx.set_style(theme::create_style(&self.settings.theme)); } }); // Save position and settings self.save_reading_position(); self.save_settings(); } } ``` - [ ] **Step 4: Build and fix compilation errors** ```bash cargo check 2>&1 ``` Fix any compilation errors iteratively: ```bash cargo check 2>&1 | head -50 ``` Common issues to fix: - Missing imports in various modules - API mismatches between `epub` crate version and usage - Method name differences in `egui` - [ ] **Step 5: Final successful build** ```bash cargo check ``` Expected: Compilation succeeds with 0 errors (warnings OK). --- ### Task 11: Build release binary **Files:** - Modify: `.cargo/config.toml` (verify static linking config) - [ ] **Step 1: Verify static linking config** `.cargo/config.toml`: ```toml [target.x86_64-pc-windows-gnu] rustflags = ["-C", "target-feature=+crt-static"] ``` - [ ] **Step 2: Build release** ```bash cargo build --release ``` Expected: `target/release/epub-read.exe` produced. - [ ] **Step 3: Verify no DLL dependencies** ```bash # Check that the exe has no mingw DLL dependencies objdump -p target/release/epub-read.exe | grep "DLL Name" ``` Expected: No `libgcc_s_seh-1.dll`, `libwinpthread-1.dll`, or `libstdc++-6.dll` listed. Only Windows system DLLs. - [ ] **Step 4: Verify console is hidden** ```bash # Run the exe — should show no console window ./target/release/epub-read.exe & ``` Expected: GUI window appears, no console/terminal window visible. - [ ] **Step 5: Verify with a real ePub file** Place a `.epub` file accessible, launch the app, click "打开 ePub 文件", select the file, verify: - Chinese text renders correctly - Pages advance with click/keyboard - TOC sidebar works - Font size adjustment works - Theme toggle works - Progress bar updates - Re-opening the same file restores position --- ## Self-Review Checklist 1. **Spec coverage:** All 12 features from spec are covered: open file ✓, parse epub ✓, pagination ✓, navigation ✓, TOC ✓, reading position ✓, font size ✓, theme ✓, Chinese font ✓, bookmarks (UI stubs in toolbar, data model complete), settings persistence ✓, recent files ✓. 2. **Placeholder scan:** No TBD/TODO. All code is complete, no vague descriptions. 3. **Type consistency:** `Book`, `Section`, `TocEntry`, `Settings`, `Theme`, `Bookmark`, `ReadingPosition` — consistent across all tasks. `ReaderAction` return type connects Task 9 to Task 10. 4. **Ambiguity check:** Bookmark functionality has data model but UI toggles are noted as stubs (🔖 button exists, action reported back to App). This is minimal viable scope — bookmark saving/listing can be added as follow-up.