diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 873e762a..94b2d3d8 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -12,6 +12,10 @@ //! 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), 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; @@ -25,6 +29,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 (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)] pub struct Job { @@ -334,14 +342,25 @@ 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, + /// 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, } @@ -350,13 +369,29 @@ 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, + wait: 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; } + /// Whether a request is still to be refined (a preview's wait counts). pub fn in_flight(&self) -> bool { self.pending.is_some() } @@ -364,10 +399,21 @@ 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(); + 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.wait { + self.ctx.request_repaint_after(self.wait - 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..68b11870 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,103 @@ 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. 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); + // 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 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)); + 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)); +} + +/// 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()); +} + +/// 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 edc18dc3..9e079036 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 @@ -247,6 +247,11 @@ 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`), 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. @@ -519,6 +524,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 |