diff --git a/ROADMAP.md b/ROADMAP.md index 75447f4..70b6e09 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -206,7 +206,7 @@ same references), and from the app's JSON control channel. See [docs/agents.md]( | M6 | Files & export (native, IDML, PDF, PNG/JPEG) | in progress (IDML ✅, PDF export) | | M7 | Long documents (sections, TOC, index, footnotes, books) | in progress (sections, TOC, text variables/running heads, hyperlinks, bookmarks, footnotes, cross-references, index) | | M8 | Tables | ✅ core (table/cell styles, rotation, diagonal lines pending) | -| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, culling, parallel compose 6.7× faster, perf harness) | +| M9 | Performance (MT composition, tiles) | in progress (glyph path cache, bounded RTL label layout cache, culling, parallel compose 6.7× faster, perf harness) | | M10 | Find/Change, spelling, Preflight | in progress (Find/Change ✅, spelling ✅, Preflight) | | M11 | Layout power features (liquid/alternate layouts, data merge) | started (data merge, step & repeat, snippets) | | M12 | Interactive & digital (EPUB, HTML, interactive PDF) | started (EPUB, PDF links/bookmarks) | diff --git a/crates/ui-egui/src/rtl.rs b/crates/ui-egui/src/rtl.rs index bc042c7..2521aae 100644 --- a/crates/ui-egui/src/rtl.rs +++ b/crates/ui-egui/src/rtl.rs @@ -2,7 +2,7 @@ //! Source strings stay logical. Never use these galleys for editable text/caret mapping. use egui::text::{LayoutJob, TextWrapping}; use egui::{Align, Align2, Color32, FontFamily, FontId, Galley, Pos2, Rect, WidgetText}; -use std::{ops::Range, sync::Arc}; +use std::{collections::HashMap, ops::Range, sync::Arc}; use unicode_bidi::{BidiClass, BidiInfo, bidi_class}; use unicode_segmentation::UnicodeSegmentation; @@ -138,7 +138,72 @@ fn visual_line(job: &LayoutJob, bidi: &BidiInfo<'_>, line: Range) -> Layo visual } -fn layout(ctx: &egui::Context, mut job: LayoutJob) -> Arc { +const MAX_CACHED_LABELS: usize = 256; +const MAX_CACHED_TEXT_BYTES: usize = 4096; + +#[derive(Clone)] +struct CachedLayout { + galley: Arc, + last_used: u64, +} + +#[derive(Clone, Default)] +struct LayoutCache { + // A tiny galley owned by egui's font cache acts as a lifetime token. Font changes, + // DPI changes, atlas rebuilds and eviction replace it. Holding the old Arc prevents + // address reuse. Unlike a font-definition hash, this also detects atlas resets. + font_token: Option>, + entries: HashMap, + clock: u64, +} + +impl LayoutCache { + fn lookup(&mut self, token: Arc, key: u64, job: &LayoutJob) -> Option> { + if self.font_token.as_ref().is_none_or(|old| !Arc::ptr_eq(old, &token)) { + self.entries.clear(); + self.font_token = Some(token); + } + self.clock = self.clock.saturating_add(1); + let entry = self.entries.get_mut(&key)?; + // Check equality as well as the hash: collisions must never display another label. + if *entry.galley.job != *job { + return None; + } + entry.last_used = self.clock; + Some(Arc::clone(&entry.galley)) + } + + fn insert(&mut self, key: u64, galley: Arc) { + if self.entries.len() >= MAX_CACHED_LABELS + && let Some(oldest) = self.entries.iter().min_by_key(|(_, entry)| entry.last_used).map(|(key, _)| *key) + { + self.entries.remove(&oldest); + } + self.entries.insert(key, CachedLayout { galley, last_used: self.clock }); + } +} + +fn layout(ctx: &egui::Context, job: LayoutJob) -> Arc { + if !may_have_rtl(&job.text) { + return ctx.fonts_mut(|fonts| fonts.layout_job(job)); + } + // Keep unusually large/dynamic content from filling a UI-label cache. + if job.text.len() > MAX_CACHED_TEXT_BYTES || job.sections.len() > 256 { + return layout_uncached(ctx, job); + } + let token = ctx.fonts_mut(|fonts| fonts.layout_no_wrap(String::new(), FontId::default(), Color32::TRANSPARENT)); + let id = egui::Id::new("designcraft.rtl.layout-cache"); + let key = egui::util::hash(&job); + let cached = ctx.data_mut(|data| data.get_temp_mut_or_default::(id).lookup(token, key, &job)); + if let Some(galley) = cached { + return galley; + } + let galley = layout_uncached(ctx, job); + ctx.data_mut(|data| data.get_temp_mut_or_default::(id).insert(key, Arc::clone(&galley))); + galley +} + +fn layout_uncached(ctx: &egui::Context, mut job: LayoutJob) -> Arc { if !has_rtl(&job.text) { return ctx.fonts_mut(|fonts| fonts.layout_job(job)); } @@ -246,6 +311,123 @@ mod tests { galley.rows.iter().flat_map(|row| row.glyphs.iter().filter(|g| g.advance_width > 0.0).map(|g| g.chr)).collect() } + fn cache_job() -> LayoutJob { + LayoutJob::simple("ملف (PDF 123) جديد للنشر والطباعة".repeat(4), FontId::proportional(15.0), Color32::WHITE, 150.0) + } + + #[test] + fn cache_reuses_final_layout_across_frames_and_matches_uncached_output() { + with_fonts(|ctx| { + let job = cache_job(); + let first = layout(ctx, job.clone()); + let reference = layout_uncached(ctx, job.clone()); + assert_eq!(displayed(&first), displayed(&reference)); + assert_eq!(first.rect, reference.rect); + assert_eq!(first.rows.len(), reference.rows.len()); + assert!(Arc::ptr_eq(&first, &layout(ctx, job.clone()))); + ctx.end_pass().textures_delta.clear(); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() }); + assert!(Arc::ptr_eq(&first, &layout(ctx, job))); + }); + } + + #[test] + fn cache_respects_text_formatting_wrapping_and_elision() { + with_fonts(|ctx| { + let job = cache_job(); + let first = layout(ctx, job.clone()); + let mut variants = Vec::new(); + let mut changed = job.clone(); + changed.wrap.max_width = 80.0; + variants.push(changed); + let mut changed = job.clone(); + changed.wrap.max_rows = 1; + variants.push(changed); + let mut changed = job.clone(); + changed.sections[0].format.font_id.size = 24.0; + variants.push(changed); + let mut changed = job.clone(); + changed.sections[0].format.color = Color32::RED; + variants.push(changed); + variants.push(LayoutJob::simple_singleline("עברית 123".into(), FontId::default(), Color32::WHITE)); + for changed in variants { + let cached = layout(ctx, changed.clone()); + let reference = layout_uncached(ctx, changed); + assert!(!Arc::ptr_eq(&first, &cached)); + assert_eq!(cached.job, reference.job); + assert_eq!(cached.rect, reference.rect); + assert_eq!(cached.elided, reference.elided); + assert_eq!(displayed(&cached), displayed(&reference)); + } + }); + } + + #[test] + fn cache_invalidates_when_fonts_or_scale_change() { + with_fonts(|ctx| { + let job = cache_job(); + let first = layout(ctx, job.clone()); + ctx.end_pass().textures_delta.clear(); + ctx.set_pixels_per_point(2.0); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() }); + let scaled = layout(ctx, job.clone()); + assert!(!Arc::ptr_eq(&first, &scaled)); + ctx.end_pass().textures_delta.clear(); + let mut fonts = ctx.fonts(|fonts| fonts.definitions().clone()); + fonts.families.insert(FontFamily::Name("cache-test".into()), Vec::new()); + ctx.set_fonts(fonts); + ctx.begin_pass(egui::RawInput { max_texture_side: Some(8192), ..Default::default() }); + let new_fonts = layout(ctx, job.clone()); + assert!(!Arc::ptr_eq(&scaled, &new_fonts)); + ctx.end_pass().textures_delta.clear(); + // Changing atlas options recreates the atlas without changing font definitions. + ctx.begin_pass(egui::RawInput { max_texture_side: Some(4096), ..Default::default() }); + let new_atlas = layout(ctx, job.clone()); + assert!(!Arc::ptr_eq(&new_fonts, &new_atlas)); + assert_eq!(displayed(&new_atlas), displayed(&layout_uncached(ctx, job))); + }); + } + + #[test] + fn cache_is_bounded_and_font_token_replacement_invalidates_entries() { + with_fonts(|ctx| { + let job = cache_job(); + let galley = layout_uncached(ctx, job.clone()); + let token = ctx.fonts_mut(|fonts| fonts.layout_no_wrap(String::new(), FontId::default(), Color32::TRANSPARENT)); + let mut cache = LayoutCache::default(); + cache.lookup(token, 0, &job); + for key in 0..=MAX_CACHED_LABELS as u64 { + cache.clock += 1; + cache.insert(key, Arc::clone(&galley)); + } + assert_eq!(cache.entries.len(), MAX_CACHED_LABELS); + assert!(!cache.entries.contains_key(&0)); + // Replacing the font-cache token models atlas recreation, even with identical fonts. + let replacement = Arc::new((*galley).clone()); + assert!(cache.lookup(replacement, 1, &job).is_none()); + assert!(cache.entries.is_empty()); + }); + } + + #[test] + #[ignore = "manual timing comparison, no machine-dependent pass threshold"] + fn benchmark_cached_wrapping() { + with_fonts(|ctx| { + let job = cache_job(); + let _ = layout(ctx, job.clone()); + let start = std::time::Instant::now(); + for _ in 0..200 { + std::hint::black_box(layout_uncached(ctx, job.clone())); + } + let uncached = start.elapsed(); + let start = std::time::Instant::now(); + for _ in 0..200 { + std::hint::black_box(layout(ctx, job.clone())); + } + eprintln!("200 wrapped labels: uncached={uncached:?}, cached={:?}", start.elapsed()); + }); + } + #[test] fn ordinary_multilingual_labels_skip_bidi_analysis() { for text in ["", "File (123)", "Édition", "中文排版", "スウォッチ", "e\u{301}", "🎨 🙂"] {