Browse / github.com/storytold/designcraft / patch
Canvas: a slow frame loses no clicks or drags, and slow edits render on the worker
A slow canvas render stalls the UI (edits render on the UI thread however long that takes) **What happens:** On a heavy document (large placed images, drop shadows, the CMYK blend-space view), the UI regularly stops answering for a moment while the CPU is mostly idle, and clicks and settings get lost. Pulling a guide out of a ruler often does nothing: the guide never appears. **Why:** 1. **Edits render on the UI thread.** `render_texture` renders every document edit synchronously "so editing never lags", however long that takes. Effects are drawn offscreen on a single-threaded context, so one core works while the UI waits. 2. **A slow frame delivers all input at once.** Everything
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- PiMinusMax (imported from pull request #1747, open)
- Base
ed8aabd1dc5a88652a076631c2d46886ca60c031- Size
- +260 −7 in 5 files
- Review
- pending
- Shared
- 2026-10-28
- Id
a4fc2a8e4547a8a859fb34ee
How it went
No other operator has reported using it yet.
Diff
raw · ROADMAP.md crates/ui-egui/src/canvas.rs crates/ui-egui/src/guide_selection_tests.rs crates/ui-egui/src/lib.rs crates/ui-egui/src/slow_render_tests.rs
@@ -380,6 +380,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-24** · A slow canvas no longer loses input or stalls the UI: the input is paced to one mouse-button press or release per frame, so a drag that arrives in one slow frame (a guide pulled out of a ruler, an object moved, a slider) is still a drag and two clicks stay two; and once rendering takes longer than 50 ms, edits render on the background worker as zooms do, so the UI keeps answering while the canvas catches up. No readiness percentage change. - **2026-10-20** · Edit › Keyboard Shortcuts is craft-keymap's shared dialog: commands grouped by menu with Show and Filter, recording that asks before taking a key from another command, undo and redo, named shortcut sets (edits of Default become Custom; an imported set becomes a set of its own), an on-screen US keyboard, InDesign Show Set files reviewed before they apply, JSON export and an HTML summary. ui.json keeps the `shortcuts` map earlier versions read, for the active set. No readiness percentage change. - **2026-10-19** · Import InDesign Set… runs on craft-keymap's importer: a set's Window menu panel items (Layers F7, Pages F12, Swatches F5) and Tools items set the panel and tool shortcuts, all of an item's Default keys apply, and the report adds numeric keypad keys read as main keys and likely commands for unmatched items (`window.importShortcuts`). On InDesign's Windows defaults it matches 366 of 1,629 items (was 233) and changes 23 commands. No readiness percentage change. - **2026-10-19** · Keyboard shortcuts run on the shared craft-keymap crate. Panels take shortcuts, with InDesign's defaults where the key is free (Swatches F5, Color F6, Layers F7, Info F8, Stroke F10, Paragraph Styles F11, Character Styles Shift+F11, Pages F12, Align Shift+F7); tool keys are editable in Edit › Keyboard Shortcuts and `window.setShortcut`, and tools sharing a key take turns (B). Saved shortcuts load unchanged. No readiness percentage change.@@ -15,6 +15,9 @@ use serde_json::{Value, json}; use crate::{DesignApp, ScreenMode, View, theme::Tokens}; pub const RULER: f32 = 15.0;+/// The longest an edit may take to render on the UI thread (ms) before edits render on the+/// worker: about three frames.+pub const SYNC_BUDGET_MS: f64 = 50.0; /// Width of the view's scrollbars. pub const SCROLLBAR: f32 = 10.0; /// Shortest scrollbar thumb.@@ -918,6 +921,9 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, app.canvas.shown_doc = app.canvas.pending_doc.take(); app.canvas.pending = None; app.perf.render_ms = done.ms;+ // A full render this fast lets edits render on the UI thread again (and its regions).+ app.canvas.sync_ms = done.ms;+ app.canvas.patch_ms = app.canvas.patch_ms.min(done.ms); } if app.canvas.shown.is_some_and(|s| covers(&s)) { return;@@ -946,12 +952,19 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, #[cfg(target_arch = "wasm32")] let has_worker = false; // An edit with the view unchanged: repaint just the regions it changed.- if doc_changed && zoom_same && patch_texture(app, layout, ppp, doc_key, preview) {+ // While rendering is quick, edits render here so the canvas never lags behind them. Past the+ // budget the UI thread would stall: clicks and drags pile up and arrive at once, and panels+ // stop answering. Edits then render on the worker, as zooms do, and the canvas keeps showing+ // the previous image until the new one is ready.+ let sync_ok = !has_worker || app.canvas.sync_ms <= SYNC_BUDGET_MS;+ let patch_ok = !has_worker || app.canvas.patch_ms <= SYNC_BUDGET_MS;+ if doc_changed && zoom_same && patch_ok && patch_texture(app, layout, ppp, doc_key, preview) { return; } // A mode switch changes every spread's meaning, even if the document has no damage. // Show the new canvas immediately, including when fitting it changes the zoom.- if !has_worker || app.canvas.shown.is_none() || mode_changed || (doc_changed && zoom_same) {+ // A slow repaint of an edit's regions means a full render would stall too: to the worker.+ if !has_worker || app.canvas.shown.is_none() || mode_changed || (doc_changed && zoom_same && sync_ok && patch_ok) { let t0 = crate::now_ms(); let Some((doc, placed, w, h, view, opts)) = job(app) else { return }; let img = app.canvas.renderer.render(&doc, &app.session.cache, &placed, w, h, view, &opts);@@ -961,6 +974,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, app.canvas.pending = None; app.canvas.pending_doc = None; app.perf.render_ms = crate::now_ms() - t0;+ app.canvas.sync_ms = app.perf.render_ms; return; } #[cfg(not(target_arch = "wasm32"))]@@ -1001,8 +1015,14 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: return false; } let new = st.doc.clone();- let Some(regions) = designcraft_render::damage::damage_with(&old, &new, Some(&app.session.cache)) else { return false };+ // Timed from the damage check on, whichever way it ends: a slow comparison of a large+ // document counts against the budget as a slow repaint does (`patch_ms`). let t0 = crate::now_ms();+ let timed = |app: &mut DesignApp, patched: bool| {+ app.canvas.patch_ms = crate::now_ms() - t0;+ patched+ };+ let Some(regions) = designcraft_render::damage::damage_with(&old, &new, Some(&app.session.cache)) else { return timed(app, false) }; let k = sh.zoom * ppp; let mut px: Option<(i64, i64, i64, i64)> = None; for (r, rect) in ®ions {@@ -1022,15 +1042,15 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: }; let Some((x0, y0, x1, y1)) = px.map(|p| (p.0.max(0), p.1.max(0), p.2.min(tw), p.3.min(th))) else { done(app);- return true;+ return timed(app, true); }; if x1 <= x0 || y1 <= y0 { done(app);- return true;+ return timed(app, true); } // Big changes: a full render costs about the same. if ((x1 - x0) * (y1 - y0)) as f64 > 0.5 * (tw * th) as f64 {- return false;+ return timed(app, false); } let placed: Vec<designcraft_render::Placed> = layout.slots.iter().map(|s| designcraft_render::Placed { spread: s.spread, xf: s.xf }).collect(); let view = Affine::translate((-(x0 as f64), -(y0 as f64)))@@ -1046,7 +1066,7 @@ fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: done(app); app.canvas.patches += 1; app.perf.render_ms = crate::now_ms() - t0;- true+ timed(app, true) } #[cfg(not(target_arch = "wasm32"))]@@ -4156,6 +4176,10 @@ mod parent_canvas_tests; #[path = "guide_selection_tests.rs"] mod guide_selection_tests; +#[cfg(test)]+#[path = "slow_render_tests.rs"]+mod slow_render_tests;+ #[cfg(test)] mod tests { use designcraft_geom::Unit;@@ -328,3 +328,101 @@ fn a_guide_dragged_out_of_a_ruler_snaps() { drag(&mut h, ruler, dest); assert_eq!(positions(&h), [200.0, 300.0, 400.0, 400.0]); }++/// A slow frame brings a whole drag at once: the press in the ruler, the moves and the release.+/// The guide still lands (the input is paced to one button change per frame); without that, the+/// drag never started and the guide was lost, like other drags and clicks on a slow canvas.+#[test]+fn a_guide_dragged_out_of_a_ruler_lands_when_the_whole_drag_comes_in_one_frame() {+ let ctx = egui::Context::default();+ let screen = egui::Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0));+ let frame = |app: &mut DesignApp, events: Vec<Event>, paced: bool| {+ let mut raw = egui::RawInput { screen_rect: Some(screen), events, ..Default::default() };+ if paced {+ app.raw_input_hook(&ctx, &mut raw);+ }+ let mut out = ctx.run_ui(raw, |ui| {+ app.logic(ui.ctx());+ app.ui(ui);+ });+ out.textures_delta.clear();+ };+ let guides = |app: &DesignApp| app.session.active().unwrap().doc.spreads[0].pages[0].guides.len();+ for paced in [false, true] {+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+ app.run("file.new", json!({})).unwrap();+ for _ in 0..6 {+ frame(&mut app, vec![], paced);+ }+ let rect = app.canvas_rect.unwrap();+ let (from, to) = (pos2(rect.center().x, rect.min.y - crate::canvas::RULER / 2.0), rect.center());+ let press = |pos, pressed| Event::PointerButton { pos, button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };+ let gesture = vec![+ Event::PointerMoved(from),+ press(from, true),+ Event::PointerMoved(from + (to - from) * 0.5),+ Event::PointerMoved(to),+ press(to, false),+ ];+ frame(&mut app, gesture, paced);+ for _ in 0..4 {+ frame(&mut app, vec![], paced);+ }+ assert_eq!(guides(&app), usize::from(paced), "paced {paced}: the guide lands only when the input is paced");+ assert!(app.held_input.is_empty());+ }+}++/// The pacing holds back everything from the second button change on, in order.+#[test]+fn input_is_paced_to_one_button_change_per_frame() {+ let ctx = egui::Context::default();+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+ let p = |x: f32| pos2(x, 10.0);+ let button = |x: f32, pressed| Event::PointerButton { pos: p(x), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };+ let all = vec![+ Event::PointerMoved(p(1.0)),+ button(1.0, true),+ Event::PointerMoved(p(2.0)),+ button(2.0, false),+ Event::PointerMoved(p(3.0)),+ button(3.0, true),+ button(3.0, false),+ ];+ let mut frames = Vec::new();+ let mut next = all.clone();+ for _ in 0..5 {+ let mut raw = egui::RawInput { events: std::mem::take(&mut next), ..Default::default() };+ app.raw_input_hook(&ctx, &mut raw);+ frames.push(raw.events);+ }+ assert_eq!(frames[0], all[..3]);+ assert_eq!(frames[1], all[3..5]);+ assert_eq!(frames[2], all[5..6]);+ assert_eq!(frames[3], all[6..7]);+ assert!(frames[4].is_empty());+ // Held input comes before what the next frame brings.+ let mut raw = egui::RawInput { events: vec![button(4.0, true), button(4.0, false)], ..Default::default() };+ app.raw_input_hook(&ctx, &mut raw);+ let mut later = egui::RawInput { events: vec![Event::PointerMoved(p(5.0))], ..Default::default() };+ app.raw_input_hook(&ctx, &mut later);+ assert_eq!(later.events, vec![button(4.0, false), Event::PointerMoved(p(5.0))]);+}++/// An injected button change (control channel, scripts) waits a pass when the user's own (or one+/// held back) is already in this pass: no pass sees two.+#[test]+fn an_injected_button_change_waits_for_a_pass_without_one() {+ let ctx = egui::Context::default();+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+ let button = |x: f32, pressed| Event::PointerButton { pos: pos2(x, 10.0), button: PointerButton::Primary, pressed, modifiers: Modifiers::NONE };+ app.synthetic.push(button(5.0, true));+ let buttons = |raw: &egui::RawInput| raw.events.iter().filter(|e| matches!(e, Event::PointerButton { .. })).count();+ let mut raw = egui::RawInput { events: vec![button(1.0, true)], ..Default::default() };+ app.raw_input_hook(&ctx, &mut raw);+ assert_eq!(buttons(&raw), 1, "only the user's");+ assert_eq!(app.synthetic.len(), 1, "the injected one waits");+ let mut next = egui::RawInput::default();+ app.raw_input_hook(&ctx, &mut next);+ assert_eq!(next.events, vec![button(5.0, true)], "and comes in the next pass");+}@@ -620,6 +620,8 @@ impl CanvasCache { patcher: designcraft_render::Renderer::new(), patches: 0, separations: separations::Preview::default(),+ sync_ms: 0.0,+ patch_ms: 0.0, } } }@@ -653,6 +655,10 @@ pub struct CanvasCache { pub patches: u64, /// View › Separations Preview: the visible spreads' plates. pub separations: separations::Preview,+ /// How long the canvas last took to render whole, and to repaint an edit's regions (ms): past+ /// [`canvas::SYNC_BUDGET_MS`], edits render on the worker instead of the UI thread.+ pub sync_ms: f64,+ pub patch_ms: f64, } #[derive(Default, Clone, Copy, Debug, Serialize)]@@ -741,6 +747,8 @@ pub struct DesignApp { pub color_applied: bool, pub perf: Perf, pub synthetic: Vec<egui::Event>,+ /// Input held back for the next frame (see [`DesignApp::pace_pointer_buttons`]).+ held_input: Vec<egui::Event>, /// Control input stays separate from native-menu input until its frame is processed. pub(crate) control_synthetic: Vec<egui::Event>, /// Resolved native menu choices, kept stable across menu rebuilds until input settles.@@ -897,6 +905,7 @@ impl DesignApp { color_applied: false, perf: Perf::default(), synthetic: vec![],+ held_input: vec![], control_synthetic: vec![], native_actions: Default::default(), native_events_pending: Default::default(),@@ -1489,6 +1498,10 @@ impl DesignApp { new_document::end_stale_preview(self); } self.request_hidden_paint_for_pending_input(ctx);+ // Input held back for the next frame (`pace_pointer_buttons`) needs that frame.+ if !self.held_input.is_empty() {+ ctx.request_repaint();+ } self.handle_quit(ctx); self.flush_window_commands(ctx); }@@ -1906,6 +1919,8 @@ impl DesignApp { } } }+ // Presses and releases beyond the first button change wait for the next pass.+ self.pace_pointer_buttons(raw); self.discard_stale_injected_batch(raw); // A batch acknowledged by the previous pass keeps its private provenance through that // pass's end hook (the clipboard guard), and is cleared at the next host iteration.@@ -1922,6 +1937,12 @@ impl DesignApp { let private = !self.control_synthetic.is_empty(); let events = if private { &mut self.control_synthetic } else { &mut self.synthetic }; let Some(first) = events.first() else { return };+ // A button change of the user's (or one held back from the last pass) is in this pass+ // already: an injected one waits for the next, so no pass sees two (see+ // `pace_pointer_buttons`). Pending injection keeps the passes coming.+ if matches!(first, egui::Event::PointerButton { .. }) && raw.events.iter().any(|e| matches!(e, egui::Event::PointerButton { .. })) {+ return;+ } let n = match first { egui::Event::PointerMoved(_) | egui::Event::PointerButton { .. } => 1, _ => events.iter().position(|e| matches!(e, egui::Event::Key { pressed: false, .. })).map_or(events.len(), |i| i + 1),@@ -2086,6 +2107,24 @@ impl DesignApp { } } + /// One press or release of a mouse button per frame. While a frame is slow (a big render, a+ /// save), everything the user does meanwhile arrives in the next frame at once: a press, the+ /// moves and the release of a whole drag. egui then sees the button go down and up within one+ /// frame, so a drag never starts (a guide pulled out of a ruler, a slider, an object moved)+ /// and two clicks become one. The events from the second button change on wait for the next+ /// frame, in order, so each frame holds at most one press or release.+ fn pace_pointer_buttons(&mut self, raw: &mut egui::RawInput) {+ if !self.held_input.is_empty() {+ let mut held = std::mem::take(&mut self.held_input);+ held.append(&mut raw.events);+ raw.events = held;+ }+ let mut buttons = raw.events.iter().enumerate().filter(|(_, e)| matches!(e, egui::Event::PointerButton { .. }));+ if let (Some(_), Some((second, _))) = (buttons.next(), buttons.next()) {+ self.held_input = raw.events.split_off(second);+ }+ }+ /// Lay out the whole window. pub fn ui(&mut self, ui: &mut egui::Ui) { #[cfg(test)]new file mode 100644@@ -0,0 +1,91 @@+//! Edits render on the UI thread while that is quick, and on the worker once it is slow, so a+//! heavy document doesn't stall the UI (clicks piling up, panels not answering).++use super::*;++fn frame(app: &mut DesignApp, ctx: &egui::Context) {+ let rect = Rect::from_min_size(Pos2::ZERO, vec2(400.0, 300.0));+ let view = View { zoom: 1.0, origin: Point::new(0.0, 0.0), fitted: true, rotation: 0 };+ let mut out = ctx.run_ui(Default::default(), |ui| {+ let st = app.session.active().unwrap();+ let layout = CanvasLayout::new(&st.doc, st.editing_parents);+ render_texture(app, ui.ctx(), rect, &Xf::new(rect, &view), &layout, false);+ });+ out.textures_delta.clear();+}++/// The texture's document is the current one.+fn current(app: &DesignApp) -> bool {+ let st = app.session.active().unwrap();+ app.canvas.shown.is_some_and(|s| s.doc == (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, false))+}++#[test]+fn edits_render_on_the_worker_once_rendering_is_slow() {+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+ app.run("file.new", json!({})).unwrap();+ let ctx = egui::Context::default();+ frame(&mut app, &ctx);+ assert!(current(&app), "the first frame renders at once");+ if app.canvas.worker.is_none() {+ // DESIGNCRAFT_SYNC_RENDER: no worker, everything renders here.+ return;+ }+ // Quick: an edit shows in the same frame.+ app.canvas.sync_ms = 0.0;+ app.canvas.patch_ms = 0.0;+ app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();+ frame(&mut app, &ctx);+ assert!(current(&app) && app.canvas.pending.is_none(), "a quick edit renders on the UI thread");+ // Slow: the edit goes to the worker; the UI thread doesn't wait for it.+ app.canvas.sync_ms = 10.0 * SYNC_BUDGET_MS;+ app.canvas.patch_ms = 10.0 * SYNC_BUDGET_MS;+ app.run("frame.create", json!({"rect": [140, 20, 240, 120]})).unwrap();+ frame(&mut app, &ctx);+ assert!(!current(&app) && app.canvas.pending.is_some(), "a slow edit renders on the worker");+ for _ in 0..500 {+ if app.canvas.pending.is_none() {+ break;+ }+ std::thread::sleep(std::time::Duration::from_millis(10));+ frame(&mut app, &ctx);+ }+ assert!(current(&app), "the worker's image arrives");+ assert!(app.canvas.sync_ms < 10.0 * SYNC_BUDGET_MS, "its time replaces the slow one");+}++/// A slow repaint of an edit's regions sends the next edit to the worker even when the last full+/// render was quick: a full render on the UI thread would stall at least as long.+#[test]+fn after_a_slow_patch_the_next_edit_goes_to_the_worker() {+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+ app.run("file.new", json!({})).unwrap();+ let ctx = egui::Context::default();+ frame(&mut app, &ctx);+ if app.canvas.worker.is_none() {+ return;+ }+ app.canvas.sync_ms = 0.0;+ app.canvas.patch_ms = 10.0 * SYNC_BUDGET_MS;+ app.run("frame.create", json!({"rect": [20, 20, 120, 120]})).unwrap();+ frame(&mut app, &ctx);+ assert!(!current(&app) && app.canvas.pending.is_some(), "the edit renders on the worker");+}++/// Every repaint attempt records its time, also one that ends early (here: the edit is off the+/// canvas, nothing to repaint), so the budget never judges by a stale earlier time.+#[test]+fn a_repaint_that_ends_early_records_its_time_too() {+ let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());+ app.run("file.new", json!({})).unwrap();+ let ctx = egui::Context::default();+ frame(&mut app, &ctx);+ if app.canvas.worker.is_none() {+ return;+ }+ // At the budget: still allowed to patch; a fresh time replaces it.+ app.canvas.patch_ms = SYNC_BUDGET_MS;+ app.run("frame.create", json!({"rect": [20000, 20000, 20010, 20010]})).unwrap();+ frame(&mut app, &ctx);+ assert!(app.canvas.patch_ms < SYNC_BUDGET_MS, "{}", app.canvas.patch_ms);+}