diff --git a/crates/ui-egui/src/canvas/render_worker.rs b/crates/ui-egui/src/canvas/render_worker.rs index 0cd3e040a..94b2d3d8d 100644 --- a/crates/ui-egui/src/canvas/render_worker.rs +++ b/crates/ui-egui/src/canvas/render_worker.rs @@ -13,7 +13,9 @@ //! the render from scratch has run past [`PREVIEW_AFTER_MS`], beside it (the full render keeps the //! other cores), and drops it if the full image is there first. //! The browser renders on the UI thread, so a zoom's full render blocks the page: there a preview -//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused). +//! is refined only once no newer request has come for [`SETTLE_MS`] (the zoom has paused), or for +//! as long as the last full render took when that is shorter (a wait never costs more than the +//! render it may save). use std::sync::Arc; @@ -27,9 +29,9 @@ use web_time::Instant; pub const PREVIEW_FACTOR: f64 = 4.0; /// Full renders quicker than this (about a frame) show without a preview. pub const PREVIEW_AFTER_MS: f64 = 12.0; -/// The browser refines a zoom's preview only once no newer request came for this long: a full -/// render there blocks the page, and one between every two wheel steps of a zoom held it up for -/// seconds. +/// The browser refines a zoom's preview only once no newer request came for this long (or for as +/// long as the last full render took, when shorter): a full render there blocks the page, and one +/// between every two wheel steps of a zoom held it up for seconds. pub const SETTLE_MS: u64 = 120; #[derive(Clone)] @@ -354,7 +356,11 @@ pub struct Inline { /// rendering the preview itself can take longer than the wait (the page is blocked meanwhile, /// so no newer request could have come). since: Instant, + /// How long to wait after a preview, at most: `wait` is this capped by the last full render's + /// time, so a page whose renders are quick is not held coarse for longer than one would take. settle: std::time::Duration, + /// The wait for the pending job's preview. + wait: std::time::Duration, renders: Renders, } @@ -367,7 +373,17 @@ impl Inline { } /// Refining a preview after `settle` without newer requests (tests: zero, the next frame). pub fn with_settle(ctx: egui::Context, settle: std::time::Duration) -> Self { - Self { ctx, pending: None, submitted: 0, previewed: false, preview_shown: false, since: Instant::now(), settle, renders: Renders::default() } + Self { + ctx, + pending: None, + submitted: 0, + previewed: false, + preview_shown: false, + since: Instant::now(), + settle, + wait: settle, + renders: Renders::default(), + } } pub fn submit(&mut self, job: Job) { self.pending = Some(job); @@ -375,6 +391,7 @@ impl Inline { self.previewed = false; self.preview_shown = false; } + /// Whether a request is still to be refined (a preview's wait counts). pub fn in_flight(&self) -> bool { self.pending.is_some() } @@ -384,14 +401,16 @@ impl Inline { if let Some(d) = self.pending.as_ref().and_then(|job| self.renders.preview(job)) { self.preview_shown = true; self.since = Instant::now(); - self.ctx.request_repaint_after(self.settle); + let full = std::time::Duration::try_from_secs_f64(self.renders.full_ms / 1000.0); + self.wait = full.map_or(self.settle, |full| full.min(self.settle)); + self.ctx.request_repaint_after(self.wait); return Some(answering(d, self.submitted)); } } if self.pending.is_some() && self.preview_shown { let waited = self.since.elapsed(); - if waited < self.settle { - self.ctx.request_repaint_after(self.settle - waited); + if waited < self.wait { + self.ctx.request_repaint_after(self.wait - waited); return None; } } diff --git a/crates/ui-egui/src/canvas/zoom_preview_tests.rs b/crates/ui-egui/src/canvas/zoom_preview_tests.rs index 47a6a0260..68b11870c 100644 --- a/crates/ui-egui/src/canvas/zoom_preview_tests.rs +++ b/crates/ui-egui/src/canvas/zoom_preview_tests.rs @@ -443,21 +443,23 @@ fn the_browser_worker_refines_a_zoom_only_once_it_settles() { /// The wait counts from the preview being shown, not from the request: rendering the preview can /// take longer than the wait (the page is blocked meanwhile, so no newer request could come), and -/// counted from the request the refinement would follow in the very next frame. +/// counted from the request the refinement would follow in the very next frame. The time the +/// preview's render would take is stood in for by a sleep between the request and the poll. #[test] fn the_wait_counts_from_the_preview_not_from_the_request() { let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200); - let s = slow_scene(&[view(2.0), view(1.0)], 20.0 * PREVIEW_AFTER_MS); + // Renders from scratch of at least the settle, so the wait is the whole settle (see the cap). + let s = slow_scene(&[view(2.0), view(1.0)], SETTLE_MS as f64); let ds = s.ds().unwrap(); let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale)); - let preview_ms = preview_with(&at(1.0), &mut Retained::default()).unwrap().ms; - let settle = std::time::Duration::from_secs_f64(preview_ms / 2000.0).max(std::time::Duration::from_millis(1)); + let settle = std::time::Duration::from_millis(SETTLE_MS); let mut w = Inline::with_settle(egui::Context::default(), settle); w.submit(at(2.0)); assert!(w.poll().is_some_and(|d| !d.coarse)); w.submit(at(1.0)); - assert!(w.poll().is_some_and(|d| d.coarse), "the preview, which takes {preview_ms} ms"); - assert!(w.poll().is_none() && w.in_flight(), "the wait ({settle:?}) starts only now"); + std::thread::sleep(settle + std::time::Duration::from_millis(30)); + assert!(w.poll().is_some_and(|d| d.coarse), "the preview, longer after the request than the wait"); + assert!(w.poll().is_none() && w.in_flight(), "the wait starts only now"); std::thread::sleep(settle); assert!(w.poll().is_some_and(|d| !d.coarse && d.view.scale == 1.0)); } @@ -491,3 +493,26 @@ fn same_zoom_requests_before_the_zoom_refines_keep_waiting_and_refine_at_once_af let d = w.poll().expect("the refined image"); assert!(!d.coarse && !w.in_flight()); } + +/// The wait never costs more than the full render it may save: a preview is refined once the +/// last full render's time has passed when that is shorter than `SETTLE_MS`, so a key zoom or a +/// page switch on a page whose renders are quick is not held coarse for longer than before. +#[test] +fn the_wait_is_capped_by_the_time_of_the_last_full_render() { + let view = |scale| view_at((300.0, 200.0), scale, 1200, 1200); + let s = slow_scene(&[view(2.0), view(1.0)], 3.0 * PREVIEW_AFTER_MS); + let ds = s.ds().unwrap(); + let at = |scale| job(&ds.doc, ds.page, ds.uid, view(scale)); + // A settle far longer than this page's renders: the wait is their time instead. + let settle = std::time::Duration::from_secs(10); + let mut w = Inline::with_settle(egui::Context::default(), settle); + w.submit(at(2.0)); + let first = w.poll().expect("the first image"); + assert!(!first.coarse && first.ms >= PREVIEW_AFTER_MS, "slow enough to preview: {} ms", first.ms); + w.submit(at(1.0)); + assert!(w.poll().is_some_and(|d| d.coarse)); + assert!(w.poll().is_none() && w.in_flight(), "not refined in the very next frame"); + std::thread::sleep(std::time::Duration::from_secs_f64(first.ms / 1000.0)); + let d = w.poll().expect("refined once the last full render's time has passed, not the settle"); + assert!(!d.coarse && d.view.scale == 1.0 && !w.in_flight()); +} diff --git a/docs/gaps.md b/docs/gaps.md index 67cc979d8..9e079036e 100644 --- a/docs/gaps.md +++ b/docs/gaps.md @@ -248,9 +248,10 @@ No alpha blockers: the alpha gate in [`roadmap.md`](roadmap.md#alpha-gate) passe page, a zoom after quick renders), a watchdog thread previews any such render still running after 12 ms (desktop only; the browser renders inline). - **Landed (2026-10-28):** the browser refines a zoom's preview only once no newer request has - come for 120 ms (`SETTLE_MS`): its full renders run on the UI thread, and one between every two - wheel steps held the page up for seconds. Pans and edits (no preview) are refined at once; the - desktop, with its render thread, is unchanged. + come for 120 ms (`SETTLE_MS`), or for as long as its last full render took when that is shorter: + its full renders run on the UI thread, and one between every two wheel steps held the page up + for seconds. Pans and edits (no preview) are refined at once; the desktop, with its render + thread, is unchanged. - **Landed (2026-10-15):** a bounded render margin is reused during panning and refilled before the visible view reaches its edge. The extra rendered area is capped at 1.6× visible pixels; this reduces blank strips without changing the CPU renderer.