2026-05-13 23:50:39 +08:00
|
|
|
use eframe::egui;
|
|
|
|
|
use crate::book::Book;
|
|
|
|
|
use crate::theme::Theme;
|
|
|
|
|
|
|
|
|
|
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.0) as usize
|
|
|
|
|
} else {
|
|
|
|
|
1
|
|
|
|
|
};
|
|
|
|
|
let lines_per_page = if line_height > 0.0 {
|
|
|
|
|
(panel_height / line_height).max(1.0) 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 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,
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
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() {
|
|
|
|
|
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 };
|
|
|
|
|
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 (rect, response) = ui.allocate_at_least(ui.available_size(), egui::Sense::click());
|
|
|
|
|
|
|
|
|
|
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()
|
|
|
|
|
)
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// 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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
fn render_toc(
|
|
|
|
|
ui: &mut egui::Ui,
|
|
|
|
|
entries: &[crate::book::TocEntry],
|
|
|
|
|
_sections: &[crate::book::Section],
|
|
|
|
|
current_section: &mut usize,
|
|
|
|
|
) {
|
|
|
|
|
for entry in entries {
|
|
|
|
|
let is_current = entry.section == *current_section;
|
|
|
|
|
let response = if is_current {
|
|
|
|
|
ui.colored_label(egui::Color32::YELLOW, &entry.label)
|
|
|
|
|
} else {
|
|
|
|
|
ui.label(&entry.label)
|
|
|
|
|
};
|
|
|
|
|
if response.clicked() {
|
|
|
|
|
*current_section = entry.section;
|
|
|
|
|
}
|
|
|
|
|
if !entry.children.is_empty() {
|
|
|
|
|
ui.indent(&entry.label, |ui| {
|
|
|
|
|
render_toc(ui, &entry.children, _sections, current_section);
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[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]);
|
|
|
|
|
}
|
|
|
|
|
}
|