From f24a8312f717589ed27d3cbcb0db3a9cf392f193 Mon Sep 17 00:00:00 2001 From: samhyun Date: Wed, 28 Oct 2026 10:31:02 +0900 Subject: [PATCH 1/3] M8: the browser refines a zoom only once it has paused MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The browser renders on the UI thread. A zoom showed its coarse preview and then, in the very next frame, ran the full render, so every wheel step of a continuous zoom had a blocking full render between it and the next: on a heavy page each one took seconds, and the zoom stuttered for as long as it went on. The browser's worker (`Inline`) now refines a preview only once no newer request has come for `SETTLE_MS` (120 ms) after the preview was shown: the zoom has paused. Each wheel step still shows its preview at once, and the worker asks for a repaint when the wait is over. Pans and edits have no preview and are refined at once, as before. The desktop has a render thread of its own and is unchanged. A zoom passing through a scale also no longer spends a full render there. The wait counts from the preview being handed over, not from the request: rendering the preview can itself take longer than the wait (the page is blocked meanwhile, so no newer request could have come), and counted from the request the full render would follow in the very next frame, as before. Measured in Chrome on a synthetic page of 20 images, 60 shadowed rectangles and two background blurs (a full render takes about 3.7 s, a preview about 250 ms), 20 wheel steps in and 20 out, 50 ms apart, three runs each: the frame interval's p95 went from 3.7 s to 240 ms, the frames over 100 ms from 79–80 (one full render per step) to 39–41 (one preview per step), and the zoom took 10–12 s instead of 141 s. The one full render after the pause still takes as long as before. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/canvas/render_worker.rs | 35 ++++++++++-- .../ui-egui/src/canvas/zoom_preview_tests.rs | 53 +++++++++++++++++-- docs/gaps.md | 5 ++ 3 files changed, 86 insertions(+), 7 deletions(-) diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 873e762a..0cd3e040 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -12,6 +12,8 @@ //! renders) nothing is spent up front: on the desktop a watchdog thread renders the preview once //! the render from scratch has run past [`PREVIEW_AFTER_MS`], beside it (the full render keeps the //! other cores), and drops it if the full image is there first. +//! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview +//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused). use std::sync::Arc; @@ -25,6 +27,10 @@ use web_time::Instant; pub const PREVIEW_FACTOR: f64 = 4.0; /// Full renders quicker than this (about a frame) show without a preview. pub const PREVIEW_AFTER_MS: f64 = 12.0; +/// The browser refines a zoom's preview only once no newer request came for this long: a full +/// render there blocks the page, and one between every two wheel steps of a zoom held it up for +/// seconds. +pub const SETTLE_MS: u64 = 120; #[derive(Clone)] pub struct Job { @@ -334,14 +340,21 @@ impl Worker { } /// The browser's worker: renders inline, one image a frame: a preview first when one is due, the -/// refined image in the next frame unless a newer request replaced the job. Built everywhere so -/// tests run it. +/// refined image once no newer request came for [`SETTLE_MS`] (a zoom has paused), right away +/// when no preview came first (a pan, an edit). Built everywhere so tests run it. pub struct Inline { ctx: egui::Context, pending: Option, /// Requests submitted so far. submitted: u64, previewed: bool, + /// Whether the pending job's preview was shown (its refinement then waits to settle). + preview_shown: bool, + /// When that preview was handed over: the wait counts from there, not from the request, since + /// rendering the preview itself can take longer than the wait (the page is blocked meanwhile, + /// so no newer request could have come). + since: Instant, + settle: std::time::Duration, renders: Renders, } @@ -350,12 +363,17 @@ pub type Worker = Inline; impl Inline { pub fn new(ctx: egui::Context) -> Self { - Self { ctx, pending: None, submitted: 0, previewed: false, renders: Renders::default() } + Self::with_settle(ctx, std::time::Duration::from_millis(SETTLE_MS)) + } + /// Refining a preview after `settle` without newer requests (tests: zero, the next frame). + pub fn with_settle(ctx: egui::Context, settle: std::time::Duration) -> Self { + Self { ctx, pending: None, submitted: 0, previewed: false, preview_shown: false, since: Instant::now(), settle, renders: Renders::default() } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); self.submitted += 1; self.previewed = false; + self.preview_shown = false; } pub fn in_flight(&self) -> bool { self.pending.is_some() @@ -364,10 +382,19 @@ impl Inline { if !self.previewed { self.previewed = true; if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) { - self.ctx.request_repaint(); + self.preview_shown = true; + self.since = Instant::now(); + self.ctx.request_repaint_after(self.settle); return Some(answering(d, self.submitted)); } } + if self.pending.is_some() && self.preview_shown { + let waited = self.since.elapsed(); + if waited < self.settle { + self.ctx.request_repaint_after(self.settle - waited); + return None; + } + } let job = self.pending.take()?; self.renders.refine(&job).map(|d| answering(d, self.submitted)) } diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs index ab0b54da..6261625e 100644 --- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs +++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs @@ -17,7 +17,9 @@ use serde_json::json; use super::{CanvasState, shows}; use figcraft_render::Retained; -use crate::render_worker::{Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, Worker, better, coarse_view, preview_with, render_now}; +use crate::render_worker::{ + Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now, +}; /// A page of ellipses with a rectangle on top; the session and the rectangle. fn document() -> (Session, NodeId) { @@ -392,8 +394,8 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS); let ds = s.ds().unwrap(); let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale)); - // The production threshold, as the browser has it. - let mut w = Inline::new(egui::Context::default()); + // The production threshold, as the browser has it; refined the next frame (settling: below). + let mut w = Inline::with_settle(egui::Context::default(), std::time::Duration::ZERO); w.submit(at(2.0)); let first = w.poll().expect("the first image"); assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by"); @@ -414,3 +416,48 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25)); assert!(w.poll().is_none() && !w.in_flight()); } + +#[test] +fn the_browser_worker_refines_a_zoom_only_once_it_settles() { + let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200); + let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale)); + let settle = std::time::Duration::from_millis(SETTLE_MS); + let mut w = Inline::with_settle(egui::Context::default(), settle); + w.submit(at(2.0)); + assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); + // Zooming: each step shows its preview, never a full render in between. + for scale in [1.5, 1.0] { + w.submit(at(scale)); + assert!(w.poll().is_some_and(|d| d.coarse)); + assert!(w.poll().is_none() && w.in_flight(), "not refined while the zoom may go on"); + } + std::thread::sleep(settle); + let d = w.poll().expect("refined once it settled"); + assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); + // A pan at the same zoom has no preview and is refined at once. + w.submit(job(&ds.doc, ds.page, ds.uid, view_at((340.0, 200.0), 1.0, 1200, 1200))); + assert!(w.poll().is_some_and(|d| !d.coarse)); +} + +/// The wait counts from the preview being shown, not from the request: rendering the preview can +/// take longer than the wait (the page is blocked meanwhile, so no newer request could come), and +/// counted from the request the refinement would follow in the very next frame. +#[test] +fn the_wait_counts_from_the_preview_not_from_the_request() { + let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200); + let s = slow_scene(&[view(2.0), view(1.0)], 20.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale)); + let preview_ms = preview_with(&at(1.0), &mut Retained::default()).unwrap().ms; + let settle = std::time::Duration::from_secs_f64(preview_ms / 2000.0).max(std::time::Duration::from_millis(1)); + let mut w = Inline::with_settle(egui::Context::default(), settle); + w.submit(at(2.0)); + assert!(w.poll().is_some_and(|d| !d.coarse)); + w.submit(at(1.0)); + assert!(w.poll().is_some_and(|d| d.coarse), "the preview, which takes {preview_ms} ms"); + assert!(w.poll().is_none() && w.in_flight(), "the wait ({settle:?}) starts only now"); + std::thread::sleep(settle); + assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0)); +} diff --git a/docs/gaps.md b/docs/gaps.md index edc18dc3..8067c2df 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -247,6 +247,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe sixteenth of the pixels first, then refined. With no measure to go by (the first render, another page, a zoom after quick renders), a watchdog thread previews any such render still running after 12 ms (desktop only; the browser renders inline). +- **Landed (2026-10-28):** the browser refines a zoom's preview only once no newer request has + come for 120 ms (`SETTLE_MS`): its full renders run on the UI thread, and one between every two + wheel steps held the page up for seconds. Pans and edits (no preview) are refined at once; the + desktop, with its render thread, is unchanged. - **Landed (2026-10-15):** a bounded render margin is reused during panning and refilled before the visible view reaches its edge. The extra rendered area is capped at 1.6× visible pixels; this reduces blank strips without changing the CPU renderer. @@ -519,6 +523,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-28 | minor | Gap 7: the browser refines a zoom's preview only once the zoom has paused for 120 ms, so a wheel zoom no longer runs a blocking full render between every two steps. Numbers unchanged | | 2026-10-25 | minor | egui/eframe 0.37 verified for gap 18: the remaining limitations are unchanged. egui-wgpu 0.37 still picks an 8-bit sRGB surface with no colour-space option, so P3 colours outside sRGB still clip on screen; eframe 0.37 still gives only the main window an AccessKit adapter, so other windows have no screen-reader tree. Numbers unchanged | | 2026-10-25 | minor | Known gaps from the #1311 review: gap 4, the export preview follows a slice's first export setting; gap 14, the scopes a paint style's color takes are FigCraft's own rule; gap 15, `proto.linkNotice` from the palette or control channel isn't saved until the next preferences save; gap 16, the file name field still renames in Dev Mode (to check against Figma first). Ranking and hours unchanged | | 2026-10-24 | minor | Gap 24: the dot grid on board pages, dragging a connector's end to re-attach it, curved connectors and elbows around the objects they join landed; connector waypoints placed by hand stay missing. 30–60 h → 28–56 h; the ranking is unchanged | From 8c951810ab16d3e12ce7d2e51290646a288a1c2e Mon Sep 17 00:00:00 2001 From: samhyun Date: Wed, 28 Oct 2026 22:29:55 +0900 Subject: [PATCH 2/3] M8: a test for same-zoom requests before a zoom refines; the gaps status line From the review of #1468: a pan or an edit at the new zoom before its full render has run renders from scratch too, so it shows a preview and restarts the wait instead of running a blocking full render between it and the next step; once refined, requests at that zoom build on the kept image and are refined at once. The new test pins both down. The status line of `docs/gaps.md` now carries the date of the gap 7 entry. Co-Authored-By: Claude Fable 5.1 --- .../ui-egui/src/canvas/zoom_preview_tests.rs | 30 +++++++++++++++++++ docs/gaps.md | 2 +- 2 files changed, 31 insertions(+), 1 deletion(-) diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs index 6261625e..47a6a026 100644 --- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs +++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs @@ -461,3 +461,33 @@ fn the_wait_counts_from_the_preview_not_from_the_request() { std::thread::sleep(settle); assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0)); } + +/// Until the zoom's full render has run, the kept image is of the old zoom, so a pan or an edit +/// at the new zoom renders from scratch too: it shows a preview and restarts the wait rather than +/// running a full render between it and the next step. Once refined, requests at that zoom build +/// on the kept image: no preview, no wait. +#[test] +fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_after() { + let view = |c, scale| view_at(c, scale, 1200, 1200); + let s = slow_scene(&[view((300.0, 200.0), 2.0), view((300.0, 200.0), 1.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |c, scale| job(&ds.doc, ds.page, ds.uid, view(c, scale)); + let settle = std::time::Duration::from_millis(SETTLE_MS); + let mut w = Inline::with_settle(egui::Context::default(), settle); + w.submit(at((300.0, 200.0), 2.0)); + assert!(w.poll().is_some_and(|d| !d.coarse), "the first render: no preview, no wait"); + // A zoom step, then a pan at the new zoom before the pause: a preview each, no full render. + w.submit(at((300.0, 200.0), 1.0)); + assert!(w.poll().is_some_and(|d| d.coarse)); + assert!(w.poll().is_none() && w.in_flight()); + w.submit(at((340.0, 200.0), 1.0)); + assert!(w.poll().is_some_and(|d| d.coarse), "from scratch still: the kept image is of the old zoom"); + assert!(w.poll().is_none() && w.in_flight(), "the wait starts over"); + std::thread::sleep(settle); + let d = w.poll().expect("refined once it settled"); + assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); + // From then on a pan at this zoom builds on the kept image: no preview, refined at once. + w.submit(at((380.0, 200.0), 1.0)); + let d = w.poll().expect("the refined image"); + assert!(!d.coarse && !w.in_flight()); +} diff --git a/docs/gaps.md b/docs/gaps.md index 8067c2df..67cc979d 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -1,6 +1,6 @@ # Where FigCraft falls short of Figma -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-25 · **Change:** minor (gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-28 · **Change:** minor (gap 7: the browser refines a zoom's preview once the zoom has paused; gap 17: branches in live sessions, branch history entries, the list-merge rows brought up to date; gap 6: plugin widgets take text input and a property menu (plugin API 4); gap 24: board templates and tables; gap 24: a shared timer and voting sessions; gap 24: the board dot grid, connector ends dragged to re-attach, curved connectors; egui/eframe 0.37 verified for gap 18; known gaps from the #1311 review in gaps 4, 14, 15 and 16; gap 16: measurements kept on the canvas, a read-only layers panel in Dev Mode; gap 24: boards and their tail; Dev Mode tail: import lines in layer trees, compare with versions, an inspect-only canvas; gap 5: reviewed Turkish, Czech and Indonesian drafts, the Japanese draft completed; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail; the 2026-10-17 landings: `.fig` import fidelity, budgets for large files, the import report, a `.fig` copy from Save as and paste from another design app (gap 1); menus, the Actions palette, editor fixes, Selection colors and smart-selection spacing (gap 4); catalog counts re-measured (gap 5); bounded canvas pan overscan; reviewed Russian catalog and integer plurals; `.fig` export: instance sizes, variables with modes, shared styles, variable-width strokes; sharing with a join code; text box sizes measured against documents re-saved by other software; live sessions per document; .fig export checked by opening written files in other software; experimental .fig export; keyboard shortcut parity suite and bindings; Simplified Chinese draft catalog; Latin American Spanish overlay; prototyping tail: gamepad triggers, widgets in prototypes, mode lookups; plugin directory on figcraft-server; Japanese and Korean draft catalogs and CJK UI fonts; plugin widgets and the plugin panel tail; French, German and Brazilian Portuguese draft catalogs; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and edit tail; dev resources links; extended collections tail; branch version history and finer list merges; comments and version history behaviour suite and fixes; canvas navigation, guides and grids behaviour suite and fixes; `.fig` import: editable vector networks, more layer kinds, variable prototype actions; `.fig` import, best effort: gap 1 down to its tail; Windows QA pass: everyday workflows on Windows 11, eight fixes incl. unsaved work kept after a crash or a forced quit; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; localization layer with a draft Spanish catalog; hosting figcraft-server: wss in the desktop app, server releases, deployment guide; plugin panels and settings; canvas fixes: boolean hit testing on the result, group drags, frame outlines and slices; code connect; stretch and scatter brushes, pen pressure; text on a path and pattern fills; team libraries on the server and swap library; branches: create, update from main, review and merge; animated GIF fills and TIFF images; Open recent, outlines for the selection and mask outlines: menu breadth 100%; extended collections, variables import/export, gradient stop and variant bindings: gap 14 down to its tail) · **Target:** Figma Design (desktop app 126.10.7) Every known shortfall against Figma, one entry each, ranked by how much it stops a Figma user from doing real work in FigCraft. Agents: pick work from the top unless the owner says otherwise. The From 3773a608019ea950462c6c91a6c81d4c6103d901 Mon Sep 17 00:00:00 2001 From: samhyun Date: Wed, 28 Oct 2026 22:53:07 +0900 Subject: [PATCH 3/3] M8: the browser's wait after a preview never exceeds the render it may save MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit With a fixed 120 ms wait, a page whose full renders take 15–100 ms was held coarse for 120 ms after a key zoom or a page switch, where before it was refined in the next frame. The wait is now the last full render's time when that is shorter than `SETTLE_MS`: a wait never costs more than the render it may save, and on a heavy page (seconds a render) it is 120 ms as before. Also from review: the test that the wait counts from the preview no longer times a preview render (a margin of a few milliseconds under load); a sleep between the request and the poll stands in for the render, with the whole settle as margin. `Inline::in_flight` says that a preview's wait counts. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/canvas/render_worker.rs | 35 ++++++++++++++---- .../ui-egui/src/canvas/zoom_preview_tests.rs | 37 ++++++++++++++++--- docs/gaps.md | 7 ++-- 3 files changed, 62 insertions(+), 17 deletions(-) diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 0cd3e040..94b2d3d8 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -13,7 +13,9 @@ //! the render from scratch has run past [`PREVIEW_AFTER_MS`], beside it (the full render keeps the //! other cores), and drops it if the full image is there first. //! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview -//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused). +//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused), or for +//! as long as the last full render took when that is shorter (a wait never costs more than the +//! render it may save). use std::sync::Arc; @@ -27,9 +29,9 @@ use web_time::Instant; pub const PREVIEW_FACTOR: f64 = 4.0; /// Full renders quicker than this (about a frame) show without a preview. pub const PREVIEW_AFTER_MS: f64 = 12.0; -/// The browser refines a zoom's preview only once no newer request came for this long: a full -/// render there blocks the page, and one between every two wheel steps of a zoom held it up for -/// seconds. +/// The browser refines a zoom's preview only once no newer request came for this long (or for as +/// long as the last full render took, when shorter): a full render there blocks the page, and one +/// between every two wheel steps of a zoom held it up for seconds. pub const SETTLE_MS: u64 = 120; #[derive(Clone)] @@ -354,7 +356,11 @@ pub struct Inline { /// rendering the preview itself can take longer than the wait (the page is blocked meanwhile, /// so no newer request could have come). since: Instant, + /// How long to wait after a preview, at most: `wait` is this capped by the last full render's + /// time, so a page whose renders are quick is not held coarse for longer than one would take. settle: std::time::Duration, + /// The wait for the pending job's preview. + wait: std::time::Duration, renders: Renders, } @@ -367,7 +373,17 @@ impl Inline { } /// Refining a preview after `settle` without newer requests (tests: zero, the next frame). pub fn with_settle(ctx: egui::Context, settle: std::time::Duration) -> Self { - Self { ctx, pending: None, submitted: 0, previewed: false, preview_shown: false, since: Instant::now(), settle, renders: Renders::default() } + Self { + ctx, + pending: None, + submitted: 0, + previewed: false, + preview_shown: false, + since: Instant::now(), + settle, + wait: settle, + renders: Renders::default(), + } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); @@ -375,6 +391,7 @@ impl Inline { self.previewed = false; self.preview_shown = false; } + /// Whether a request is still to be refined (a preview's wait counts). pub fn in_flight(&self) -> bool { self.pending.is_some() } @@ -384,14 +401,16 @@ impl Inline { if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) { self.preview_shown = true; self.since = Instant::now(); - self.ctx.request_repaint_after(self.settle); + let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms / 1000.0); + self.wait = full.map_or(self.settle, |full| full.min(self.settle)); + self.ctx.request_repaint_after(self.wait); return Some(answering(d, self.submitted)); } } if self.pending.is_some() && self.preview_shown { let waited = self.since.elapsed(); - if waited < self.settle { - self.ctx.request_repaint_after(self.settle - waited); + if waited < self.wait { + self.ctx.request_repaint_after(self.wait - waited); return None; } } diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs index 47a6a026..68b11870 100644 --- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs +++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs @@ -443,21 +443,23 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() { /// The wait counts from the preview being shown, not from the request: rendering the preview can /// take longer than the wait (the page is blocked meanwhile, so no newer request could come), and -/// counted from the request the refinement would follow in the very next frame. +/// counted from the request the refinement would follow in the very next frame. The time the +/// preview's render would take is stood in for by a sleep between the request and the poll. #[test] fn the_wait_counts_from_the_preview_not_from_the_request() { let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200); - let s = slow_scene(&[view(2.0), view(1.0)], 20.0 * PREVIEW_AFTER_MS); + // Renders from scratch of at least the settle, so the wait is the whole settle (see the cap). + let s = slow_scene(&[view(2.0), view(1.0)], SETTLE_MS as f64); let ds = s.ds().unwrap(); let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale)); - let preview_ms = preview_with(&at(1.0), &mut Retained::default()).unwrap().ms; - let settle = std::time::Duration::from_secs_f64(preview_ms / 2000.0).max(std::time::Duration::from_millis(1)); + let settle = std::time::Duration::from_millis(SETTLE_MS); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at(2.0)); assert!(w.poll().is_some_and(|d| !d.coarse)); w.submit(at(1.0)); - assert!(w.poll().is_some_and(|d| d.coarse), "the preview, which takes {preview_ms} ms"); - assert!(w.poll().is_none() && w.in_flight(), "the wait ({settle:?}) starts only now"); + std::thread::sleep(settle + std::time::Duration::from_millis(30)); + assert!(w.poll().is_some_and(|d| d.coarse), "the preview, longer after the request than the wait"); + assert!(w.poll().is_none() && w.in_flight(), "the wait starts only now"); std::thread::sleep(settle); assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0)); } @@ -491,3 +493,26 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af let d = w.poll().expect("the refined image"); assert!(!d.coarse && !w.in_flight()); } + +/// The wait never costs more than the full render it may save: a preview is refined once the +/// last full render's time has passed when that is shorter than `SETTLE_MS`, so a key zoom or a +/// page switch on a page whose renders are quick is not held coarse for longer than before. +#[test] +fn the_wait_is_capped_by_the_time_of_the_last_full_render() { + let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200); + let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale)); + // A settle far longer than this page's renders: the wait is their time instead. + let settle = std::time::Duration::from_secs(10); + let mut w = Inline::with_settle(egui::Context::default(), settle); + w.submit(at(2.0)); + let first = w.poll().expect("the first image"); + assert!(!first.coarse && first.ms >= PREVIEW_AFTER_MS, "slow enough to preview: {} ms", first.ms); + w.submit(at(1.0)); + assert!(w.poll().is_some_and(|d| d.coarse)); + assert!(w.poll().is_none() && w.in_flight(), "not refined in the very next frame"); + std::thread::sleep(std::time::Duration::from_secs_f64(first.ms / 1000.0)); + let d = w.poll().expect("refined once the last full render's time has passed, not the settle"); + assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); +} diff --git a/docs/gaps.md b/docs/gaps.md index 67cc979d..9e079036 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -248,9 +248,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe page, a zoom after quick renders), a watchdog thread previews any such render still running after 12 ms (desktop only; the browser renders inline). - **Landed (2026-10-28):** the browser refines a zoom's preview only once no newer request has - come for 120 ms (`SETTLE_MS`): its full renders run on the UI thread, and one between every two - wheel steps held the page up for seconds. Pans and edits (no preview) are refined at once; the - desktop, with its render thread, is unchanged. + come for 120 ms (`SETTLE_MS`), or for as long as its last full render took when that is shorter: + its full renders run on the UI thread, and one between every two wheel steps held the page up + for seconds. Pans and edits (no preview) are refined at once; the desktop, with its render + thread, is unchanged. - **Landed (2026-10-15):** a bounded render margin is reused during panning and refilled before the visible view reaches its edge. The extra rendered area is capped at 1.6× visible pixels; this reduces blank strips without changing the CPU renderer.