diff --git a/crates/render/src/lib.rs b/crates/render/src/lib.rs index 9f2064fc..4625ed7f 100644 --- a/crates/render/src/lib.rs +++ b/crates/render/src/lib.rs @@ -26,7 +26,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}; @@ -89,7 +89,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) @@ -112,7 +112,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 } @@ -146,42 +146,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..518033ae 100644 --- a/crates/render/src/retained_tests.rs +++ b/crates/render/src/retained_tests.rs @@ -287,3 +287,89 @@ 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 { .. })))); +} + +/// 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 94b2d3d8..3d7659f5 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,17 @@ 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: 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)] pub struct Job { @@ -100,11 +114,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 +126,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 +162,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 +223,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 +235,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 +268,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 +579,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 +592,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..2c04ec5b 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,13 @@ 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 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). - **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 +562,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 |