feat: 响应式分页引擎 - 基于egui Galley精确测量替代字符计数
This commit is contained in:
47
src/app.rs
47
src/app.rs
@@ -22,6 +22,7 @@ pub struct AppState {
|
|||||||
pub sidebar_open: bool,
|
pub sidebar_open: bool,
|
||||||
pub file_path: Option<PathBuf>,
|
pub file_path: Option<PathBuf>,
|
||||||
pub error_message: Option<String>,
|
pub error_message: Option<String>,
|
||||||
|
pub pending_anchor: Option<theme::ReadingPosition>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AppState {
|
impl AppState {
|
||||||
@@ -32,7 +33,7 @@ impl AppState {
|
|||||||
} else if self.current_section > 0 {
|
} else if self.current_section > 0 {
|
||||||
self.current_section -= 1;
|
self.current_section -= 1;
|
||||||
self.current_page = book.sections[self.current_section]
|
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) {
|
pub fn next_page(&mut self) {
|
||||||
if let Some(ref book) = self.book {
|
if let Some(ref book) = self.book {
|
||||||
if self.current_page + 1 < book.sections[self.current_section]
|
if self.current_page + 1 < book.sections[self.current_section]
|
||||||
.pages.len().saturating_sub(1)
|
.page_block_ranges.len()
|
||||||
{
|
{
|
||||||
self.current_page += 1;
|
self.current_page += 1;
|
||||||
} else if self.current_section + 1 < book.sections.len() {
|
} else if self.current_section + 1 < book.sections.len() {
|
||||||
@@ -64,6 +65,7 @@ impl App {
|
|||||||
sidebar_open: false,
|
sidebar_open: false,
|
||||||
file_path: None,
|
file_path: None,
|
||||||
error_message: None,
|
error_message: None,
|
||||||
|
pending_anchor: None,
|
||||||
},
|
},
|
||||||
settings,
|
settings,
|
||||||
settings_dir,
|
settings_dir,
|
||||||
@@ -78,7 +80,7 @@ impl App {
|
|||||||
match crate::book::load_epub(&path) {
|
match crate::book::load_epub(&path) {
|
||||||
Ok(book) => {
|
Ok(book) => {
|
||||||
let path_str = path.to_string_lossy().to_string();
|
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();
|
let mut recent = Vec::new();
|
||||||
recent.push(path_str.clone());
|
recent.push(path_str.clone());
|
||||||
for f in &self.settings.recent_files {
|
for f in &self.settings.recent_files {
|
||||||
@@ -91,8 +93,9 @@ impl App {
|
|||||||
}
|
}
|
||||||
self.settings.recent_files = recent;
|
self.settings.recent_files = recent;
|
||||||
self.state.book = Some(book);
|
self.state.book = Some(book);
|
||||||
self.state.current_section = pos.map(|p| p.section).unwrap_or(0);
|
self.state.current_section = pos.as_ref().map(|p| p.section).unwrap_or(0);
|
||||||
self.state.current_page = pos.map(|p| p.page).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.sidebar_open = false;
|
||||||
self.state.file_path = Some(path);
|
self.state.file_path = Some(path);
|
||||||
self.state.error_message = None;
|
self.state.error_message = None;
|
||||||
@@ -117,11 +120,27 @@ impl App {
|
|||||||
fn save_reading_position(&mut self) {
|
fn save_reading_position(&mut self) {
|
||||||
if let Some(ref path) = self.state.file_path {
|
if let Some(ref path) = self.state.file_path {
|
||||||
let path_str = path.to_string_lossy().to_string();
|
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(
|
self.settings.reading_positions.insert(
|
||||||
path_str,
|
path_str,
|
||||||
theme::ReadingPosition {
|
theme::ReadingPosition {
|
||||||
section: self.state.current_section,
|
section: self.state.current_section,
|
||||||
page: self.state.current_page,
|
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)
|
// Sync style changes back to active profile (outside closure)
|
||||||
if let Some(p) = self.settings.profiles.iter_mut()
|
if let Some(p) = self.settings.profiles.iter_mut()
|
||||||
.find(|p| p.name == self.settings.active_profile)
|
.find(|p| p.name == self.settings.active_profile)
|
||||||
|
|||||||
13
src/book.rs
13
src/book.rs
@@ -179,12 +179,18 @@ pub struct TocEntry {
|
|||||||
pub children: Vec<TocEntry>,
|
pub children: Vec<TocEntry>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct ContentBlock {
|
||||||
|
pub text: String,
|
||||||
|
pub is_heading: bool,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct Section {
|
pub struct Section {
|
||||||
pub title: String,
|
pub title: String,
|
||||||
pub content: String,
|
pub content: String,
|
||||||
/// Populated by pagination algorithm (pre-computed char offsets for page boundaries)
|
pub blocks: Vec<ContentBlock>,
|
||||||
pub pages: Vec<usize>,
|
pub page_block_ranges: Vec<(usize, usize)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq)]
|
#[derive(Debug, Clone, Copy, PartialEq)]
|
||||||
@@ -253,7 +259,8 @@ pub fn load_epub(path: impl AsRef<Path>) -> Result<Book, String> {
|
|||||||
sections.push(Section {
|
sections.push(Section {
|
||||||
title,
|
title,
|
||||||
content: text,
|
content: text,
|
||||||
pages: Vec::new(),
|
blocks: Vec::new(),
|
||||||
|
page_block_ranges: Vec::new(),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
422
src/reader.rs
422
src/reader.rs
@@ -1,26 +1,162 @@
|
|||||||
use eframe::egui;
|
use eframe::egui;
|
||||||
use crate::book::Book;
|
use crate::book::{Book, ContentBlock};
|
||||||
use crate::style::{StyleProfile, TextAlignment};
|
use crate::style::{StyleProfile, TextAlignment};
|
||||||
use crate::theme::{self, BgType, Theme};
|
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) {
|
fn parse_blocks(raw_text: &str) -> Vec<ContentBlock> {
|
||||||
let char_width = font_size * 1.0;
|
let mut blocks = Vec::new();
|
||||||
let safety_w = 0.95;
|
for paragraph in raw_text.split("\n\n") {
|
||||||
let safety_h = 1.0;
|
let trimmed = paragraph.trim();
|
||||||
let chars_per_line = if char_width > 0.0 {
|
if trimmed.is_empty() {
|
||||||
((panel_width / char_width) * safety_w).max(1.0) as usize
|
continue;
|
||||||
} else {
|
}
|
||||||
1
|
let is_heading = trimmed.contains('\x01');
|
||||||
};
|
let text = trimmed.replace('\x01', "").replace('\x02', "");
|
||||||
let lines_per_page = if line_height > 0.0 {
|
let text = text.trim().to_string();
|
||||||
((panel_height / line_height) * safety_h).max(1.0) as usize
|
if !text.is_empty() {
|
||||||
} else {
|
blocks.push(ContentBlock { text, is_heading });
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
|
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<usize> {
|
||||||
|
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<usize> {
|
||||||
|
let search = snippet.chars().take(30).collect::<String>();
|
||||||
|
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 {
|
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);
|
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 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));
|
let mut sidebar_tab: usize = ui.data_mut(|d| *d.get_temp_mut_or_default::<usize>(sidebar_tab_id));
|
||||||
|
|
||||||
@@ -93,7 +228,6 @@ pub fn reading_view(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- Top toolbar ---
|
|
||||||
egui::TopBottomPanel::top("reader_toolbar")
|
egui::TopBottomPanel::top("reader_toolbar")
|
||||||
.show_inside(ui, |ui| {
|
.show_inside(ui, |ui| {
|
||||||
ui.horizontal(|ui| {
|
ui.horizontal(|ui| {
|
||||||
@@ -152,16 +286,14 @@ egui::ComboBox::from_id_salt("bg_type_selector")
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// 在工具条之后、进度条之前用当前可用高度重新分页
|
|
||||||
let panel_size = ui.available_size();
|
let panel_size = ui.available_size();
|
||||||
let recalc_width = (panel_size.x - 48.0).max(100.0);
|
let recalc_width = (panel_size.x - 48.0).max(100.0);
|
||||||
let recalc_height = (panel_size.y - 45.0).max(200.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 total_pages = if *current_section < book.sections.len() {
|
||||||
let p = &book.sections[*current_section].pages;
|
let section = &book.sections[*current_section];
|
||||||
if p.len() > 1 { p.len() - 1 } else { 0 }
|
if section.page_block_ranges.is_empty() { 0 } else { section.page_block_ranges.len() }
|
||||||
} else { 0 };
|
} else { 0 };
|
||||||
|
|
||||||
egui::TopBottomPanel::bottom("reader_progress")
|
egui::TopBottomPanel::bottom("reader_progress")
|
||||||
@@ -172,11 +304,13 @@ egui::ComboBox::from_id_salt("bg_type_selector")
|
|||||||
.unwrap_or("");
|
.unwrap_or("");
|
||||||
ui.label(section_title);
|
ui.label(section_title);
|
||||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
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 prev_enabled = *current_page > 0 || *current_section > 0;
|
||||||
let next_enabled = (*current_page + 1 < book.sections[*current_section]
|
let next_enabled = if *current_section < book.sections.len() {
|
||||||
.pages.len().saturating_sub(1))
|
*current_page + 1 < book.sections[*current_section].page_block_ranges.len()
|
||||||
|| (*current_section + 1 < book.sections.len());
|
|| *current_section + 1 < book.sections.len()
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
};
|
||||||
|
|
||||||
let next_btn = egui::Button::new(
|
let next_btn = egui::Button::new(
|
||||||
egui::RichText::new("下一页 ▶").size(13.0)
|
egui::RichText::new("下一页 ▶").size(13.0)
|
||||||
@@ -223,78 +357,70 @@ egui::ComboBox::from_id_salt("bg_type_selector")
|
|||||||
let available = ui.available_size();
|
let available = ui.available_size();
|
||||||
let (rect, response) = ui.allocate_at_least(available, egui::Sense::click());
|
let (rect, response) = ui.allocate_at_least(available, egui::Sense::click());
|
||||||
|
|
||||||
// Add reading margins (inset)
|
|
||||||
let inset = 24.0;
|
let inset = 24.0;
|
||||||
let text_rect = egui::Rect::from_min_size(
|
let text_rect = egui::Rect::from_min_size(
|
||||||
egui::pos2(rect.min.x + inset, rect.min.y),
|
egui::pos2(rect.min.x + inset, rect.min.y),
|
||||||
egui::vec2((rect.width() - inset * 2.0).max(100.0), rect.height()),
|
egui::vec2((rect.width() - inset * 2.0).max(100.0), rect.height()),
|
||||||
);
|
);
|
||||||
|
|
||||||
// 分页已在工具条之后统一计算(使用当前可用高度),此处不再重复
|
|
||||||
if let Some(section) = book.sections.get(*current_section) {
|
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 {
|
if *current_page > max_page {
|
||||||
*current_page = max_page;
|
*current_page = max_page;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if let Some(section) = book.sections.get(*current_section) {
|
if let Some(section) = book.sections.get(*current_section) {
|
||||||
if *current_page < section.pages.len().saturating_sub(1) {
|
if *current_page < section.page_block_ranges.len() {
|
||||||
let start = section.pages[*current_page];
|
let (block_start, block_end) = section.page_block_ranges[*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();
|
|
||||||
|
|
||||||
let align = match style.alignment {
|
let indent_str = if style.first_line_indent > 0.0 {
|
||||||
TextAlignment::Left => egui::Align::LEFT,
|
"\u{3000}".repeat(style.first_line_indent as usize)
|
||||||
TextAlignment::Center => egui::Align::Center,
|
} else {
|
||||||
TextAlignment::Right => egui::Align::RIGHT,
|
String::new()
|
||||||
};
|
};
|
||||||
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(text_rect), |ui| {
|
|
||||||
ui.with_layout(
|
let line_height = measure_line_height(ui.ctx(), style.font_size);
|
||||||
egui::Layout::top_down(align).with_main_justify(false),
|
let para_spacing = style.paragraph_spacing.round().max(0.0) as f32 * line_height;
|
||||||
|ui| {
|
|
||||||
let mut is_heading = false;
|
let align = match style.alignment {
|
||||||
let mut text_start = 0usize;
|
TextAlignment::Left => egui::Align::LEFT,
|
||||||
let char_indices: Vec<_> = page_text.char_indices().collect();
|
TextAlignment::Center => egui::Align::Center,
|
||||||
let mut idx = 0;
|
TextAlignment::Right => egui::Align::RIGHT,
|
||||||
while idx < char_indices.len() {
|
};
|
||||||
let (byte_pos, ch) = char_indices[idx];
|
|
||||||
if ch == '\x01' || ch == '\x02' {
|
ui.allocate_new_ui(egui::UiBuilder::new().max_rect(text_rect), |ui| {
|
||||||
if byte_pos > text_start {
|
ui.spacing_mut().item_spacing.y = 0.0;
|
||||||
let text = &page_text[text_start..byte_pos];
|
ui.with_layout(
|
||||||
let mut rt = egui::RichText::new(text)
|
egui::Layout::top_down(align).with_main_justify(false),
|
||||||
.size(style.font_size)
|
|ui| {
|
||||||
.color(colors.text);
|
for i in block_start..block_end {
|
||||||
if is_heading {
|
if i > block_start {
|
||||||
rt = rt.strong();
|
ui.add_space(para_spacing);
|
||||||
}
|
}
|
||||||
ui.add(egui::Label::new(rt).wrap());
|
|
||||||
}
|
let block = §ion.blocks[i];
|
||||||
is_heading = ch == '\x01';
|
let display_text = if block.is_heading || indent_str.is_empty() {
|
||||||
idx += 1;
|
block.text.clone()
|
||||||
if idx < char_indices.len() {
|
} else {
|
||||||
text_start = char_indices[idx].0;
|
format!("{}{}", indent_str, block.text)
|
||||||
} else {
|
};
|
||||||
text_start = page_text.len();
|
|
||||||
}
|
if display_text.is_empty() {
|
||||||
} else {
|
continue;
|
||||||
idx += 1;
|
}
|
||||||
}
|
|
||||||
}
|
let mut rt = egui::RichText::new(&display_text)
|
||||||
if text_start < page_text.len() {
|
.size(style.font_size)
|
||||||
let text = &page_text[text_start..];
|
.color(colors.text);
|
||||||
let mut rt = egui::RichText::new(text)
|
if block.is_heading {
|
||||||
.size(style.font_size)
|
rt = rt.strong();
|
||||||
.color(colors.text);
|
}
|
||||||
if is_heading {
|
ui.add(egui::Label::new(rt).wrap());
|
||||||
rt = rt.strong();
|
}
|
||||||
}
|
},
|
||||||
ui.add(egui::Label::new(rt).wrap());
|
);
|
||||||
}
|
});
|
||||||
},
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
if has_bookmark {
|
if has_bookmark {
|
||||||
let painter = ui.painter();
|
let painter = ui.painter();
|
||||||
@@ -310,7 +436,6 @@ egui::ComboBox::from_id_salt("bg_type_selector")
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Click navigation
|
|
||||||
if response.clicked() {
|
if response.clicked() {
|
||||||
if let Some(click_pos) = response.interact_pointer_pos() {
|
if let Some(click_pos) = response.interact_pointer_pos() {
|
||||||
let x_ratio = (click_pos.x - rect.min.x) / rect.width();
|
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)) {
|
if ui.input(|i| i.key_pressed(egui::Key::ArrowRight)) {
|
||||||
action.page_next = true;
|
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<usize> {
|
|
||||||
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<char> = 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)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::*;
|
use super::*;
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pagination_empty() {
|
fn test_parse_blocks_empty() {
|
||||||
let pages = calculate_pages("", 10, 5);
|
let blocks = parse_blocks("");
|
||||||
assert_eq!(pages, vec![0]);
|
assert_eq!(blocks.len(), 1);
|
||||||
|
assert!(blocks[0].text.is_empty());
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pagination_shorter_than_page() {
|
fn test_parse_blocks_single_paragraph() {
|
||||||
let pages = calculate_pages("Hello World", 10, 10);
|
let blocks = parse_blocks("Hello World");
|
||||||
assert_eq!(pages, vec![0, 11]);
|
assert_eq!(blocks.len(), 1);
|
||||||
|
assert_eq!(blocks[0].text, "Hello World");
|
||||||
|
assert!(!blocks[0].is_heading);
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pagination_exact_fit() {
|
fn test_parse_blocks_multiple_paragraphs() {
|
||||||
let pages = calculate_pages("ABCD", 2, 2);
|
let blocks = parse_blocks("第一段\n\n第二段\n\n第三段");
|
||||||
assert_eq!(pages, vec![0, 4]);
|
assert_eq!(blocks.len(), 3);
|
||||||
|
assert_eq!(blocks[0].text, "第一段");
|
||||||
|
assert_eq!(blocks[1].text, "第二段");
|
||||||
|
assert_eq!(blocks[2].text, "第三段");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pagination_multiple_pages() {
|
fn test_parse_blocks_heading() {
|
||||||
let text = "A".repeat(100);
|
let blocks = parse_blocks("\x01标题\x02\n\n正文内容");
|
||||||
let pages = calculate_pages(&text, 10, 3);
|
assert_eq!(blocks.len(), 2);
|
||||||
assert_eq!(pages, vec![0, 30, 60, 90, 100]);
|
assert!(blocks[0].is_heading);
|
||||||
|
assert_eq!(blocks[0].text, "标题");
|
||||||
|
assert!(!blocks[1].is_heading);
|
||||||
|
assert_eq!(blocks[1].text, "正文内容");
|
||||||
}
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn test_pagination_single_char() {
|
fn test_parse_blocks_extra_newlines() {
|
||||||
let pages = calculate_pages("A", 10, 5);
|
let blocks = parse_blocks("段一\n\n\n\n段二");
|
||||||
assert_eq!(pages, vec![0, 1]);
|
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]);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -239,10 +239,12 @@ impl Default for Settings {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct ReadingPosition {
|
pub struct ReadingPosition {
|
||||||
pub section: usize,
|
pub section: usize,
|
||||||
pub page: usize,
|
pub page: usize,
|
||||||
|
pub block_index: Option<usize>,
|
||||||
|
pub text_snippet: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
|||||||
Reference in New Issue
Block a user