2026-05-13 23:50:39 +08:00
|
|
|
use eframe::egui;
|
|
|
|
|
use crate::book::Book;
|
2026-05-14 21:02:34 +08:00
|
|
|
use crate::style::{StyleProfile, TextAlignment};
|
2026-05-14 23:19:14 +08:00
|
|
|
use crate::theme::{self, BgType, Theme};
|
2026-05-13 23:50:39 +08:00
|
|
|
|
2026-05-15 20:47:31 +08:00
|
|
|
pub fn recalculate_pages(book: &mut Book, font_size: f32, line_height: f32, panel_width: f32, panel_height: f32, style: &StyleProfile) {
|
2026-05-15 20:00:22 +08:00
|
|
|
let char_width = font_size * 1.0;
|
2026-05-15 20:47:31 +08:00
|
|
|
let safety = 0.95;
|
2026-05-13 23:50:39 +08:00
|
|
|
let chars_per_line = if char_width > 0.0 {
|
2026-05-15 19:43:40 +08:00
|
|
|
((panel_width / char_width) * safety).max(1.0) as usize
|
2026-05-13 23:50:39 +08:00
|
|
|
} else {
|
|
|
|
|
1
|
|
|
|
|
};
|
|
|
|
|
let lines_per_page = if line_height > 0.0 {
|
2026-05-15 19:43:40 +08:00
|
|
|
((panel_height / line_height) * safety).max(1.0) as usize
|
2026-05-13 23:50:39 +08:00
|
|
|
} else {
|
|
|
|
|
1
|
|
|
|
|
};
|
|
|
|
|
let chars_per_page = chars_per_line * lines_per_page;
|
|
|
|
|
for section in &mut book.sections {
|
2026-05-15 20:47:31 +08:00
|
|
|
let styled = style.apply_to_text(§ion.content);
|
|
|
|
|
section.pages = calculate_pages(&styled, chars_per_page);
|
2026-05-13 23:50:39 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub struct ReaderAction {
|
|
|
|
|
pub go_back: bool,
|
|
|
|
|
pub toggle_theme: bool,
|
|
|
|
|
pub toggle_bookmark: bool,
|
2026-05-14 23:19:14 +08:00
|
|
|
pub switch_bg: Option<BgType>,
|
2026-05-14 21:08:31 +08:00
|
|
|
pub switch_to_profile: Option<String>,
|
2026-05-14 17:28:40 +08:00
|
|
|
pub page_next: bool,
|
|
|
|
|
pub page_prev: bool,
|
2026-05-13 23:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pub fn reading_view(
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
book: &mut Book,
|
|
|
|
|
current_section: &mut usize,
|
|
|
|
|
current_page: &mut usize,
|
|
|
|
|
sidebar_open: &mut bool,
|
2026-05-14 21:02:34 +08:00
|
|
|
style: &mut StyleProfile,
|
2026-05-13 23:50:39 +08:00
|
|
|
theme: &Theme,
|
2026-05-14 23:19:14 +08:00
|
|
|
bg_type: BgType,
|
2026-05-14 22:50:01 +08:00
|
|
|
_file_path: &str,
|
2026-05-14 21:08:31 +08:00
|
|
|
profile_names: &[String],
|
2026-05-15 11:27:22 +08:00
|
|
|
bookmarks: &[crate::theme::Bookmark],
|
|
|
|
|
) -> (ReaderAction, Option<usize>) {
|
2026-05-13 23:50:39 +08:00
|
|
|
let mut action = ReaderAction {
|
|
|
|
|
go_back: false,
|
|
|
|
|
toggle_theme: false,
|
|
|
|
|
toggle_bookmark: false,
|
2026-05-14 23:19:14 +08:00
|
|
|
switch_bg: None,
|
2026-05-14 21:08:31 +08:00
|
|
|
switch_to_profile: None,
|
2026-05-14 17:28:40 +08:00
|
|
|
page_next: false,
|
|
|
|
|
page_prev: false,
|
2026-05-13 23:50:39 +08:00
|
|
|
};
|
2026-05-15 11:27:22 +08:00
|
|
|
let mut jump_to_bookmark: Option<usize> = None;
|
2026-05-13 23:50:39 +08:00
|
|
|
|
2026-05-15 20:05:34 +08:00
|
|
|
let colors = theme::reader_colors(theme);
|
2026-05-15 20:00:22 +08:00
|
|
|
|
2026-05-15 20:05:34 +08:00
|
|
|
let has_bookmark = bookmarks.iter().any(|b| b.section == *current_section && b.page == *current_page);
|
2026-05-14 17:28:40 +08:00
|
|
|
|
2026-05-15 11:27:22 +08:00
|
|
|
// --- Sidebar (TOC + Bookmarks) ---
|
2026-05-15 11:32:04 +08:00
|
|
|
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::<usize>(sidebar_tab_id));
|
|
|
|
|
|
2026-05-13 23:50:39 +08:00
|
|
|
if *sidebar_open {
|
|
|
|
|
egui::SidePanel::left("toc_sidebar")
|
|
|
|
|
.resizable(true)
|
2026-05-15 11:27:22 +08:00
|
|
|
.default_width(240.0)
|
2026-05-13 23:50:39 +08:00
|
|
|
.show_inside(ui, |ui| {
|
2026-05-15 11:27:22 +08:00
|
|
|
ui.horizontal(|ui| {
|
|
|
|
|
let toc_response = ui.selectable_label(sidebar_tab == 0, "📋 目录");
|
|
|
|
|
let bm_response = ui.selectable_label(sidebar_tab == 1,
|
|
|
|
|
format!("🔖 书签 ({})", bookmarks.len())
|
|
|
|
|
);
|
2026-05-15 11:32:04 +08:00
|
|
|
if toc_response.clicked() {
|
|
|
|
|
sidebar_tab = 0;
|
|
|
|
|
ui.data_mut(|d| *d.get_temp_mut_or_default::<usize>(sidebar_tab_id) = 0);
|
|
|
|
|
}
|
|
|
|
|
if bm_response.clicked() {
|
|
|
|
|
sidebar_tab = 1;
|
|
|
|
|
ui.data_mut(|d| *d.get_temp_mut_or_default::<usize>(sidebar_tab_id) = 1);
|
|
|
|
|
}
|
2026-05-15 11:27:22 +08:00
|
|
|
});
|
2026-05-13 23:50:39 +08:00
|
|
|
ui.separator();
|
2026-05-15 11:27:22 +08:00
|
|
|
if sidebar_tab == 0 {
|
|
|
|
|
render_toc(ui, &book.toc, current_section, current_page);
|
|
|
|
|
} else {
|
|
|
|
|
render_bookmarks(ui, bookmarks, &mut jump_to_bookmark);
|
|
|
|
|
}
|
2026-05-13 23:50:39 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// --- Top toolbar ---
|
|
|
|
|
egui::TopBottomPanel::top("reader_toolbar")
|
|
|
|
|
.show_inside(ui, |ui| {
|
|
|
|
|
ui.horizontal(|ui| {
|
2026-05-14 17:28:40 +08:00
|
|
|
if ui.button("← 返回").on_hover_text("返回书架").clicked() {
|
2026-05-13 23:50:39 +08:00
|
|
|
action.go_back = true;
|
|
|
|
|
}
|
|
|
|
|
ui.separator();
|
|
|
|
|
ui.label(&book.title);
|
|
|
|
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
2026-05-14 17:28:40 +08:00
|
|
|
let (theme_icon, theme_hint) = match theme {
|
|
|
|
|
Theme::Dark => ("🌞", "切换到浅色主题"),
|
|
|
|
|
Theme::Light => ("🌙", "切换到夜间主题"),
|
|
|
|
|
Theme::Sepia => ("📜", "切换到棕褐色主题"),
|
2026-05-13 23:50:39 +08:00
|
|
|
};
|
2026-05-14 17:28:40 +08:00
|
|
|
if ui.button(theme_icon).on_hover_text(theme_hint).clicked() {
|
2026-05-13 23:50:39 +08:00
|
|
|
action.toggle_theme = true;
|
|
|
|
|
}
|
2026-05-15 11:27:22 +08:00
|
|
|
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() {
|
2026-05-13 23:50:39 +08:00
|
|
|
action.toggle_bookmark = true;
|
|
|
|
|
}
|
2026-05-14 17:28:40 +08:00
|
|
|
if ui.button("A⁻").on_hover_text("缩小字体").clicked() {
|
2026-05-14 21:02:34 +08:00
|
|
|
style.font_size = (style.font_size - 2.0).max(10.0);
|
2026-05-13 23:50:39 +08:00
|
|
|
}
|
2026-05-14 17:28:40 +08:00
|
|
|
if ui.button("A⁺").on_hover_text("放大字体").clicked() {
|
2026-05-14 21:02:34 +08:00
|
|
|
style.font_size = (style.font_size + 2.0).min(48.0);
|
|
|
|
|
}
|
2026-05-14 21:08:31 +08:00
|
|
|
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());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-14 23:19:14 +08:00
|
|
|
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));
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
});
|
2026-05-14 17:28:40 +08:00
|
|
|
if ui.button("☰").on_hover_text("打开/关闭目录").clicked() {
|
2026-05-13 23:50:39 +08:00
|
|
|
*sidebar_open = !*sidebar_open;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 20:09:54 +08:00
|
|
|
// 在工具条之后、进度条之前用当前可用高度重新分页
|
|
|
|
|
let panel_size = ui.available_size();
|
|
|
|
|
let recalc_width = (panel_size.x - 48.0).max(100.0);
|
|
|
|
|
let recalc_height = (panel_size.y - 60.0).max(200.0);
|
2026-05-15 20:47:31 +08:00
|
|
|
recalculate_pages(book, style.font_size, style.line_height(), recalc_width, recalc_height, style);
|
2026-05-15 20:09:54 +08:00
|
|
|
|
2026-05-13 23:50:39 +08:00
|
|
|
// --- 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| {
|
2026-05-14 17:28:40 +08:00
|
|
|
// 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_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();
|
|
|
|
|
|
2026-05-13 23:50:39 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-14 22:50:01 +08:00
|
|
|
// --- 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());
|
2026-05-14 20:49:27 +08:00
|
|
|
|
2026-05-14 22:50:01 +08:00
|
|
|
// Add reading margins (inset)
|
2026-05-15 20:00:22 +08:00
|
|
|
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()),
|
|
|
|
|
);
|
2026-05-13 23:50:39 +08:00
|
|
|
|
2026-05-15 20:09:54 +08:00
|
|
|
// 分页已在工具条之后统一计算(使用当前可用高度),此处不再重复
|
2026-05-15 20:05:34 +08:00
|
|
|
if let Some(section) = book.sections.get(*current_section) {
|
|
|
|
|
let max_page = section.pages.len().saturating_sub(2);
|
|
|
|
|
if *current_page > max_page {
|
|
|
|
|
*current_page = max_page;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 20:00:22 +08:00
|
|
|
if let Some(section) = book.sections.get(*current_section) {
|
2026-05-13 23:50:39 +08:00
|
|
|
if *current_page < section.pages.len().saturating_sub(1) {
|
|
|
|
|
let start = section.pages[*current_page];
|
|
|
|
|
let end = section.pages[*current_page + 1];
|
2026-05-15 20:47:31 +08:00
|
|
|
let styled_full = style.apply_to_text(§ion.content);
|
|
|
|
|
let page_text: String = styled_full.chars().skip(start).take(end - start).collect();
|
2026-05-14 21:02:34 +08:00
|
|
|
|
|
|
|
|
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| {
|
2026-05-15 12:09:11 +08:00
|
|
|
let mut is_heading = false;
|
2026-05-15 13:44:46 +08:00
|
|
|
let mut text_start = 0usize;
|
2026-05-15 20:47:31 +08:00
|
|
|
let char_indices: Vec<_> = page_text.char_indices().collect();
|
2026-05-15 13:44:46 +08:00
|
|
|
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 {
|
2026-05-15 20:47:31 +08:00
|
|
|
let text = &page_text[text_start..byte_pos];
|
2026-05-15 12:09:11 +08:00
|
|
|
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());
|
|
|
|
|
}
|
2026-05-15 13:44:46 +08:00
|
|
|
is_heading = ch == '\x01';
|
|
|
|
|
idx += 1;
|
|
|
|
|
if idx < char_indices.len() {
|
|
|
|
|
text_start = char_indices[idx].0;
|
2026-05-15 12:09:11 +08:00
|
|
|
} else {
|
2026-05-15 20:47:31 +08:00
|
|
|
text_start = page_text.len();
|
2026-05-15 12:09:11 +08:00
|
|
|
}
|
|
|
|
|
} else {
|
2026-05-15 13:44:46 +08:00
|
|
|
idx += 1;
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-05-15 20:47:31 +08:00
|
|
|
if text_start < page_text.len() {
|
|
|
|
|
let text = &page_text[text_start..];
|
2026-05-15 13:44:46 +08:00
|
|
|
let mut rt = egui::RichText::new(text)
|
|
|
|
|
.size(style.font_size)
|
|
|
|
|
.color(colors.text);
|
|
|
|
|
if is_heading {
|
|
|
|
|
rt = rt.strong();
|
2026-05-15 12:09:11 +08:00
|
|
|
}
|
2026-05-15 13:44:46 +08:00
|
|
|
ui.add(egui::Label::new(rt).wrap());
|
2026-05-15 12:09:11 +08:00
|
|
|
}
|
2026-05-14 21:02:34 +08:00
|
|
|
},
|
|
|
|
|
);
|
2026-05-13 23:50:39 +08:00
|
|
|
});
|
2026-05-15 11:27:22 +08:00
|
|
|
|
|
|
|
|
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,
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-05-13 23:50:39 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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 {
|
2026-05-14 17:28:40 +08:00
|
|
|
action.page_prev = true;
|
2026-05-13 23:50:39 +08:00
|
|
|
} else if x_ratio > 0.7 {
|
2026-05-14 17:28:40 +08:00
|
|
|
action.page_next = true;
|
2026-05-13 23:50:39 +08:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Keyboard navigation
|
|
|
|
|
if ui.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
|
2026-05-14 17:28:40 +08:00
|
|
|
action.page_next = true;
|
2026-05-13 23:50:39 +08:00
|
|
|
}
|
|
|
|
|
if ui.input(|i| i.key_pressed(egui::Key::ArrowLeft)) {
|
2026-05-14 17:28:40 +08:00
|
|
|
action.page_prev = true;
|
2026-05-13 23:50:39 +08:00
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
|
2026-05-15 11:27:22 +08:00
|
|
|
(action, jump_to_bookmark)
|
2026-05-13 23:50:39 +08:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_toc(
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
entries: &[crate::book::TocEntry],
|
|
|
|
|
current_section: &mut usize,
|
2026-05-14 17:28:40 +08:00
|
|
|
current_page: &mut usize,
|
2026-05-13 23:50:39 +08:00
|
|
|
) {
|
|
|
|
|
for entry in entries {
|
|
|
|
|
let is_current = entry.section == *current_section;
|
2026-05-14 17:28:40 +08:00
|
|
|
let label_text = if is_current {
|
|
|
|
|
egui::RichText::new(&entry.label).color(egui::Color32::YELLOW).strong()
|
2026-05-13 23:50:39 +08:00
|
|
|
} else {
|
2026-05-14 17:28:40 +08:00
|
|
|
egui::RichText::new(&entry.label)
|
2026-05-13 23:50:39 +08:00
|
|
|
};
|
2026-05-14 17:28:40 +08:00
|
|
|
let response = ui.add(
|
|
|
|
|
egui::Button::new(label_text)
|
|
|
|
|
.frame(false)
|
|
|
|
|
.wrap()
|
|
|
|
|
);
|
2026-05-13 23:50:39 +08:00
|
|
|
if response.clicked() {
|
|
|
|
|
*current_section = entry.section;
|
2026-05-14 17:28:40 +08:00
|
|
|
*current_page = 0;
|
2026-05-13 23:50:39 +08:00
|
|
|
}
|
2026-05-14 17:28:40 +08:00
|
|
|
response.on_hover_text(format!("跳转到: {}", entry.label));
|
2026-05-13 23:50:39 +08:00
|
|
|
if !entry.children.is_empty() {
|
|
|
|
|
ui.indent(&entry.label, |ui| {
|
2026-05-14 17:28:40 +08:00
|
|
|
render_toc(ui, &entry.children, current_section, current_page);
|
2026-05-13 23:50:39 +08:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-15 11:27:22 +08:00
|
|
|
fn render_bookmarks(
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
bookmarks: &[crate::theme::Bookmark],
|
|
|
|
|
jump_to_bookmark: &mut Option<usize>,
|
|
|
|
|
) {
|
|
|
|
|
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);
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-13 23:24:25 +08:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 20:43:29 +08:00
|
|
|
let chars: Vec<char> = text.chars().collect();
|
|
|
|
|
let total_chars = chars.len();
|
2026-05-13 23:24:25 +08:00
|
|
|
if total_chars <= chars_per_page {
|
|
|
|
|
pages.push(total_chars);
|
|
|
|
|
return pages;
|
|
|
|
|
}
|
|
|
|
|
|
2026-05-14 20:43:29 +08:00
|
|
|
let mut pos: usize = 0;
|
2026-05-13 23:24:25 +08:00
|
|
|
while pos < total_chars {
|
2026-05-14 20:43:29 +08:00
|
|
|
let next = pos + chars_per_page;
|
|
|
|
|
if next >= total_chars {
|
|
|
|
|
pages.push(total_chars);
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Search backward from next for paragraph (\n\n) or line (\n) breaks
|
|
|
|
|
let search_start = pos + chars_per_page / 2;
|
|
|
|
|
let search_end = (next + chars_per_page / 2).min(total_chars);
|
|
|
|
|
let mut split = next;
|
|
|
|
|
|
|
|
|
|
// Prefer double newline (paragraph), then single newline
|
|
|
|
|
let mut found = false;
|
|
|
|
|
for i in (search_start..search_end).rev() {
|
|
|
|
|
if chars[i] == '\n' && i > 0 && chars[i - 1] == '\n' {
|
|
|
|
|
split = i - 1;
|
|
|
|
|
found = true;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
if !found {
|
|
|
|
|
for i in (search_start..search_end).rev() {
|
|
|
|
|
if chars[i] == '\n' {
|
|
|
|
|
split = i + 1;
|
|
|
|
|
break;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if split <= pos {
|
|
|
|
|
split = next;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pos = split.min(total_chars);
|
2026-05-13 23:24:25 +08:00
|
|
|
pages.push(pos);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
pages
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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]);
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn test_pagination_zero_chars_per_page() {
|
|
|
|
|
let pages = calculate_pages("test", 0);
|
|
|
|
|
assert_eq!(pages, vec![0]);
|
|
|
|
|
}
|
2026-05-14 17:28:40 +08:00
|
|
|
}
|