feat: 响应式分页引擎 - 基于egui Galley精确测量替代字符计数
This commit is contained in:
422
src/reader.rs
422
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<ContentBlock> {
|
||||
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<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 {
|
||||
@@ -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::<usize>(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<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)]
|
||||
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]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user