use eframe::egui; use crate::book::{Book, ContentBlock}; use crate::style::{StyleProfile, TextAlignment}; use crate::theme::{self, BgType, Theme}; 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 mut heading_level = 0u8; let mut anchor: Option = None; let mut text = trimmed.to_string(); if text.contains('\x01') { if let Some(pos) = text.find('\x01') { let rest = &text[pos + 1..]; heading_level = rest .chars() .next() .and_then(|c| c.to_digit(10)) .unwrap_or(1) as u8; let after_level = &text[pos + 2..]; if let Some(anchor_start) = after_level.find('\x03') { if let Some(anchor_end) = after_level.find('\x04') { let anchor_str = &after_level[anchor_start + 1..anchor_end]; anchor = Some(anchor_str.to_string()); // Remove \x03\x04 from text to prevent anchor ID contamination let rm_start = pos + 2 + anchor_start; let rm_end = pos + 2 + anchor_end + 1; text.replace_range(rm_start..rm_end, ""); } } text.drain(pos..pos + 2); } text = text.replace('\x02', ""); } let text = text.trim().to_string(); if !text.is_empty() { blocks.push(ContentBlock { text, heading_level, anchor }); } } if blocks.is_empty() { blocks.push(ContentBlock { text: String::new(), heading_level: 0, anchor: None, }); } blocks } fn heading_font_size(base_size: f32, level: u8) -> f32 { match level { 1 => base_size * 1.6, 2 => base_size * 1.35, 3 => base_size * 1.15, 4 => base_size * 1.05, _ => base_size, } } fn heading_top_spacing(para_spacing: f32, level: u8) -> f32 { match level { 1 => para_spacing * 3.5, 2 => para_spacing * 3.0, 3 => para_spacing * 2.5, 4 => para_spacing * 2.0, _ => 0.0, } } 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 available_width = panel_width.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 block_font_size = if block.heading_level > 0 { heading_font_size(font_size, block.heading_level) } else { font_size }; let display_text = if block.heading_level > 0 || indent_str.is_empty() { block.text.clone() } else { format!("{}{}", indent_str, block.text) }; let block_height = measure_block_height(ctx, &display_text, block_font_size, available_width); let spacing = if i > page_start_block && i > 0 { if block.heading_level > 0 { para_spacing + heading_top_spacing(para_spacing, block.heading_level) } else { 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 fn find_page_for_anchor(section: &crate::book::Section, anchor: &str) -> Option { for (block_idx, block) in section.blocks.iter().enumerate() { if let Some(ref a) = block.anchor { if a == anchor { return find_page_for_block(section, block_idx); } } } None } pub struct ReaderAction { pub go_back: bool, pub toggle_theme: bool, pub toggle_bookmark: bool, pub switch_bg: Option, pub switch_to_profile: Option, pub switch_font: Option, pub page_next: bool, pub page_prev: bool, pub toggle_sidebar: bool, pub jump_to_section: Option, pub jump_to_anchor: Option, } pub fn reading_view( ui: &mut egui::Ui, book: &mut Book, current_section: &mut usize, current_page: &mut usize, sidebar_open: &mut bool, style: &mut StyleProfile, theme: &Theme, bg_type: BgType, _file_path: &str, profile_names: &[String], bookmarks: &[crate::theme::Bookmark], current_font: &str, ) -> (ReaderAction, Option) { let mut action = ReaderAction { go_back: false, toggle_theme: false, toggle_bookmark: false, switch_bg: None, switch_to_profile: None, switch_font: None, page_next: false, page_prev: false, toggle_sidebar: false, jump_to_section: None, jump_to_anchor: None, }; let mut jump_to_bookmark: Option = None; let colors = theme::reader_colors(theme); let has_bookmark = bookmarks.iter().any(|b| b.section == *current_section && b.page == *current_page); 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)); if *sidebar_open { egui::SidePanel::left("toc_sidebar") .resizable(true) .default_width(240.0) .show_inside(ui, |ui| { ui.horizontal(|ui| { let toc_response = ui.selectable_label(sidebar_tab == 0, "📋 目录"); let bm_response = ui.selectable_label(sidebar_tab == 1, format!("🔖 书签 ({})", bookmarks.len()) ); if toc_response.clicked() { sidebar_tab = 0; ui.data_mut(|d| *d.get_temp_mut_or_default::(sidebar_tab_id) = 0); } if bm_response.clicked() { sidebar_tab = 1; ui.data_mut(|d| *d.get_temp_mut_or_default::(sidebar_tab_id) = 1); } }); ui.separator(); if sidebar_tab == 0 { egui::ScrollArea::vertical().show(ui, |ui| { if let Some((section, anchor)) = render_toc(ui, &book.toc, *current_section) { action.jump_to_section = Some(section); action.jump_to_anchor = anchor; } }); } else { egui::ScrollArea::vertical().show(ui, |ui| { render_bookmarks(ui, bookmarks, &mut jump_to_bookmark); }); } }); } egui::TopBottomPanel::top("reader_toolbar") .show_inside(ui, |ui| { ui.horizontal(|ui| { if ui.button("← 返回").on_hover_text("返回书架").clicked() { action.go_back = true; } ui.separator(); ui.label(format!("《{}》", &book.title)); ui.label(format!("[{}]", book.layout.label())); ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { let (theme_icon, theme_hint) = match theme { Theme::Dark => ("🌞", "切换到浅色主题"), Theme::Light => ("🌙", "切换到夜间主题"), Theme::Sepia => ("📜", "切换到棕褐色主题"), }; if ui.button(theme_icon).on_hover_text(theme_hint).clicked() { action.toggle_theme = true; } let bookmark_icon = if has_bookmark { "🔴" } else { "🔖" }; let bookmark_hint = if has_bookmark { "移除书签" } else { "添加书签" }; if ui.button(bookmark_icon).on_hover_text(bookmark_hint).clicked() { action.toggle_bookmark = true; } if ui.button("A⁻").on_hover_text("缩小字体").clicked() { style.font_size = (style.font_size - 2.0).max(10.0); } if ui.button("A⁺").on_hover_text("放大字体").clicked() { style.font_size = (style.font_size + 2.0).min(48.0); } egui::ComboBox::from_id_salt("profile_selector") .width(110.0) .selected_text(&style.name) .show_ui(ui, |ui| { for name in profile_names { let selected = *name == style.name; if ui.selectable_label(selected, name).clicked() { action.switch_to_profile = Some(name.clone()); } } }); egui::ComboBox::from_id_salt("bg_type_selector") .width(100.0) .selected_text(bg_type.label()) .show_ui(ui, |ui| { for &label in BgType::ALL.iter() { let selected = bg_type.label() == label; if ui.selectable_label(selected, label).clicked() { action.switch_bg = Some(theme::BgType::from_label(label)); } } }); egui::ComboBox::from_id_salt("font_selector") .width(100.0) .selected_text(current_font) .show_ui(ui, |ui| { for name in crate::font::font_display_names() { let selected = name == current_font; if ui.selectable_label(selected, &name).clicked() { action.switch_font = Some(name); } } }); if ui.button("☰").on_hover_text("打开/关闭目录").clicked() { *sidebar_open = !*sidebar_open; } }); }); }); 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(ui.ctx(), book, style.font_size, recalc_width, recalc_height, style); let total_pages = if *current_section < book.sections.len() { 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") .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 prev_enabled = *current_page > 0 || *current_section > 0; 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) ).min_size(egui::vec2(100.0, 28.0)); let prev_btn = egui::Button::new( egui::RichText::new("◀ 上一页").size(13.0) ).min_size(egui::vec2(100.0, 28.0)); if !next_enabled { ui.add_enabled(false, next_btn); } else if ui.add(next_btn).on_hover_text("下一页 (→键)").clicked() { action.page_next = true; } ui.add_space(8.0); if !prev_enabled { ui.add_enabled(false, prev_btn); } else if ui.add(prev_btn).on_hover_text("上一页 (←键)").clicked() { action.page_prev = true; } ui.separator(); 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 }; if ui.add(egui::Slider::new(&mut progress, 0.0..=1.0).text("")).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 available = ui.available_size(); let (rect, response) = ui.allocate_at_least(available, egui::Sense::click()); 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.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.page_block_ranges.len() { let (block_start, block_end) = section.page_block_ranges[*current_page]; 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.heading_level > 0 || indent_str.is_empty() { block.text.clone() } else { format!("{}{}", indent_str, block.text) }; if display_text.is_empty() { continue; } if block.heading_level > 0 && i > block_start { ui.add_space(heading_top_spacing(para_spacing, block.heading_level)); } let block_font_size = if block.heading_level > 0 { heading_font_size(style.font_size, block.heading_level) } else { style.font_size }; let mut rt = egui::RichText::new(&display_text) .size(block_font_size) .color(colors.text); if block.heading_level >= 1 && block.heading_level <= 4 { rt = rt.strong(); } let label = egui::Label::new(rt).wrap(); if block.heading_level == 1 { ui.centered_and_justified(|ui| { ui.add(label); }); } else { ui.add(label); } } }, ); }); if has_bookmark { let painter = ui.painter(); let bookmark_pos = egui::pos2(rect.max.x - 30.0, rect.min.y + 10.0); painter.text( bookmark_pos, egui::Align2::RIGHT_TOP, "🔴", egui::FontId::proportional(18.0), egui::Color32::RED, ); } } } 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 { action.page_prev = true; } else if x_ratio > 0.7 { action.page_next = true; } } } if ui.input(|i| i.key_pressed(egui::Key::ArrowRight)) { action.page_next = true; } if ui.input(|i| i.key_pressed(egui::Key::ArrowLeft)) { action.page_prev = true; } if ui.input(|i| i.key_pressed(egui::Key::B)) { action.toggle_sidebar = true; } }); (action, jump_to_bookmark) } fn render_toc( ui: &mut egui::Ui, entries: &[crate::book::TocEntry], current_section: usize, ) -> Option<(usize, Option)> { let mut jump: Option<(usize, Option)> = None; for entry in entries { let label_text = egui::RichText::new(&entry.label); let response = ui.add( egui::Button::new(label_text) .frame(false) .wrap() ); if response.clicked() { jump = Some((entry.section, entry.anchor.clone())); } let anchor_info = entry.anchor.as_ref().map(|a| format!(" @{a}")).unwrap_or_default(); response.on_hover_text(format!("跳转到: {} (章节 {}{})", entry.label, entry.section, anchor_info)); if !entry.children.is_empty() { ui.indent(&entry.label, |ui| { if let Some(s) = render_toc(ui, &entry.children, current_section) { jump = Some(s); } }); } } jump } fn render_bookmarks( ui: &mut egui::Ui, bookmarks: &[crate::theme::Bookmark], jump_to_bookmark: &mut Option, ) { if bookmarks.is_empty() { ui.horizontal(|ui| { ui.label("暂无书签"); }); ui.label("点击工具栏 🔖 按钮添加书签"); return; } for (idx, bm) in bookmarks.iter().enumerate() { let label_text = egui::RichText::new(&bm.label).size(13.0); if ui.add( egui::Button::new(label_text) .frame(false) .wrap() ).on_hover_text("点击跳转到该书签").clicked() { *jump_to_bookmark = Some(idx); } } } #[cfg(test)] mod tests { use super::*; #[test] fn test_parse_blocks_empty() { let blocks = parse_blocks(""); assert_eq!(blocks.len(), 1); assert!(blocks[0].text.is_empty()); } #[test] 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_eq!(blocks[0].heading_level, 0); } #[test] 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_parse_blocks_heading() { let blocks = parse_blocks("\x011标题\x02\n\n正文内容"); assert_eq!(blocks.len(), 2); assert_eq!(blocks[0].heading_level, 1); assert_eq!(blocks[0].text, "标题"); assert_eq!(blocks[1].heading_level, 0); assert_eq!(blocks[1].text, "正文内容"); } #[test] fn test_parse_blocks_heading_levels() { let blocks = parse_blocks("\x011一级\x02\n\n\x012二级\x02\n\n\x013三级\x02\n\n正文"); assert_eq!(blocks.len(), 4); assert_eq!(blocks[0].heading_level, 1); assert_eq!(blocks[0].text, "一级"); assert_eq!(blocks[1].heading_level, 2); assert_eq!(blocks[1].text, "二级"); assert_eq!(blocks[2].heading_level, 3); assert_eq!(blocks[2].text, "三级"); assert_eq!(blocks[3].heading_level, 0); assert_eq!(blocks[3].text, "正文"); } #[test] 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_parse_blocks_heading_with_anchor() { // \x011\x03toc1\x04Chapter 1\x02 → heading_level=1, anchor="toc1", text="Chapter 1" let blocks = parse_blocks("\x011\x03toc1\x04Chapter 1\x02\n\nbody text"); assert_eq!(blocks.len(), 2); assert_eq!(blocks[0].heading_level, 1); assert_eq!(blocks[0].anchor.as_deref(), Some("toc1")); assert_eq!(blocks[0].text, "Chapter 1"); assert_eq!(blocks[1].heading_level, 0); assert_eq!(blocks[1].text, "body text"); } #[test] fn test_parse_blocks_heading_without_anchor() { // \x011Title\x02 → heading_level=1, anchor=None, text="Title" let blocks = parse_blocks("\x011Title\x02"); assert_eq!(blocks.len(), 1); assert_eq!(blocks[0].heading_level, 1); assert_eq!(blocks[0].anchor, None); assert_eq!(blocks[0].text, "Title"); } }