feat: add create_style function with light/dark theme support

This commit is contained in:
Developer
2026-05-13 23:43:02 +08:00
parent 5bf11256b7
commit 17fbe7efbb
3 changed files with 1714 additions and 0 deletions

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,162 @@
# ePub Reader — Design Document
## Overview
A lightweight ePub reader desktop application built with Rust + egui, compiled as a single Windows executable (x86_64-pc-windows-gnu / MSYS2+MinGW). No console window. Built-in Chinese font support. Kindle-like reading experience with medium feature set.
## Tech Stack
| Layer | Choice |
|---|---|
| GUI Framework | `eframe` (egui official framework) |
| Rendering Backend | `wgpu` |
| ePub Parsing | `epub-rs` |
| Font | Bundled Noto Sans SC (or similar open-source Chinese font) |
| Build Target | `x86_64-pc-windows-gnu` |
| Binary | Single `.exe`, `#![windows_subsystem = "windows"]` |
## Core Data Model
```rust
struct AppState {
book: Option<Book>,
current_section: usize,
current_page: usize,
settings: Settings,
bookmarks: Vec<Bookmark>,
sidebar_open: bool,
}
struct Book {
title: String,
author: String,
cover: Option<Vec<u8>>,
sections: Vec<Section>,
toc: Vec<TocEntry>,
}
struct Section {
title: String,
content: String, // plain text (HTML tags stripped)
pages: Vec<usize>, // character offsets for page boundaries
}
struct Settings {
font_size: f32, // default 20.0
theme: Theme,
}
enum Theme { Light, Dark }
struct Bookmark {
section: usize,
page: usize,
label: String,
timestamp: i64,
}
struct TocEntry {
label: String,
section: usize,
children: Vec<TocEntry>,
}
```
## Architecture
```
┌──────────────────────────────────────────┐
│ eframe::App │
│ ┌──────────┐ ┌───────────────────────┐ │
│ │ WelcomePg │ │ ReadingView │ │
│ │ (no book) │ │ ├─ TopToolbar │ │
│ │ ──────── │ │ ├─ Sidebar (TOC) │ │
│ │ open btn │ │ ├─ TextArea │ │
│ │ recents │ │ └─ BottomProgressBar │ │
│ └──────────┘ └───────────────────────┘ │
│ ┌──────────────────────────────────────┐ │
│ │ AppState │ │
│ └──────────────────────────────────────┘ │
└──────────────────────────────────────────┘
```
## UI Layout
### Welcome Screen (no book open)
- Centered "Open ePub File" button
- Recently opened books list (read from settings file)
### Reading Screen (Kindle-like)
- **Top toolbar**: back button, book title, font size controls, menu, bookmark toggle, theme toggle
- **Left sidebar** (collapsible): table of contents tree
- **Center**: text content area with pagination
- Click left 30% → prev page, right 30% → next page, center 40% → toggle toolbar visibility
- Keyboard: ← → for prev/next page
- **Bottom bar**: current chapter name, progress slider, page number indicator
## Features (Medium Scope)
### Core
1. Open `.epub` file via native file dialog
2. Parse ePub (OPF manifest, NCX/TOC, Spine)
3. Render chapter content with pagination
4. Next/previous page navigation (click & keyboard)
5. Table of contents sidebar with chapter navigation
6. Remember last reading position (bookmark per file)
### Settings & Display
7. Font size adjustment (A⁺/A⁻)
8. Light/Dark theme toggle
9. Chinese font support (bundled Noto Sans SC)
### Bookmarks
10. Add/remove bookmarks at current page
11. Bookmark list view
### Persistence
12. `settings.json` saves: font_size, theme, recent files, reading positions, bookmarks
## Data Flow
1. User clicks "Open" → native file dialog → select `.epub`
2. `EpubLoader` reads zip, extracts OPF metadata, reads spine order, parses NCX for TOC
3. For each spine item: extract HTML content, strip tags → plain text stored in `Section.content`
4. On section load: calculate pagination based on current font_size + panel width → store page offsets
5. On page change: slice `Section.content[pages[current_page]..pages[current_page+1]]` → render to egui `Label`
6. On font_size change: recalculate all pagination for current section
7. On theme toggle: switch `egui::Style` between light/dark visuals
## Pagination Algorithm
```
fn paginate(text: &str, font_size: f32, panel_width: f32) -> Vec<usize>
chars_per_line = floor(panel_width / (font_size * 0.6))
lines_per_page = floor(panel_height / (font_size * 1.5))
chars_per_page = chars_per_line * lines_per_page
Iterate text, split at chars_per_page boundaries (prefer word/char boundaries)
```
## File Storage
- `settings.json` (next to exe OR in standard config dir)
- font_size, theme
- recent_files: Vec<{path, title}>
- reading_positions: HashMap<path, {section, page}>
- bookmarks: HashMap<path, Vec<Bookmark>>
- Last window size/position
## Error Handling
- ePub parsing failure → show error dialog with message
- File not found (recent list) → remove from list silently
- Font loading failure → fall back to system monospace
- Settings file corruption → reset to defaults, log warning
## Out of Scope (v1)
- Text search within book
- Highlighting / annotations
- Notes/export
- TTS
- Epub with complex CSS styling / reflowable layout math
- DRM-protected ePubs

View File

@@ -1,3 +1,5 @@
use eframe::egui;
use eframe::egui::Style;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)] #[derive(Debug, Clone, Copy, PartialEq, Serialize, Deserialize)]
@@ -12,6 +14,19 @@ impl Default for Theme {
} }
} }
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()
},
}
}
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Settings { pub struct Settings {
pub font_size: f32, pub font_size: f32,
@@ -69,6 +84,18 @@ mod tests {
assert_eq!(restored.theme, s.theme); assert_eq!(restored.theme, s.theme);
} }
#[test]
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] #[test]
fn test_theme_serialize() { fn test_theme_serialize() {
let json = serde_json::to_string(&Theme::Dark).unwrap(); let json = serde_json::to_string(&Theme::Dark).unwrap();