diff --git a/src/app.rs b/src/app.rs index 4c607c8..fc04537 100644 --- a/src/app.rs +++ b/src/app.rs @@ -22,6 +22,7 @@ pub struct AppState { pub sidebar_open: bool, pub file_path: Option, pub error_message: Option, + pub pending_anchor: Option, } impl AppState { @@ -32,7 +33,7 @@ impl AppState { } else if self.current_section > 0 { self.current_section -= 1; self.current_page = book.sections[self.current_section] - .pages.len().saturating_sub(2); + .page_block_ranges.len().saturating_sub(1); } } } @@ -40,7 +41,7 @@ impl AppState { pub fn next_page(&mut self) { if let Some(ref book) = self.book { if self.current_page + 1 < book.sections[self.current_section] - .pages.len().saturating_sub(1) + .page_block_ranges.len() { self.current_page += 1; } else if self.current_section + 1 < book.sections.len() { @@ -64,6 +65,7 @@ impl App { sidebar_open: false, file_path: None, error_message: None, + pending_anchor: None, }, settings, settings_dir, @@ -78,7 +80,7 @@ impl App { 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).copied(); + 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 { @@ -91,8 +93,9 @@ impl App { } 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.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; @@ -117,11 +120,27 @@ impl App { 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, }, ); } @@ -395,6 +414,24 @@ BgType::Custom(ref path) if !path.is_empty() => { } } + 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) diff --git a/src/book.rs b/src/book.rs index ab79f05..bdf6589 100644 --- a/src/book.rs +++ b/src/book.rs @@ -179,12 +179,18 @@ pub struct TocEntry { pub children: Vec, } +#[derive(Debug, Clone)] +pub struct ContentBlock { + pub text: String, + pub is_heading: bool, +} + #[derive(Debug, Clone)] pub struct Section { pub title: String, pub content: String, - /// Populated by pagination algorithm (pre-computed char offsets for page boundaries) - pub pages: Vec, + pub blocks: Vec, + pub page_block_ranges: Vec<(usize, usize)>, } #[derive(Debug, Clone, Copy, PartialEq)] @@ -253,7 +259,8 @@ pub fn load_epub(path: impl AsRef) -> Result { sections.push(Section { title, content: text, - pages: Vec::new(), + blocks: Vec::new(), + page_block_ranges: Vec::new(), }); } diff --git a/src/reader.rs b/src/reader.rs index cb6e163..54ac5ac 100644 --- a/src/reader.rs +++ b/src/reader.rs @@ -1,26 +1,162 @@ use eframe::egui; -use crate::book::Book; +use crate::book::{Book, ContentBlock}; use crate::style::{StyleProfile, TextAlignment}; use crate::theme::{self, BgType, Theme}; -pub fn recalculate_pages(book: &mut Book, font_size: f32, line_height: f32, panel_width: f32, panel_height: f32, style: &StyleProfile) { - let char_width = font_size * 1.0; - let safety_w = 0.95; - let safety_h = 1.0; - let chars_per_line = if char_width > 0.0 { - ((panel_width / char_width) * safety_w).max(1.0) as usize - } else { - 1 - }; - let lines_per_page = if line_height > 0.0 { - ((panel_height / line_height) * safety_h).max(1.0) as usize - } else { - 1 - }; - for section in &mut book.sections { - let styled = style.apply_to_text(§ion.content); - section.pages = calculate_pages(&styled, chars_per_line, lines_per_page); +fn parse_blocks(raw_text: &str) -> Vec { + let mut blocks = Vec::new(); + for paragraph in raw_text.split("\n\n") { + let trimmed = paragraph.trim(); + if trimmed.is_empty() { + continue; + } + let is_heading = trimmed.contains('\x01'); + let text = trimmed.replace('\x01', "").replace('\x02', ""); + let text = text.trim().to_string(); + if !text.is_empty() { + blocks.push(ContentBlock { text, is_heading }); + } } + if blocks.is_empty() { + blocks.push(ContentBlock { + text: String::new(), + is_heading: false, + }); + } + blocks +} + +fn measure_block_height( + ctx: &egui::Context, + text: &str, + font_size: f32, + available_width: f32, +) -> f32 { + if text.is_empty() || available_width <= 0.0 { + return 0.0; + } + let mut job = egui::text::LayoutJob::default(); + job.text = text.to_string(); + job.sections = vec![egui::text::LayoutSection { + leading_space: 0.0, + byte_range: 0..text.len(), + format: egui::text::TextFormat { + font_id: egui::FontId::proportional(font_size), + ..Default::default() + }, + }]; + job.wrap = egui::text::TextWrapping { + max_width: available_width, + ..Default::default() + }; + let galley = ctx.fonts(|f| f.layout_job(job)); + galley.size().y +} + +fn measure_line_height(ctx: &egui::Context, font_size: f32) -> f32 { + let test_text = "M"; + let mut job = egui::text::LayoutJob::default(); + job.text = test_text.to_string(); + job.sections = vec![egui::text::LayoutSection { + leading_space: 0.0, + byte_range: 0..test_text.len(), + format: egui::text::TextFormat { + font_id: egui::FontId::proportional(font_size), + ..Default::default() + }, + }]; + job.wrap = egui::text::TextWrapping { + max_width: 100000.0, + ..Default::default() + }; + let galley = ctx.fonts(|f| f.layout_job(job)); + galley.size().y +} + +pub fn recalculate_pages( + ctx: &egui::Context, + book: &mut Book, + font_size: f32, + panel_width: f32, + panel_height: f32, + style: &StyleProfile, +) { + let inset = 24.0; + let available_width = (panel_width - inset * 2.0).max(100.0); + let available_height = panel_height.max(100.0); + + let indent_str = if style.first_line_indent > 0.0 { + "\u{3000}".repeat(style.first_line_indent as usize) + } else { + String::new() + }; + + let line_height = measure_line_height(ctx, font_size); + let para_spacing = style.paragraph_spacing.round().max(0.0) as f32 * line_height; + + for section in &mut book.sections { + if section.blocks.is_empty() { + section.blocks = parse_blocks(§ion.content); + } + + let mut page_block_ranges: Vec<(usize, usize)> = Vec::new(); + let mut page_start_block: usize = 0; + let mut current_height: f32 = 0.0; + + for (i, block) in section.blocks.iter().enumerate() { + let display_text = if block.is_heading || indent_str.is_empty() { + block.text.clone() + } else { + format!("{}{}", indent_str, block.text) + }; + + let block_height = measure_block_height(ctx, &display_text, font_size, available_width); + + let spacing = if i > page_start_block && i > 0 { + para_spacing + } else { + 0.0 + }; + let needed = block_height + spacing; + + if current_height > 0.0 && current_height + needed > available_height && i > page_start_block { + page_block_ranges.push((page_start_block, i)); + page_start_block = i; + current_height = block_height; + } else { + current_height += needed; + } + } + + if page_start_block < section.blocks.len() { + page_block_ranges.push((page_start_block, section.blocks.len())); + } + + if page_block_ranges.is_empty() { + page_block_ranges.push((0, section.blocks.len().min(1))); + } + + section.page_block_ranges = page_block_ranges; + } +} + +pub fn find_page_for_block(section: &crate::book::Section, block_index: usize) -> Option { + for (page_idx, &(start, end)) in section.page_block_ranges.iter().enumerate() { + if block_index >= start && block_index < end { + return Some(page_idx); + } + } + None +} + +pub fn find_page_by_snippet(section: &crate::book::Section, snippet: &str) -> Option { + let search = snippet.chars().take(30).collect::(); + for (block_idx, block) in section.blocks.iter().enumerate() { + if block.text.contains(&search) { + return find_page_for_block(section, block_idx); + } + } + None } pub struct ReaderAction { @@ -61,7 +197,6 @@ pub fn reading_view( let has_bookmark = bookmarks.iter().any(|b| b.section == *current_section && b.page == *current_page); - // --- Sidebar (TOC + Bookmarks) --- let sidebar_tab_id = ui.make_persistent_id("sidebar_tab"); let mut sidebar_tab: usize = ui.data_mut(|d| *d.get_temp_mut_or_default::(sidebar_tab_id)); @@ -93,7 +228,6 @@ pub fn reading_view( }); } - // --- Top toolbar --- egui::TopBottomPanel::top("reader_toolbar") .show_inside(ui, |ui| { ui.horizontal(|ui| { @@ -152,16 +286,14 @@ egui::ComboBox::from_id_salt("bg_type_selector") }); }); - // 在工具条之后、进度条之前用当前可用高度重新分页 let panel_size = ui.available_size(); let recalc_width = (panel_size.x - 48.0).max(100.0); let recalc_height = (panel_size.y - 45.0).max(200.0); - recalculate_pages(book, style.font_size, style.line_height(), recalc_width, recalc_height, style); + recalculate_pages(ui.ctx(), book, style.font_size, recalc_width, recalc_height, style); - // --- 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 } + let section = &book.sections[*current_section]; + if section.page_block_ranges.is_empty() { 0 } else { section.page_block_ranges.len() } } else { 0 }; egui::TopBottomPanel::bottom("reader_progress") @@ -172,11 +304,13 @@ egui::ComboBox::from_id_salt("bg_type_selector") .unwrap_or(""); ui.label(section_title); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { - // Page turn buttons (right side of bottom bar) let prev_enabled = *current_page > 0 || *current_section > 0; - let next_enabled = (*current_page + 1 < book.sections[*current_section] - .pages.len().saturating_sub(1)) - || (*current_section + 1 < book.sections.len()); + let next_enabled = if *current_section < book.sections.len() { + *current_page + 1 < book.sections[*current_section].page_block_ranges.len() + || *current_section + 1 < book.sections.len() + } else { + false + }; let next_btn = egui::Button::new( egui::RichText::new("下一页 ▶").size(13.0) @@ -223,78 +357,70 @@ egui::ComboBox::from_id_salt("bg_type_selector") let available = ui.available_size(); let (rect, response) = ui.allocate_at_least(available, egui::Sense::click()); - // Add reading margins (inset) let inset = 24.0; let text_rect = egui::Rect::from_min_size( egui::pos2(rect.min.x + inset, rect.min.y), egui::vec2((rect.width() - inset * 2.0).max(100.0), rect.height()), ); -// 分页已在工具条之后统一计算(使用当前可用高度),此处不再重复 if let Some(section) = book.sections.get(*current_section) { - let max_page = section.pages.len().saturating_sub(2); + let max_page = section.page_block_ranges.len().saturating_sub(1); if *current_page > max_page { *current_page = max_page; } } 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 styled_full = style.apply_to_text(§ion.content); - let page_text: String = styled_full.chars().skip(start).take(end - start).collect(); + if *current_page < section.page_block_ranges.len() { + let (block_start, block_end) = section.page_block_ranges[*current_page]; - let align = match style.alignment { - TextAlignment::Left => egui::Align::LEFT, - TextAlignment::Center => egui::Align::Center, - TextAlignment::Right => egui::Align::RIGHT, - }; - ui.allocate_new_ui(egui::UiBuilder::new().max_rect(text_rect), |ui| { - ui.with_layout( - egui::Layout::top_down(align).with_main_justify(false), - |ui| { - let mut is_heading = false; - let mut text_start = 0usize; - let char_indices: Vec<_> = page_text.char_indices().collect(); - let mut idx = 0; - while idx < char_indices.len() { - let (byte_pos, ch) = char_indices[idx]; - if ch == '\x01' || ch == '\x02' { - if byte_pos > text_start { - let text = &page_text[text_start..byte_pos]; - let mut rt = egui::RichText::new(text) - .size(style.font_size) - .color(colors.text); - if is_heading { - rt = rt.strong(); - } - ui.add(egui::Label::new(rt).wrap()); - } - is_heading = ch == '\x01'; - idx += 1; - if idx < char_indices.len() { - text_start = char_indices[idx].0; - } else { - text_start = page_text.len(); - } - } else { - idx += 1; - } - } - if text_start < page_text.len() { - let text = &page_text[text_start..]; - let mut rt = egui::RichText::new(text) - .size(style.font_size) - .color(colors.text); - if is_heading { - rt = rt.strong(); - } - ui.add(egui::Label::new(rt).wrap()); - } - }, - ); - }); + let indent_str = if style.first_line_indent > 0.0 { + "\u{3000}".repeat(style.first_line_indent as usize) + } else { + String::new() + }; + + let line_height = measure_line_height(ui.ctx(), style.font_size); + let para_spacing = style.paragraph_spacing.round().max(0.0) as f32 * line_height; + + let align = match style.alignment { + TextAlignment::Left => egui::Align::LEFT, + TextAlignment::Center => egui::Align::Center, + TextAlignment::Right => egui::Align::RIGHT, + }; + + ui.allocate_new_ui(egui::UiBuilder::new().max_rect(text_rect), |ui| { + ui.spacing_mut().item_spacing.y = 0.0; + ui.with_layout( + egui::Layout::top_down(align).with_main_justify(false), + |ui| { + for i in block_start..block_end { + if i > block_start { + ui.add_space(para_spacing); + } + + let block = §ion.blocks[i]; + let display_text = if block.is_heading || indent_str.is_empty() { + block.text.clone() + } else { + format!("{}{}", indent_str, block.text) + }; + + if display_text.is_empty() { + continue; + } + + let mut rt = egui::RichText::new(&display_text) + .size(style.font_size) + .color(colors.text); + if block.is_heading { + rt = rt.strong(); + } + ui.add(egui::Label::new(rt).wrap()); + } + }, + ); + }); if has_bookmark { let painter = ui.painter(); @@ -310,7 +436,6 @@ egui::ComboBox::from_id_salt("bg_type_selector") } } - // 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(); @@ -322,7 +447,6 @@ egui::ComboBox::from_id_salt("bg_type_selector") } } - // Keyboard navigation if ui.input(|i| i.key_pressed(egui::Key::ArrowRight)) { action.page_next = true; } @@ -390,113 +514,49 @@ fn render_bookmarks( } } -pub fn calculate_pages(text: &str, chars_per_line: usize, lines_per_page: usize) -> Vec { - let mut pages = Vec::new(); - pages.push(0); - - if text.is_empty() || chars_per_line == 0 || lines_per_page == 0 { - return pages; - } - - let chars: Vec = text.chars().collect(); - let total = chars.len(); - if total == 0 { - return pages; - } - - let mut page_start: usize = 0; - let mut line_count: usize = 0; - let mut line_pos: usize = 0; - let mut i: usize = 0; - - while i < total { - let ch = chars[i]; - - if ch == '\n' { - line_count += 1; - line_pos = 0; - i += 1; - } else if line_pos >= chars_per_line { - line_count += 1; - line_pos = 0; - } else { - line_pos += 1; - i += 1; - } - - if line_count >= lines_per_page && i > page_start { - let window_back = chars_per_line.max(3); - let search_start = page_start.max(i.saturating_sub(window_back)); - let mut split = i; - for j in (search_start..i).rev() { - if j > 0 && chars[j] == '\n' && chars[j - 1] == '\n' { - split = j + 1; - break; - } - } - if split <= page_start { - split = i; - } - if split < total { - // Skip leading newlines to prevent starting page with blank line - while split < total && chars[split] == '\n' { - split += 1; - } - if split < total { - pages.push(split); - page_start = split; - line_count = 0; - line_pos = 0; - i = split; - } - } - } - } - - if *pages.last().unwrap() < total { - pages.push(total); - } - pages -} - #[cfg(test)] mod tests { use super::*; #[test] - fn test_pagination_empty() { - let pages = calculate_pages("", 10, 5); - assert_eq!(pages, vec![0]); + fn test_parse_blocks_empty() { + let blocks = parse_blocks(""); + assert_eq!(blocks.len(), 1); + assert!(blocks[0].text.is_empty()); } #[test] - fn test_pagination_shorter_than_page() { - let pages = calculate_pages("Hello World", 10, 10); - assert_eq!(pages, vec![0, 11]); + fn test_parse_blocks_single_paragraph() { + let blocks = parse_blocks("Hello World"); + assert_eq!(blocks.len(), 1); + assert_eq!(blocks[0].text, "Hello World"); + assert!(!blocks[0].is_heading); } #[test] - fn test_pagination_exact_fit() { - let pages = calculate_pages("ABCD", 2, 2); - assert_eq!(pages, vec![0, 4]); + fn test_parse_blocks_multiple_paragraphs() { + let blocks = parse_blocks("第一段\n\n第二段\n\n第三段"); + assert_eq!(blocks.len(), 3); + assert_eq!(blocks[0].text, "第一段"); + assert_eq!(blocks[1].text, "第二段"); + assert_eq!(blocks[2].text, "第三段"); } #[test] - fn test_pagination_multiple_pages() { - let text = "A".repeat(100); - let pages = calculate_pages(&text, 10, 3); - assert_eq!(pages, vec![0, 30, 60, 90, 100]); + fn test_parse_blocks_heading() { + let blocks = parse_blocks("\x01标题\x02\n\n正文内容"); + assert_eq!(blocks.len(), 2); + assert!(blocks[0].is_heading); + assert_eq!(blocks[0].text, "标题"); + assert!(!blocks[1].is_heading); + assert_eq!(blocks[1].text, "正文内容"); } #[test] - fn test_pagination_single_char() { - let pages = calculate_pages("A", 10, 5); - assert_eq!(pages, vec![0, 1]); + fn test_parse_blocks_extra_newlines() { + let blocks = parse_blocks("段一\n\n\n\n段二"); + assert_eq!(blocks.len(), 2); + assert_eq!(blocks[0].text, "段一"); + assert_eq!(blocks[1].text, "段二"); } - - #[test] - fn test_pagination_zero_chars_per_page() { - let pages = calculate_pages("test", 0, 5); - assert_eq!(pages, vec![0]); - } -} \ No newline at end of file +} diff --git a/src/theme.rs b/src/theme.rs index 879b721..3fdccb1 100644 --- a/src/theme.rs +++ b/src/theme.rs @@ -239,10 +239,12 @@ impl Default for Settings { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct ReadingPosition { pub section: usize, pub page: usize, + pub block_index: Option, + pub text_snippet: Option, } #[derive(Debug, Clone, Serialize, Deserialize)]