From 9eadb37622025f9eaf7d9f1ccdcfbbd5992d76ff Mon Sep 17 00:00:00 2001 From: Andrii Volkov <60794004+volkovskey@users.noreply.github.com> Date: Wed, 14 Oct 2026 19:14:53 +0300 Subject: [PATCH] perf(ui): look for CJK names in edits, not on every pan Each frame checks the active document's names for CJK scripts, to install faces for them. The check was keyed on the revision, which pans and zooms bump too, so every frame of a pan read every layer name. In a 900 000-layer file that was ~70 ms a frame: panning ran at ~14 fps whatever the canvas cost. It now keeps the document version it read: the same version (a pan, a zoom) is skipped, and a new one reads only the layers that changed since (`Document::changed_ids`, sized by the edit), or every name past 10 000 changes. On that file the UI frame while panning went from ~71 ms to ~3 ms. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01YQxjkNSUJhTkTfY74aNeki --- crates/ui-egui/src/idle_tests.rs | 20 ++++++++++++++++++++ crates/ui-egui/src/lib.rs | 31 ++++++++++++++++++++++++------- 2 files changed, 44 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/idle_tests.rs b/crates/ui-egui/src/idle_tests.rs index 2d4e5757..3c71146d 100644 --- a/crates/ui-egui/src/idle_tests.rs +++ b/crates/ui-egui/src/idle_tests.rs @@ -288,3 +288,23 @@ fn an_animated_gif_wakes_only_the_presenter_and_only_at_its_frames() { h.app.ui.present = None; h.assert_idle("the presenter closed"); } + +#[test] +fn cjk_names_are_looked_for_in_edits_not_on_every_pan() { + let mut s = Session::new(); + let a = s.execute("shape.rectangle", &json!({"x": 0, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + let b = s.execute("shape.rectangle", &json!({"x": 20, "y": 0, "width": 10, "height": 10})).unwrap()["id"].clone(); + s.execute("node.set", &json!({"id": a, "name": "中文"})).unwrap(); + let mut h = Harness::new(s); + let ctx = h.ctx.clone(); + assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HAN, "the first look reads every name"); + // A pan or zoom changes the revision but not the document: its hundreds of thousands of + // names aren't read again (that took 70 ms a frame in a 900 000-layer file). + let revision = h.app.session.ds().unwrap().revision; + h.run("view.set", json!({"zoom": 2, "cx": 300, "cy": 200})); + assert_ne!(h.app.session.ds().unwrap().revision, revision); + assert_eq!(h.app.cjk_needed(&ctx, 0), 0); + // An edit is read: the layers it changed. + h.run("node.set", json!({"id": b, "name": "한국어"})); + assert_eq!(h.app.cjk_needed(&ctx, 0), crate::cjk::HANGUL); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 613f18f9..059ca364 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -156,6 +156,7 @@ mod windows_tests; use std::collections::BTreeSet; use std::path::PathBuf; +use std::sync::Arc; use figcraft_doc::NodeId; use figcraft_engine::Session; @@ -241,6 +242,9 @@ pub enum TitleBar { /// How often open documents get an autosave checkpoint (seconds). pub const AUTOSAVE_SECS: f64 = 30.0 * 60.0; +/// Most changed layers whose names are checked for CJK scripts one by one (beyond: all of them). +const CJK_CHANGES: usize = 10_000; + /// How long a toast shows (seconds). const TOAST_SECS: f64 = 3.0; @@ -483,8 +487,8 @@ pub struct FigApp { /// System faces for scripts the built-in fonts lack were added (after the font scan), with /// faces for these CJK scripts ([`cjk`]) in this UI language's order. script_fonts: Option<(u8, &'static str)>, - /// The tab (uid) and revision whose names were last checked for CJK scripts. - cjk_checked: Option<(u64, u64)>, + /// The tab (uid) and document version whose names were last checked for CJK scripts. + cjk_checked: Option<(u64, Arc)>, /// The language preference the UI strings follow (applied when it changes). language: Option, /// A field had the keyboard when the last frame ended. @@ -856,11 +860,24 @@ impl FigApp { }) }); let dragging = ctx.input(|i| i.pointer.any_down()); - if let Some(d) = self.session.docs.get(self.session.active).filter(|d| !dragging && self.cjk_checked != Some((d.uid, d.revision))) { - self.cjk_checked = Some((d.uid, d.revision)); - let doc = &d.doc; - let comments = doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).flat_map(|m| [&m.author, &m.text]); - need |= std::iter::once(&doc.name).chain(doc.nodes().map(|n| &n.name)).chain(comments).fold(0, |m, s| m | cjk::scripts(s)); + // Pans and zooms change the revision too: only a new version of the document is looked + // at, and only the layers it changed (a large file has hundreds of thousands of names). + let current = self.session.docs.get(self.session.active).filter(|_| !dragging).map(|d| (d.uid, d.doc.clone())); + if let Some((uid, doc)) = current { + let before = self.cjk_checked.take().filter(|(u, _)| *u == uid).map(|(_, b)| b); + if !before.as_ref().is_some_and(|b| Arc::ptr_eq(b, &doc)) { + let mut scan = |s: &str| need |= cjk::scripts(s); + scan(&doc.name); + match before.as_ref().and_then(|b| doc.changed_ids(b, CJK_CHANGES)) { + Some(ids) => ids.into_iter().filter_map(|id| doc.node(id)).for_each(|n| scan(&n.name)), + None => doc.nodes().for_each(|n| scan(&n.name)), + } + doc.comments.iter().flat_map(|c| std::iter::once(&c.first).chain(&c.replies)).for_each(|m| { + scan(&m.author); + scan(&m.text); + }); + } + self.cjk_checked = Some((uid, doc)); } need }