From 6d8b0e68f0a252d3f899930566c9fd8e7cfc3908 Mon Sep 17 00:00:00 2001 From: samhyun Date: Thu, 29 Oct 2026 14:11:29 +0900 Subject: [PATCH 1/2] M8: the browser draws a zoom's full render a band at a time MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Once a zoom had paused, the browser drew its full render in one frame. On a heavy page that frame took seconds, and no input was handled meanwhile: the wheel steps and drags that came were seen only when it was over. The renderer's `Retained::begin` hands a full render back as `Steps`, drawn `band_h` rows at a time with the same `render_band` and `put_band` the desktop's parallel bands use (bands overlap by what background blurs read and keep their own rows; a blur reaching past a band makes the bands taller rather than one piece), and `finish` keeps the image as a full render. A render that is reused or patched is done at once, as before. The browser's `Inline` worker refines through `Renders::refine_for`, which draws at least a band a frame, more while they fit in `STEP_MS` (24 ms), and asks for the next frame while bands remain, so input is handled between them; a view has at most eight bands, so on a page whose render takes seconds the longest frame is about an eighth of it. A render drawn for the very request is done once drawn. A pan or an edit meanwhile at the same zoom keeps the render going and is patched in once it is drawn; a pan too far to patch or an edit the patch can't take draws it again in steps, the image just drawn showing till then (`Refined::Shown`); a zoom, another page or file, another size or other render options drop the render for one of the new view, and what its bands took stands as the measure the next zoom's preview and wait go by. A render in steps counts as one render (glass keeps the shapes a later render asks for again). The desktop, with its render thread, is unchanged. Measured in Chrome on two synthetic pages (20 images and 60 shadowed rectangles, with and without two background-blur panels) whose full render takes 3.6–4.0 s at a deep zoom: the longest frame while that render runs went from 3.7–4.0 s to 0.9–1.4 s (blur) and 0.69–0.80 s (no blur); the bands together take longer than one render (the refined image comes after 6.7–8.1 s and 5.3–5.5 s instead of 3.8–4.1 s), each band rendering its overlap again and repeating some per-band work, shadows mostly. The zoom steps are as before. Co-Authored-By: Claude Fable 5.1 --- crates/render/src/lib.rs | 71 ++++-- crates/render/src/retained.rs | 137 ++++++++++- crates/render/src/retained_tests.rs | 58 +++++ crates/ui-egui/src/canvas/render_worker.rs | 222 ++++++++++++++++-- .../ui-egui/src/canvas/zoom_preview_tests.rs | 195 +++++++++++++-- docs/gaps.md | 9 +- 6 files changed, 625 insertions(+), 67 deletions(-) diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index a357033d..69a7a1cc 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -25,7 +25,7 @@ pub mod shadow; pub use dots::Dots; pub use draw::{node_outline, paint_bounds, stroke_fill, subtree_outline, ts_path}; pub use export_depth::{ExportDepthError, MAX_EXPORT_DEPTH, check_export_depth}; -pub use retained::{Retained, Work}; +pub use retained::{Begun, Retained, Steps, Work}; pub use tiny_skia::{FillRule, Pixmap}; use figcraft_color::{Color, ColorProfile}; @@ -88,7 +88,7 @@ pub struct Options { pub dots: Option, } -fn new_pixmap(w: u32, h: u32) -> Option { +pub(crate) fn new_pixmap(w: u32, h: u32) -> Option { let w = w.clamp(1, 16_384); let h = h.clamp(1, 16_384); Pixmap::new(w, h) @@ -111,7 +111,7 @@ pub(crate) fn backdrop_reach(radius: f64, scale: f64) -> f64 { static RENDERS: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0); /// The number of the render that just started. -fn start_render() -> u64 { +pub(crate) fn start_render() -> u64 { RENDERS.fetch_add(1, std::sync::atomic::Ordering::Relaxed) + 1 } @@ -145,42 +145,61 @@ pub fn render_view(doc: &Document, page: NodeId, view: &View, opts: &Options) -> fn render_bands(doc: &Document, page: NodeId, view: &View, opts: &Options, bands: u32) -> Option { use rayon::prelude::*; let band_h = view.height.div_ceil(bands.max(1)); - let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil(); - if !overlap.is_finite() || overlap > f64::from(band_h) { - return None; - } - let overlap = overlap as u32; - let s = view.scale.max(1e-9); + let overlap = band_overlap(doc, view, band_h)?; let parts: Vec<(u32, Option)> = (0..bands) .into_par_iter() .map(|i| { let y0 = i * band_h; - let h = band_h.min(view.height.saturating_sub(y0)).max(1); - let top = y0.saturating_sub(overlap); - let bottom = (y0 + h + overlap).min(view.height).max(y0 + h); - let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top }; - let part = render_one(doc, page, &v, opts); - let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h); - (y0, if overlap == 0 { part } else { part.zip(own).and_then(|(p, r)| p.clone_rect(r)) }) + (y0, render_band(doc, page, view, opts, y0, band_h, overlap)) }) .collect(); let mut out = new_pixmap(view.width, view.height)?; - let row = out.width() as usize * 4; for (y0, part) in parts { - let Some(part) = part else { continue }; - // Bands are as wide as the view: their rows go straight into place. - let start = y0 as usize * row; - let dst = out.data_mut(); - let n = part.data().len().min(dst.len().saturating_sub(start)); - if part.width() == view.width.clamp(1, 16_384) - && let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n)) - { - d.copy_from_slice(s); + if let Some(part) = part { + put_band(&mut out, y0, &part); } } Some(out) } +/// How many rows bands of `band_h` rows of `view` overlap (what background blurs read); `None` +/// when that is more than a band. +pub(crate) fn band_overlap(doc: &Document, view: &View, band_h: u32) -> Option { + let overlap = backdrop_reach(max_backdrop_blur(doc), view.scale).ceil(); + (overlap.is_finite() && overlap <= f64::from(band_h)).then_some(overlap as u32) +} + +/// The rows `y0 .. y0 + band_h` of `view` (fewer at the bottom), rendered with `overlap` rows +/// more each way and cut back to their own. +pub(crate) fn render_band(doc: &Document, page: NodeId, view: &View, opts: &Options, y0: u32, band_h: u32, overlap: u32) -> Option { + let s = view.scale.max(1e-9); + let h = band_h.min(view.height.saturating_sub(y0)).max(1); + let top = y0.saturating_sub(overlap); + let bottom = (y0 + h + overlap).min(view.height).max(y0 + h); + let v = View { origin: (view.origin.0, view.origin.1 + f64::from(top) / s), scale: view.scale, width: view.width, height: bottom - top }; + let part = render_one(doc, page, &v, opts); + if overlap == 0 { + return part; + } + let own = tiny_skia::IntRect::from_xywh(0, (y0 - top) as i32, view.width.clamp(1, 16_384), h); + part.zip(own).and_then(|(p, r)| p.clone_rect(r)) +} + +/// Copy a band's rows into `out` at row `y0` (bands are as wide as the view: their rows go +/// straight into place). +pub(crate) fn put_band(out: &mut Pixmap, y0: u32, part: &Pixmap) { + let row = out.width() as usize * 4; + let start = y0 as usize * row; + let width = out.width(); + let dst = out.data_mut(); + let n = part.data().len().min(dst.len().saturating_sub(start)); + if part.width() == width + && let (Some(d), Some(s)) = (dst.get_mut(start..start + n), part.data().get(..n)) + { + d.copy_from_slice(s); + } +} + /// Render a page region on the calling thread. pub fn render_view_single(doc: &Document, page: NodeId, view: &View, opts: &Options) -> Option { start_render(); diff --git a/crates/render/src/retained.rs b/crates/render/src/retained.rs index 620b8f56..046ed842 100644 --- a/crates/render/src/retained.rs +++ b/crates/render/src/retained.rs @@ -127,6 +127,70 @@ impl Px { } } +/// What [`Retained::prepare`] found. +enum Prepared { + /// The previous render patched (or reused as is), now kept. + Patched(View, Work), + /// Only a full render will do: the snapped view, its top-left device pixel, the font + /// generation and what [`sensitive`] found when the previous render's document was checked. + Full { snapped: View, px: (i64, i64), fonts: u64, sensitive: Option> }, +} + +/// What [`Retained::begin`] did. +pub enum Begun<'a> { + /// The view drawn, its image and what it took. + Done(View, &'a Pixmap, Work), + /// A full render to draw band by band (boxed: it holds the image). + Steps(Box), +} + +/// A full render drawn a band at a time ([`Retained::begin`]): bands overlap by what background +/// blurs read and keep only their own rows, as the desktop's parallel bands do. +pub struct Steps { + doc: Arc, + page: NodeId, + view: View, + px: (i64, i64), + fonts: u64, + opts: Options, + sensitive: Option>, + pix: Pixmap, + /// The first row not drawn yet. + next: u32, + band_h: u32, + overlap: u32, +} + +impl Steps { + /// Draw the next band; `true` once every row is drawn. + pub fn step(&mut self) -> bool { + if self.done() { + return true; + } + let y0 = self.next; + if let Some(part) = crate::render_band(&self.doc, self.page, &self.view, &self.opts, y0, self.band_h, self.overlap) { + crate::put_band(&mut self.pix, y0, &part); + } + self.next = y0.saturating_add(self.band_h); + self.done() + } + + /// Whether every row is drawn. + pub fn done(&self) -> bool { + self.next >= self.view.height + } + + /// The view drawn (snapped to device pixels). + pub fn view(&self) -> &View { + &self.view + } + + /// The page drawn. + pub fn page(&self) -> NodeId { + self.page + } +} + /// Keeps the last rendered view; see the module docs. #[derive(Default)] pub struct Retained { @@ -142,13 +206,73 @@ impl Retained { /// Render `view` of `page`, reusing the previous render where nothing changed. Returns the /// view actually drawn (snapped to device pixels, see [`snap`]), the image and what it took. pub fn render(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options) -> Option<(View, &Pixmap, Work)> { + match self.prepare(doc, page, view, opts)? { + Prepared::Patched(snapped, work) => Some((snapped, &self.last.as_ref()?.pix, work)), + Prepared::Full { snapped, px, fonts, sensitive } => { + let pix = render_view(doc, page, &snapped, opts)?; + let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(doc))); + let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); + Some((snapped, &frame.pix, Work::Full)) + } + } + } + + /// [`Self::render`], except that a full render comes back as [`Steps`] to draw `band_h` rows + /// at a time (the browser, whose renders block the page: input is handled between bands) and + /// keep with [`Self::finish`]. A render that is reused or patched is done at once. + pub fn begin(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options, band_h: u32) -> Option> { + match self.prepare(doc, page, view, opts)? { + Prepared::Patched(snapped, work) => Some(Begun::Done(snapped, &self.last.as_ref()?.pix, work)), + Prepared::Full { snapped, px, fonts, sensitive } => { + // Bands at least twice what they overlap (each draws its overlap again both ways), + // so a background blur reaching past `band_h` makes the bands taller. + let overlap = crate::band_overlap(doc, &snapped, u32::MAX).map(|o| o.min(snapped.height)); + let band_h = band_h.max(1).max(overlap.unwrap_or(0).saturating_mul(2)); + // A blur that can't be measured: one piece. + let Some(overlap) = overlap else { + let pix = render_view(doc, page, &snapped, opts)?; + let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(doc))); + let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); + return Some(Begun::Done(snapped, &frame.pix, Work::Full)); + }; + let pix = crate::new_pixmap(snapped.width, snapped.height)?; + // One render, as the desktop's parallel bands are: glass keeps the shapes a + // later render asks for again, and the bands of this one are not later renders. + crate::start_render(); + Some(Begun::Steps(Box::new(Steps { + doc: doc.clone(), + page, + view: snapped, + px, + fonts, + opts: opts.clone(), + sensitive, + pix, + next: 0, + band_h, + overlap, + }))) + } + } + } + + /// Keep the image `steps` drew (all its bands: see [`Steps::step`]) as a full render. + pub fn finish(&mut self, steps: Steps) -> (View, &Pixmap) { + let Steps { doc, page, view, px, fonts, opts, sensitive, pix, .. } = steps; + let s = sensitive.unwrap_or_else(|| Arc::new(self::sensitive(&doc))); + let frame = self.keep(&doc, page, (&view, px, fonts), &opts, pix, s); + (view, &frame.pix) + } + + /// The previous render patched where it could be (kept, see [`Self::keep`]), or what a full + /// render of `view` needs. + fn prepare(&mut self, doc: &Arc, page: NodeId, view: &View, opts: &Options) -> Option { // A view that can't be put on a pixel grid (non-finite or absurd) draws nothing. let Some((snapped, px)) = snap(view) else { self.last = None; return None; }; let fonts = figcraft_text::layout_generation(); - let mut work = Work::Full; let mut sens = None; if let Some(last) = self.last.take() && last.page == page @@ -173,16 +297,13 @@ impl Retained { let same_doc = Arc::ptr_eq(&last.doc, doc); let s = if same_doc { last.sensitive.clone() } else { Arc::new(sensitive(doc)) }; if let Some((pix, pixels)) = patch(last, doc, page, &snapped, px, opts, &s) { - work = if pixels == 0 { Work::Reused } else { Work::Partial { pixels } }; - let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); - return Some((snapped, &frame.pix, work)); + let work = if pixels == 0 { Work::Reused } else { Work::Partial { pixels } }; + self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); + return Some(Prepared::Patched(snapped, work)); } sens = Some(s); } - let pix = render_view(doc, page, &snapped, opts)?; - let s = sens.unwrap_or_else(|| Arc::new(sensitive(doc))); - let frame = self.keep(doc, page, (&snapped, px, fonts), opts, pix, s); - Some((snapped, &frame.pix, work)) + Some(Prepared::Full { snapped, px, fonts, sensitive: sens }) } /// Keep `pix`, the render of `at`: the snapped view, its top-left device pixel and the font diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 958da373..89b7b7a1 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -287,3 +287,61 @@ fn overflowing_text_is_drawn_while_its_box_is_out_of_view() { let dark = pix.data().as_chunks::<4>().0.iter().filter(|c| c[0] < 128).count(); assert!(dark > 20, "glyphs drawn: {dark}"); } + +/// Draw a full render through [`Retained::begin`] `band_h` rows at a time; how many steps it took. +fn stepped(r: &mut Retained, doc: &Arc, page: NodeId, v: &View, band_h: u32) -> (View, Pixmap, usize) { + match r.begin(doc, page, v, &Options::default(), band_h).unwrap() { + crate::Begun::Done(shown, pix, work) => { + assert_eq!(work, Work::Full, "one piece only when a blur can't be measured"); + (shown, pix.clone(), 0) + } + crate::Begun::Steps(mut steps) => { + let mut n = 1; + while !steps.step() { + n += 1; + assert!(n < 1000, "steps end"); + } + let (shown, pix) = r.finish(*steps); + (shown, pix.clone(), n) + } + } +} + +#[test] +fn a_render_drawn_band_by_band_matches_one_in_one_piece() { + for cut_sensitive in [false, true] { + let s = scene(cut_sensitive); + // The whole scene in view, so the glass's backdrop is never cut by the view. + let v = View { origin: (-10.0, -10.0), scale: 1.0, width: 380, height: 360 }; + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 40); + assert_eq!(n, shown.height.div_ceil(40) as usize, "a band a step"); + let full = render_view(&s.doc, s.page, &shown, &Options::default()).unwrap(); + assert_same(&pix, &full, "bands"); + // Kept as a full render: the next pan patches it. + assert!(partial(check(&mut r, &s.doc, s.page, &View { origin: (0.0, -10.0), ..v }))); + } +} + +#[test] +fn bands_thinner_than_a_blur_reaches_grow_to_twice_its_reach() { + let s = scene(true); + let v = View { origin: (-10.0, -10.0), scale: 4.0, width: 380, height: 360 }; + let reach = crate::band_overlap(&s.doc, &v, u32::MAX).unwrap(); + assert!(reach > 4, "the blur reaches past the bands asked for"); + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &s.doc, s.page, &v, 4); + assert_eq!(n, shown.height.div_ceil(4.max(reach.min(shown.height) * 2)) as usize, "bands twice the reach"); + assert_same(&pix, &render_view(&s.doc, s.page, &shown, &Options::default()).unwrap(), "taller bands"); +} + +#[test] +fn a_reused_or_patched_render_needs_no_steps() { + let s = scene(false); + let v = view(0.0, 0.0, 1.0); + let mut r = Retained::default(); + check(&mut r, &s.doc, s.page, &v); + assert!(matches!(r.begin(&s.doc, s.page, &v, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Reused)))); + let panned = View { origin: (12.0, 0.0), ..v }; + assert!(matches!(r.begin(&s.doc, s.page, &panned, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Partial { .. })))); +} diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 94b2d3d8..0ffdd34f 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -15,13 +15,16 @@ //! 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). +//! render it may save), and the full render is then drawn a band at a time +//! ([`figcraft_render::Steps`]), at least a band a frame and more while they fit in [`STEP_MS`], +//! so input is handled while it draws (on a page whose render takes seconds, a band is a +//! fraction of that: eight at most). use std::sync::Arc; use figcraft_color::ColorProfile; use figcraft_doc::{Document, NodeId}; -use figcraft_render::{Pixmap, Retained, View, Work}; +use figcraft_render::{Begun, Pixmap, Retained, Steps, View, Work}; // A monotonic clock in the browser too: preview decisions rest on how long renders take there. use web_time::Instant; @@ -33,6 +36,16 @@ pub const PREVIEW_AFTER_MS: f64 = 12.0; /// 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; +/// The browser draws a full render a band at a time ([`figcraft_render::Steps`]): at least one +/// band a frame, and more while they fit in this long, so input is handled while it draws. A +/// band of a render that takes seconds takes a fraction of that (see [`MAX_BANDS`]). +pub const STEP_MS: f64 = 24.0; +/// Bands are at least this many rows ... +const MIN_BAND_ROWS: u32 = 64; +/// ... and a view has at most this many: the longest frame of a render drawn in steps is about a +/// band's, an eighth of the render plus its overlap (more bands would repeat more of the overlap +/// and of the per-band work). +const MAX_BANDS: u32 = 8; #[derive(Clone)] pub struct Job { @@ -100,11 +113,9 @@ fn mark_outside_srgb(image: &mut egui::ColorImage, pix: &Pixmap, profile: ColorP } } -/// Render `view` of the job (its own view, or the coarse one standing in for it). -fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { - let t0 = Instant::now(); - // The canvas is a preview: what's too small to see in detail at this zoom is approximated. - let opts = figcraft_render::Options { +/// The canvas is a preview: what's too small to see in detail at this zoom is approximated. +fn options(job: &Job) -> figcraft_render::Options { + figcraft_render::Options { outline_mode: job.outline, outline: job.outline_extras, outlined: job.outlined.clone(), @@ -114,14 +125,23 @@ fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option< magnified_image_pixels: true, dots: job.dots, ..Default::default() - }; - let (view, pix, work) = retained.render(&job.doc, job.page, view, &opts)?; + } +} + +/// Render `view` of the job (its own view, or the coarse one standing in for it). +fn run(job: &Job, view: &View, retained: &mut Retained, coarse: bool) -> Option { + let t0 = Instant::now(); + let (view, pix, work) = retained.render(&job.doc, job.page, view, &options(job))?; + Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, coarse)) +} + +/// The image `pix` of `view`, rendered for `job` in `ms`. +fn done(job: &Job, view: View, pix: &Pixmap, work: Work, ms: f64, coarse: bool) -> Done { let mut image = screen_image(pix, job.doc.color_profile); if job.proof { mark_outside_srgb(&mut image, pix, job.doc.color_profile); } - let ms = t0.elapsed().as_secs_f64() * 1000.0; - Some(Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 }) + Done { image, view, ms, work, uid: job.uid, page: job.page, coarse, at: job.at, seq: 0 } } /// `view` with [`PREVIEW_FACTOR`] times fewer pixels each way, showing at least the same area. @@ -141,6 +161,56 @@ pub struct Renders { full_ms: f64, /// Previews come first only when renders from scratch take at least this long. preview_after_ms: f64, + /// A full render being drawn band by band (the browser): the job it was started for, the + /// font generation then, the bands and the time they took so far. + steps: Option, +} + +/// A full render drawn in steps and what it is of: a newer job of the same file, page, zoom, size +/// and render options keeps it going (its image is then patched to the job, as a pan or an edit +/// is), any other drops it. +struct Stepping { + job: Job, + fonts: u64, + steps: Box, + spent: f64, +} + +/// Whether `a` and `b` are drawn the same way: the same file, page, zoom, size and options (the +/// view may be elsewhere and the document edited: a render of one is patched to the other). +fn same_settings(a: &Job, b: &Job) -> bool { + a.uid == b.uid + && a.page == b.page + && a.view.scale.to_bits() == b.view.scale.to_bits() + && (a.view.width, a.view.height) == (b.view.width, b.view.height) + && a.outline == b.outline + && a.outline_extras == b.outline_extras + && a.outlined == b.outlined + && a.skip == b.skip + && a.solo == b.solo + && a.proof == b.proof + && a.dots == b.dots +} + +/// Whether a render drawn for `a` (with fonts of generation `fonts`) is `b`'s image as it is: +/// the same settings, document and view, and no fonts arrived meanwhile. +fn same_request(a: &Job, fonts: u64, b: &Job) -> bool { + same_settings(a, b) + && Arc::ptr_eq(&a.doc, &b.doc) + && a.view.origin.0.to_bits() == b.view.origin.0.to_bits() + && a.view.origin.1.to_bits() == b.view.origin.1.to_bits() + && figcraft_text::layout_generation() == fonts +} + +/// What [`Renders::refine_for`] got to. +pub enum Refined { + /// The image (`None`: nothing could be drawn). + Done(Option), + /// Bands remain: call again (next frame). + More, + /// A full render drawn for what has changed since (an edit, a pan or images that came while + /// it was drawn), shown meanwhile; it is drawn again: call again. + Shown(Done), } impl Default for Renders { @@ -152,7 +222,7 @@ impl Default for Renders { impl Renders { /// Previews when renders from scratch take at least `ms` (tests: 0, always). pub fn with_threshold(ms: f64) -> Self { - Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms } + Self { full: Retained::default(), preview: Retained::default(), last: None, full_ms: 0.0, preview_after_ms: ms, steps: None } } /// Whether `job` is a render from scratch: the kept image is of another zoom, page or file, or @@ -164,7 +234,13 @@ impl Renders { /// Whether `job` gets a preview first: it renders from scratch and those have been slow on a /// page already drawn (the first render has no measure). fn wants_preview(&self, job: &Job) -> bool { - job.zoom_preview && self.last.is_some() && self.full_ms >= self.preview_after_ms && self.starts_over(job) + job.zoom_preview && self.last.is_some() && self.full_ms_so_far() >= self.preview_after_ms && self.starts_over(job) + } + + /// How long the last render from scratch took, or what the one being drawn in steps has taken + /// so far when that is more: a render dropped mid-way (a zoom going on) leaves a measure too. + pub fn full_ms_so_far(&self) -> f64 { + self.full_ms.max(self.steps.as_ref().map_or(0.0, |s| s.spent)) } /// Whether `job` should get a preview should its render run long: from scratch, with none first. @@ -191,6 +267,107 @@ impl Renders { } done } + + /// [`Self::refine`] for about `budget_ms` at a time (the browser): a full render is drawn band by + /// band over several calls, the kept image (re-projected) or the preview showing meanwhile. + pub fn refine_for(&mut self, job: &Job, budget_ms: f64) -> Refined { + let t0 = Instant::now(); + if let Some(s) = self.steps.as_ref() + && !same_settings(&s.job, job) + { + // Dropped for a render of the new view. What its bands took is at least what a full + // render takes: enough for the next zoom to get its preview and its wait. + self.full_ms = self.full_ms.max(s.spent); + self.steps = None; + } + if self.steps.is_none() + && let Some(r) = self.begin(job, t0, None) + { + return r; + } + let Some(s) = self.steps.as_mut() else { return Refined::Done(None) }; + let steps = &mut s.steps; + let stepped = std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| { + loop { + if steps.step() { + return true; + } + if t0.elapsed().as_secs_f64() * 1000.0 >= budget_ms { + return false; + } + } + })); + s.spent += t0.elapsed().as_secs_f64() * 1000.0; + match stepped { + Ok(false) => Refined::More, + Ok(true) => { + let Some(Stepping { job: drawn_for, fonts, steps, spent }) = self.steps.take() else { return Refined::Done(None) }; + let (view, pix) = self.full.finish(*steps); + self.full_ms = spent; + // The image of the job it was started for: its document's colour profile, its + // proof setting and its request time. + let drawn = done(&drawn_for, view, pix, Work::Full, spent, false); + // Drawn for this very request: shown, done. Otherwise kept, then started on like + // any render: an edit, a pan or images that came meanwhile are patched in, or it + // is drawn again in steps (never all at once here), this one shown till then. + if same_request(&drawn_for, fonts, job) { + return Refined::Done(self.finished(job, Some(drawn))); + } + self.begin(job, t0, Some(drawn)).unwrap_or(Refined::More) + } + Err(_) => { + self.steps = None; + self.full.clear(); + Refined::Done(None) + } + } + } + + /// Start `job` on the kept image: done when it is reused or patched, `None` when a full render + /// is now drawn in steps. `drawn` is the kept image just drawn in steps: what a reuse shows, + /// else shown while it is drawn again. + fn begin(&mut self, job: &Job, t0: Instant, mut drawn: Option) -> Option { + // The snapped view may be a row taller. + let rows = job.view.height.saturating_add(1).div_ceil(MAX_BANDS).max(MIN_BAND_ROWS); + let begun = + std::panic::catch_unwind(std::panic::AssertUnwindSafe(|| match self.full.begin(&job.doc, job.page, &job.view, &options(job), rows) { + Some(Begun::Done(view, pix, work)) => Err(Box::new(match drawn.take() { + Some(d) if work == Work::Reused => Some(d), + _ => Some(done(job, view, pix, work, t0.elapsed().as_secs_f64() * 1000.0, false)), + })), + Some(Begun::Steps(steps)) => Ok(steps), + None => Err(Box::new(None)), + })); + match begun { + Ok(Ok(steps)) => { + // A pan or an edit at this zoom builds on this render: no preview for it. + self.last = Some((job.uid, job.page, job.view.scale)); + self.steps = Some(Stepping { job: job.clone(), fonts: figcraft_text::layout_generation(), steps, spent: 0.0 }); + drawn.map(Refined::Shown) + } + Ok(Err(d)) => Some(Refined::Done(self.finished(job, *d))), + Err(_) => { + self.full.clear(); + Some(Refined::Done(None)) + } + } + } + + /// Record a render done at once in [`Self::refine_for`]. + fn finished(&mut self, job: &Job, d: Option) -> Option { + if let Some(d) = &d { + self.last = Some((job.uid, job.page, job.view.scale)); + if d.work == Work::Full { + self.full_ms = d.ms; + } + } + d + } + + /// Whether a full render is being drawn band by band. + pub fn stepping(&self) -> bool { + self.steps.is_some() + } } /// The coarse preview of the whole of `job`'s view, kept in `retained`. @@ -401,7 +578,7 @@ 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(); - let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms / 1000.0); + let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms_so_far() / 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)); @@ -414,8 +591,21 @@ impl Inline { return None; } } - let job = self.pending.take()?; - self.renders.refine(&job).map(|d| answering(d, self.submitted)) + let job = self.pending.as_ref()?; + match self.renders.refine_for(job, STEP_MS) { + Refined::Done(d) => { + self.pending = None; + d.map(|d| answering(d, self.submitted)) + } + Refined::More => { + self.ctx.request_repaint(); + None + } + Refined::Shown(d) => { + self.ctx.request_repaint(); + Some(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 68b11870..321016a3 100644 --- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs +++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs @@ -18,7 +18,7 @@ use super::{CanvasState, shows}; use figcraft_render::Retained; use crate::render_worker::{ - Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Renders, SETTLE_MS, Worker, better, coarse_view, preview_with, render_now, + Done, Inline, Job, PREVIEW_AFTER_MS, PREVIEW_FACTOR, Refined, 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. @@ -90,6 +90,45 @@ fn same_pixels(a: &Done, b: &Done) -> bool { a.image.size == b.image.size && a.image.pixels == b.image.pixels } +/// The same image up to where render bands meet ([`figcraft_render::Steps`] and the desktop's +/// parallel bands cut the view in different places): on each row, under a quarter of a level on +/// average and at most one pixel in twenty off. +fn close_pixels(a: &Done, b: &Done) -> bool { + let [w, h] = a.image.size; + a.image.size == b.image.size + && (0..h).all(|y| { + let row = |d: &Done| d.image.pixels[y * w..(y + 1) * w].to_vec(); + let (ra, rb) = (row(a), row(b)); + let diffs: Vec = + ra.iter().zip(&rb).map(|(p, q)| p.to_array().iter().zip(q.to_array()).map(|(x, y)| u32::from(x.abs_diff(y))).sum()).collect(); + let sum: u32 = diffs.iter().sum(); + f64::from(sum) / (w as f64 * 4.0) < 0.25 && diffs.iter().filter(|d| **d > 0).count() * 20 <= w + }) +} + +/// Poll the browser's worker until it shows the full image (drawn band by band over polls). +fn refined(w: &mut Inline) -> Option { + for _ in 0..1000 { + match w.poll() { + Some(d) => return Some(d), + None if !w.in_flight() => return None, + None => {} + } + } + None +} + +/// Refine `job` through `r` with no time to spare (a band a call); the image and how many calls. +fn stepped(r: &mut Renders, job: &Job) -> (Option, usize) { + for n in 1..1000 { + match r.refine_for(job, 0.0) { + Refined::Done(d) => return (d, n), + Refined::Shown(_) | Refined::More => {} + } + } + (None, 1000) +} + fn covers(outer: &View, inner: &View) -> bool { let (o, i) = (outer.page_rect(), inner.page_rect()); let eps = 1e-6; @@ -397,23 +436,23 @@ fn the_browser_worker_previews_by_measured_render_time_then_refines_the_next_fra // 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"); + let first = refined(&mut w).expect("the first image"); assert!(!first.coarse && !w.in_flight(), "the first render has no measure to preview by"); assert!(first.ms >= PREVIEW_AFTER_MS, "renders are timed in the browser worker too (and this one is slow): {} ms", first.ms); - // A new zoom: the preview this frame, the refined image the next. + // A new zoom: the preview this frame, the refined image over the next (a band each). w.submit(at(1.0)); let p = w.poll().expect("a preview"); assert!(p.coarse && w.in_flight()); - let d = w.poll().expect("the refined image"); + let d = refined(&mut w).expect("the refined image"); assert!(!d.coarse && !w.in_flight()); - assert!(same_pixels(&d, &render_now(&at(1.0)).unwrap())); + assert!(close_pixels(&d, &render_now(&at(1.0)).unwrap()), "drawn band by band"); // A newer request before the refinement: refining the old one is skipped. w.submit(at(0.5)); assert!(w.poll().is_some_and(|d| d.coarse)); w.submit(at(0.25)); let p = w.poll().expect("the newer one's preview"); assert!(p.coarse && (p.view.scale - 0.25 / PREVIEW_FACTOR).abs() < 1e-12); - assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 0.25)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse && d.view.scale == 0.25)); assert!(w.poll().is_none() && !w.in_flight()); } @@ -426,7 +465,7 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() { 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"); + assert!(refined(&mut w).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)); @@ -434,11 +473,11 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() { 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"); + let d = refined(&mut w).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)); + assert!(refined(&mut w).is_some_and(|d| !d.coarse)); } /// The wait counts from the preview being shown, not from the request: rendering the preview can @@ -455,13 +494,13 @@ fn the_wait_counts_from_the_preview_not_from_the_request() { 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)); + assert!(refined(&mut w).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)); + assert!(refined(&mut w).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 @@ -477,7 +516,7 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af 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"); + assert!(refined(&mut w).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)); @@ -486,11 +525,11 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af 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"); + let d = refined(&mut w).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"); + let d = refined(&mut w).expect("the refined image"); assert!(!d.coarse && !w.in_flight()); } @@ -507,12 +546,136 @@ fn the_wait_is_capped_by_the_time_of_the_last_full_render() { 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"); + let first = refined(&mut w).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"); + let d = refined(&mut w).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()); } + +#[test] +fn the_browser_draws_a_full_render_band_by_band() { + let view = |x| view_at((x, 200.0), 1.0, 600, 600); + let s = slow_scene(&[view(300.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |x| job(&ds.doc, ds.page, ds.uid, view(x)); + let mut r = Renders::default(); + let (d, n) = stepped(&mut r, &at(300.0)); + let d = d.expect("drawn"); + assert!(n > 2 && !r.stepping(), "several calls, then done ({n})"); + assert!(close_pixels(&d, &render_now(&at(300.0)).unwrap())); + // Kept: a pan now patches it at once. + assert!(matches!(r.refine_for(&at(320.0), 0.0), Refined::Done(Some(d)) if d.work != Work::Full)); +} + +#[test] +fn a_pan_while_drawing_finishes_the_render_then_patches_it_and_a_zoom_drops_it() { + let view = |x, scale| view_at((x, 200.0), scale, 600, 600); + let s = slow_scene(&[view(300.0, 1.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |x, scale| job(&ds.doc, ds.page, ds.uid, view(x, scale)); + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More)); + // Panned meanwhile: the same render goes on, and what comes is the panned view. + let (d, _) = stepped(&mut r, &at(330.0, 1.0)); + let d = d.expect("drawn"); + assert!(close_pixels(&d, &render_now(&at(330.0, 1.0)).unwrap()), "the panned view"); + // Zoomed meanwhile: the render under way is dropped for one of the new zoom. + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&at(300.0, 1.0), 0.0), Refined::More)); + let (d, _) = stepped(&mut r, &at(300.0, 2.0)); + assert_eq!(d.expect("drawn").view.scale, 2.0); +} + +#[test] +fn an_edit_while_drawing_shows_in_what_comes() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let mut s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let (doc, page, uid) = { + let ds = s.ds().unwrap(); + (ds.doc.clone(), ds.page, ds.uid) + }; + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&job(&doc, page, uid, v.clone()), 0.0), Refined::More)); + // Edited meanwhile, the view unchanged: the render goes on and what comes shows the edit. + s.execute("shape.rectangle", &json!({"x": 100, "y": 0, "width": 400, "height": 400})).unwrap(); + let edited = job(&s.ds().unwrap().doc, page, uid, v); + let (d, _) = stepped(&mut r, &edited); + assert!(close_pixels(&d.expect("drawn"), &render_now(&edited).unwrap()), "the edit shows"); +} + +#[test] +fn a_render_overtaken_by_a_long_pan_is_shown_then_drawn_again() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&job(&ds.doc, ds.page, ds.uid, v.clone()), 0.0), Refined::More)); + // Panned more than a view meanwhile: nothing to patch. What was drawn shows, then the new view. + let far = job(&ds.doc, ds.page, ds.uid, view_at((1500.0, 200.0), 1.0, 600, 600)); + let mut shown = vec![]; + let last = (0..1000).find_map(|_| match r.refine_for(&far, 0.0) { + Refined::More => None, + Refined::Shown(d) => { + shown.push(d); + None + } + Refined::Done(d) => Some(d), + }); + assert_eq!(shown.len(), 1, "the render drawn shows once"); + assert!(close_pixels(&shown[0], &render_now(&job(&ds.doc, ds.page, ds.uid, v)).unwrap()), "the view it was drawn for"); + assert!(close_pixels(&last.flatten().expect("drawn"), &render_now(&far).unwrap()), "then the new view"); +} + +/// Drawn for the very request, the render is done: it is not started on again, even where the +/// kept image can never be patched (outline mode drawing hidden layers), which drew it again +/// and again, the page never idle. +#[test] +fn a_render_drawn_for_the_request_is_done_even_where_it_could_never_be_patched() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let mut hidden = job(&ds.doc, ds.page, ds.uid, v); + hidden.outline = true; + hidden.outline_extras.hidden = true; + let mut r = Renders::default(); + let (d, n) = stepped(&mut r, &hidden); + assert!(d.is_some_and(|d| !d.coarse && d.work == Work::Full) && !r.stepping(), "done after {n} calls"); + assert!(n < 100, "drawn once, not over and over ({n} calls)"); +} + +/// A change of render options (outline mode here) mid-way drops the render, as a zoom does: the +/// bands of the old one are not drawn to the end first. +#[test] +fn a_change_of_options_while_drawing_drops_the_render() { + let v = view_at((300.0, 200.0), 1.0, 600, 600); + let s = slow_scene(std::slice::from_ref(&v), 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let plain = job(&ds.doc, ds.page, ds.uid, v.clone()); + let mut outlined = plain.clone(); + outlined.outline = true; + let (_, alone) = stepped(&mut Renders::default(), &outlined); + let mut r = Renders::default(); + assert!(matches!(r.refine_for(&plain, 0.0), Refined::More)); + let (d, n) = stepped(&mut r, &outlined); + assert!(d.is_some_and(|d| !d.coarse)); + assert!(n <= alone + 1, "the old render's bands are dropped: {n} calls against {alone} alone"); +} + +/// A render dropped mid-way leaves a measure behind: what its bands took is at least what a full +/// render takes, so the next zoom gets its preview (and, in the browser, its wait) instead of +/// starting from scratch at once, as many times as the zoom goes on. +#[test] +fn a_render_dropped_while_drawing_leaves_its_time_as_the_measure() { + let view = |scale| view_at((300.0, 200.0), scale, 600, 600); + let s = slow_scene(&[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)); + // Previews once renders have taken any time at all: a band has, where no measure (0 ms) hasn't. + let mut r = Renders::with_threshold(1e-9); + assert!(matches!(r.refine_for(&at(1.0), 0.0), Refined::More)); + assert!(r.preview(&at(2.0)).is_some(), "the zoom gets a preview by the dropped render's measure"); +} diff --git a/docs/gaps.md b/docs/gaps.md index cac8392f..537b6315 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-29 · **Change:** minor (`.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; 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) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-29 · **Change:** minor (gap 7: the browser draws a zoom's full render a band at a time; `.fig` import keeps the file's saved line breaks while the text and its box are unedited (#1236); FigJam outer shape outlines and sticky body fallback beside author text (#1341, #1397); Arabic draft catalog; 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 @@ -273,6 +273,12 @@ 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-29):** the browser draws a zoom's full render a band at a time (`Retained::begin`, + `Steps`; at most eight bands, so the longest frame is about an eighth of the render where it was + the whole of it), so input is handled while it draws: a pan or an edit meanwhile is patched into + the render once drawn (or it is drawn again, the first shown till then), a zoom drops it. Bands + overlap by what background blurs read, as the desktop's parallel bands do, and the bands together + take longer than one render (each repeats its overlap and some per-band work). - **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 @@ -555,6 +561,7 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe | Date | Change | Summary | |---|---|---| +| 2026-10-29 | minor | Gap 7: the browser draws a zoom's full render a band at a time (eight at most), so a deep zoom no longer holds the page for the whole render; pans and edits meanwhile are patched in once it is drawn. Numbers unchanged | | 2026-10-28 | minor | Arabic draft: 1,857 UI strings, 620 command labels, 31 menu wordings; count-neutral plurals and web font gate. Native review and panel mirroring remain open; grade and estimates unchanged. Evidence: `locales/ar.toml`, i18n catalog tests and [translation notes](translating.md#arabic-draft) | | 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-27 | minor | M12: the MCP agent feedback workflow landed (comments as tasks, `comment.prompt`, Copy for AI, `fix_comments` / `watch_comments`, `figcraft://comments`, a bounded event log, `events.poll` and `wait_for_changes`). Figma AI features and local models are still not started, so gap 19's missing list and estimate stand | From 3f896453b66c9cbcaf364d900367a7860772bb46 Mon Sep 17 00:00:00 2001 From: samhyun Date: Thu, 29 Oct 2026 23:08:12 +0900 Subject: [PATCH 2/2] M8: a test for stacked background blurs drawn in bands; the eight-band wording From the review of #1496: three background blurs stacked on each other across the band boundaries draw the same in bands as in one piece (within anti-aliasing), so the bands' overlap, the reach of the largest blur, is enough, as it is for the desktop's parallel bands; pinned by a test. The docs and the `MAX_BANDS` comment no longer put the longest frame at "an eighth of the render": a frame draws at least a band, a part of the render rather than the whole, and the numbers measured for #1496 are cited instead. Co-Authored-By: Claude Fable 5.1 --- crates/render/src/retained_tests.rs | 28 ++++++++++++++++++++++ crates/ui-egui/src/canvas/render_worker.rs | 7 +++--- docs/gaps.md | 5 ++-- 3 files changed, 35 insertions(+), 5 deletions(-) diff --git a/crates/render/src/retained_tests.rs b/crates/render/src/retained_tests.rs index 89b7b7a1..518033ae 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -345,3 +345,31 @@ fn a_reused_or_patched_render_needs_no_steps() { let panned = View { origin: (12.0, 0.0), ..v }; assert!(matches!(r.begin(&s.doc, s.page, &panned, &Options::default(), 40), Some(crate::Begun::Done(_, _, Work::Partial { .. })))); } + +/// Background blurs stacked on each other (the upper reads the lower's blurred pixels) across the +/// band boundaries: the bands' overlap, the reach of the largest blur, is enough for the image to +/// match one drawn in one piece, as it is for the desktop's parallel bands. +#[test] +fn stacked_backdrop_blurs_draw_the_same_in_bands() { + fonts(); + let mut d = Document::new("stacked"); + let page = d.first_page().unwrap(); + // Stripes to blur, then three translucent blurred panels overlapping each other. + for i in 0..40 { + let r = d.create(page, NodeKind::Rectangle, "S", 0.0, f64::from(i) * 10.0, 400.0, 5.0).unwrap(); + solid(d.get_mut(r).unwrap(), if i % 2 == 0 { "E03030" } else { "3070E0" }); + } + for (x, y) in [(40.0, 60.0), (120.0, 120.0), (60.0, 200.0)] { + let b = d.create(page, NodeKind::Rectangle, "B", x, y, 220.0, 160.0).unwrap(); + d.get_mut(b).unwrap().fills = vec![Paint::solid(Color::new(1.0, 1.0, 1.0, 0.25))]; + d.get_mut(b).unwrap().effects.push(Effect { kind: EffectKind::BackgroundBlur(figcraft_doc::Blur::uniform(24.0)), visible: true }); + } + let doc = Arc::new(d); + let v = View { origin: (0.0, 0.0), scale: 1.0, width: 400, height: 400 }; + for band_h in [64, 100, 200] { + let mut r = Retained::default(); + let (shown, pix, n) = stepped(&mut r, &doc, page, &v, band_h); + assert!(n >= 3, "{n} bands of {band_h} rows"); + assert_same(&pix, &render_view(&doc, page, &shown, &Options::default()).unwrap(), &format!("bands of {band_h} rows")); + } +} diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 0ffdd34f..3d7659f5 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -42,9 +42,10 @@ pub const SETTLE_MS: u64 = 120; pub const STEP_MS: f64 = 24.0; /// Bands are at least this many rows ... const MIN_BAND_ROWS: u32 = 64; -/// ... and a view has at most this many: the longest frame of a render drawn in steps is about a -/// band's, an eighth of the render plus its overlap (more bands would repeat more of the overlap -/// and of the per-band work). +/// ... and a view has at most this many: a frame draws at least one band, so the longest frame of +/// a render drawn in steps is about a band's time, a part of the render rather than the whole of it +/// (0.7–1.4 s where the render took 3.7–4.0 s, measured for #1496; more bands would repeat more of +/// the overlap and of the per-band work). const MAX_BANDS: u32 = 8; #[derive(Clone)] diff --git a/docs/gaps.md b/docs/gaps.md index 537b6315..2c04ec5b 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -274,8 +274,9 @@ 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-29):** the browser draws a zoom's full render a band at a time (`Retained::begin`, - `Steps`; at most eight bands, so the longest frame is about an eighth of the render where it was - the whole of it), so input is handled while it draws: a pan or an edit meanwhile is patched into + `Steps`; at least a band a frame and at most eight bands, so a frame draws a part of the render + where it drew the whole of it: 0.7–1.4 s where the render took 3.7–4.0 s in the measurement of + #1496), so input is handled while it draws: a pan or an edit meanwhile is patched into the render once drawn (or it is drawn again, the first shown till then), a zoom drops it. Bands overlap by what background blurs read, as the desktop's parallel bands do, and the bands together take longer than one render (each repeats its overlap and some per-band work).