mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
M9: cache final RTL label layouts
Reuse wrapped and shaped RTL galleys across frames using a bounded, context-local cache keyed by the complete LayoutJob. Invalidate cached results when egui replaces its font-cache token, including font, DPI and atlas changes. Preserve existing wrapping and elision behavior. Add regression coverage for cross-frame reuse, layout parameter changes, font/scale/atlas invalidation and cache eviction, plus an opt-in benchmark. Validation: cargo xtask ci passed fmt, clippy, 590 tests, assets and layers. WASM checks are blocked by the missing wasm32-unknown-unknown standard library in the local Homebrew Rust toolchain (E0463). Arabic menu screenshots remained identical after ten additional frames. Manual benchmark: 200 wrapped labels took 31.46 ms uncached vs 0.082 ms with final-layout cache hits on this machine.
This commit is contained in:
1 parent
7f0d94b24f
commit
f52c591b87
2 files changed
+185
-3
No files matched your search
+1
-1
@@ -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) |
|
||||
|
||||
+184
-2
@@ -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<usize>) -> Layo
|
||||
visual
|
||||
}
|
||||
|
||||
fn layout(ctx: &egui::Context, mut job: LayoutJob) -> Arc<Galley> {
|
||||
const MAX_CACHED_LABELS: usize = 256;
|
||||
const MAX_CACHED_TEXT_BYTES: usize = 4096;
|
||||
|
||||
#[derive(Clone)]
|
||||
struct CachedLayout {
|
||||
galley: Arc<Galley>,
|
||||
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<Arc<Galley>>,
|
||||
entries: HashMap<u64, CachedLayout>,
|
||||
clock: u64,
|
||||
}
|
||||
|
||||
impl LayoutCache {
|
||||
fn lookup(&mut self, token: Arc<Galley>, key: u64, job: &LayoutJob) -> Option<Arc<Galley>> {
|
||||
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<Galley>) {
|
||||
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<Galley> {
|
||||
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::<LayoutCache>(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::<LayoutCache>(id).insert(key, Arc::clone(&galley)));
|
||||
galley
|
||||
}
|
||||
|
||||
fn layout_uncached(ctx: &egui::Context, mut job: LayoutJob) -> Arc<Galley> {
|
||||
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}", "🎨 🙂"] {
|
||||
|
||||
Reference in new issue
Block a user