1526 lines
43 KiB
Markdown
1526 lines
43 KiB
Markdown
|
|
# 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("<p>Hello</p>"), "Hello");
|
||
|
|
}
|
||
|
|
|
||
|
|
#[test]
|
||
|
|
fn test_strip_html_nested_tags() {
|
||
|
|
assert_eq!(
|
||
|
|
strip_html("<div><p>Hello <b>World</b></p></div>"),
|
||
|
|
"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<TocEntry>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct Section {
|
||
|
|
pub title: String,
|
||
|
|
pub content: String,
|
||
|
|
pub pages: Vec<usize>,
|
||
|
|
}
|
||
|
|
|
||
|
|
#[derive(Debug, Clone)]
|
||
|
|
pub struct Book {
|
||
|
|
pub title: String,
|
||
|
|
pub author: String,
|
||
|
|
pub cover: Option<Vec<u8>>,
|
||
|
|
pub sections: Vec<Section>,
|
||
|
|
pub toc: Vec<TocEntry>,
|
||
|
|
}
|
||
|
|
```
|
||
|
|
|
||
|
|
- [ ] **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<String>,
|
||
|
|
pub reading_positions: std::collections::HashMap<String, ReadingPosition>,
|
||
|
|
pub bookmarks: std::collections::HashMap<String, Vec<Bookmark>>,
|
||
|
|
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<Path>) -> Result<Book, String> {
|
||
|
|
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<String> {
|
||
|
|
if let Some(start) = html.find("<title>") {
|
||
|
|
let rest = &html[start + 7..];
|
||
|
|
if let Some(end) = rest.find("</title>") {
|
||
|
|
return Some(strip_html(&rest[..end]).trim().to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
if let Some(start) = html.find("<h1") {
|
||
|
|
let rest = &html[start..];
|
||
|
|
if let Some(content_start) = rest.find('>') {
|
||
|
|
let inner = &rest[content_start + 1..];
|
||
|
|
if let Some(end) = inner.find("</h1>") {
|
||
|
|
return Some(strip_html(&inner[..end]).trim().to_string());
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
None
|
||
|
|
}
|
||
|
|
|
||
|
|
fn build_toc(
|
||
|
|
entries: &[epub::doc::TocEntry],
|
||
|
|
spine: &[String],
|
||
|
|
) -> Vec<TocEntry> {
|
||
|
|
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<usize> {
|
||
|
|
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<Settings, String> {
|
||
|
|
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<Book>,
|
||
|
|
pub current_section: usize,
|
||
|
|
pub current_page: usize,
|
||
|
|
pub sidebar_open: bool,
|
||
|
|
pub file_path: Option<PathBuf>,
|
||
|
|
pub error_message: Option<String>,
|
||
|
|
}
|
||
|
|
|
||
|
|
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<usize> = 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<usize> {
|
||
|
|
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.
|