From 2d07bc5295ef71ec3dffbb6e52f67e0833291cc4 Mon Sep 17 00:00:00 2001 From: harrisonoconnorhover <130776285+harrisonoconnorhover@users.noreply.github.com> Date: Tue, 13 Oct 2026 17:28:38 -0400 Subject: [PATCH] fix: refresh canvas when switching parent editing --- ROADMAP.md | 4 +- crates/ui-egui/examples/ui_shot.rs | 1 + crates/ui-egui/src/canvas.rs | 18 +++- crates/ui-egui/src/lib.rs | 2 + crates/ui-egui/src/parent_canvas_tests.rs | 104 ++++++++++++++++++++++ 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 crates/ui-egui/src/parent_canvas_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..2499c222d 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -2,7 +2,7 @@ **Stage: pre-alpha** · next: alpha, ~0% and ~15–25 h away; beta ~28 points and ~420–750 h beyond that (the core-workflow gate, not the percentage: ready for real work is already ~47%) -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-12 · **Change:** minor (cancelable macOS Dock Quit) · **Target:** Adobe InDesign 2026 +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-13 · **Change:** minor (parent canvas refresh) · **Target:** Adobe InDesign 2026 DesignCraft aims at full Adobe InDesign parity, and to be better: faster, open (documented JSON format + IDML), scriptable by agents (MCP), and available on the web. @@ -127,6 +127,8 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m ## Progress log +- **2026-10-13** · Switching between document and parent pages refreshes the canvas immediately, preventing black parent pages and stale document objects when the zoom stays the same. Ordinary edits retain partial repainting. No readiness percentage change. + Newest first. - **2026-10-12** · macOS Dock Quit uses the same unsaved-document prompts as window and menu Quit. Repeated requests keep the current answers; Cancel retains every tab and unsaved recovery checkpoint, and completed Save/Don't Save answers exit through the ordinary event loop. No readiness percentage change. diff --git a/crates/ui-egui/examples/ui_shot.rs b/crates/ui-egui/examples/ui_shot.rs index c0bf38943..c241df135 100644 --- a/crates/ui-egui/examples/ui_shot.rs +++ b/crates/ui-egui/examples/ui_shot.rs @@ -31,6 +31,7 @@ fn main() { } let mut harness = egui_kittest::Harness::builder() .with_size(egui::vec2(1440.0, 900.0)) + .with_step_dt(1.0 / 60.0) .with_pixels_per_point(2.0) .with_max_steps(1_000_000) .wgpu() diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..0289ad9f0 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -372,12 +372,14 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let Some(st) = app.session.active() else { return }; let ppp = ctx.pixels_per_point() as f64; let doc_key = (st.uid, std::sync::Arc::as_ptr(&st.doc) as u64, st.revision, preview); + let editing_parents = st.editing_parents; // Overscan: 25% of the viewport on every side, within the GPU's maximum texture size. let max_side = ctx.input(|i| i.max_texture_side) as f32 / ppp as f32; let (ow, oh) = ((rect.width() * 0.25).min((max_side - rect.width()) / 2.0).max(0.0), (rect.height() * 0.25).min((max_side - rect.height()) / 2.0).max(0.0)); let want = |origin: Point| crate::Shown { doc: doc_key, + editing_parents, origin: Point::new(origin.x - ow as f64 / xf.zoom, origin.y - oh as f64 / xf.zoom), zoom: xf.zoom, size: (rect.width() + 2.0 * ow, rect.height() + 2.0 * oh), @@ -385,7 +387,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, let target = want(xf.origin); // Does the current texture cover the viewport at this zoom and document state? let covers = |s: &crate::Shown| { - s.doc == doc_key && (s.zoom - xf.zoom).abs() < 1e-9 && { + s.doc == doc_key && s.editing_parents == editing_parents && (s.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - s.origin.x) * s.zoom; let y0 = (xf.origin.y - s.origin.y) * s.zoom; x0 >= -0.5 && y0 >= -0.5 && x0 + rect.width() as f64 <= s.size.0 as f64 + 0.5 && y0 + rect.height() as f64 <= s.size.1 as f64 + 0.5 @@ -407,6 +409,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, return; } let doc_changed = app.canvas.shown.is_none_or(|s| s.doc != doc_key); + let mode_changed = app.canvas.shown.is_some_and(|s| s.editing_parents != editing_parents); let zoom_same = app.canvas.shown.is_some_and(|s| (s.zoom - xf.zoom).abs() < 1e-9); let job = |app: &DesignApp| { let st = app.session.active()?; @@ -449,7 +452,9 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, if doc_changed && zoom_same && patch_texture(app, layout, ppp, doc_key, preview) { return; } - if !has_worker || app.canvas.shown.is_none() || (doc_changed && zoom_same) { + // 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) { 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); @@ -457,6 +462,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, app.canvas.shown = Some(target); app.canvas.shown_doc = Some(doc); app.canvas.pending = None; + app.canvas.pending_doc = None; app.perf.render_ms = crate::now_ms() - t0; return; } @@ -464,7 +470,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, { // Already rendering this view? if app.canvas.pending.is_some_and(|(_, p)| { - p.doc == doc_key && (p.zoom - xf.zoom).abs() < 1e-9 && { + p.doc == doc_key && p.editing_parents == editing_parents && (p.zoom - xf.zoom).abs() < 1e-9 && { let x0 = (xf.origin.x - p.origin.x) * p.zoom; let y0 = (xf.origin.y - p.origin.y) * p.zoom; x0 >= 0.0 && y0 >= 0.0 && x0 + rect.width() as f64 <= p.size.0 as f64 && y0 + rect.height() as f64 <= p.size.1 as f64 @@ -487,7 +493,7 @@ fn render_texture(app: &mut DesignApp, ctx: &egui::Context, rect: Rect, xf: &Xf, /// Returns false when a full render is needed (view moved, too much changed, or damage unknown). fn patch_texture(app: &mut DesignApp, layout: &CanvasLayout, ppp: f64, doc_key: (u64, u64, u64, bool), preview: bool) -> bool { let (Some(sh), Some(old), Some(st)) = (app.canvas.shown, app.canvas.shown_doc.clone(), app.session.active()) else { return false }; - if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || app.canvas.texture.is_none() { + if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || sh.editing_parents != st.editing_parents || app.canvas.texture.is_none() { return false; } let new = st.doc.clone(); @@ -1771,6 +1777,10 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec true } +#[cfg(test)] +#[path = "parent_canvas_tests.rs"] +mod parent_canvas_tests; + #[cfg(test)] mod tests { use designcraft_geom::Unit; diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index fc7a3e634..6e7650b92 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -410,6 +410,8 @@ impl Default for View { pub struct Shown { /// (doc uid, doc pointer, revision, preview). pub doc: (u64, u64, u64, bool), + /// Parent and document spreads have different canvas placement and content. + pub editing_parents: bool, /// Canvas point at the texture's top-left, zoom, and texture size in screen points. pub origin: Point, pub zoom: f64, diff --git a/crates/ui-egui/src/parent_canvas_tests.rs b/crates/ui-egui/src/parent_canvas_tests.rs new file mode 100644 index 000000000..b3197a7e0 --- /dev/null +++ b/crates/ui-egui/src/parent_canvas_tests.rs @@ -0,0 +1,104 @@ +use super::*; + +fn app_with_document_object() -> DesignApp { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"width": 64, "height": 64, "pages": 1, "facingPages": true, "margins": 4})).unwrap(); + app.run("frame.create", json!({"rect": [24, 24, 40, 40], "content": "unassigned"})).unwrap(); + app.run("object.fill", json!({"swatch": "[Black]"})).unwrap(); + // Exercise the production texture cache without starting a background thread. + app.canvas.worker_started = true; + app +} + +fn render(app: &mut DesignApp, ctx: &egui::Context) -> egui::epaint::ImageDelta { + render_at_zoom(app, ctx, 1.0) +} + +fn render_at_zoom(app: &mut DesignApp, ctx: &egui::Context, zoom: f64) -> egui::epaint::ImageDelta { + let rect = Rect::from_min_size(Pos2::ZERO, vec2(128.0, 64.0)); + let view = View { zoom, origin: Point::new(-64.0, 0.0), fitted: true, rotation: 0 }; + let mut output = 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); + }); + let id = app.canvas.texture.as_ref().unwrap().id(); + let deltas = output.textures_delta.set.remove(&id); + output.textures_delta.clear(); + let mut deltas = deltas.expect("canvas pixels must update"); + assert_eq!(deltas.len(), 1, "one canvas render should produce one texture update"); + deltas.pop().unwrap() +} + +fn pixel(delta: &egui::epaint::ImageDelta, shown: crate::Shown, point: Point) -> Color32 { + let egui::ImageData::Color(image) = &delta.image; + let offset = delta.pos.unwrap_or([0, 0]); + let x = ((point.x - shown.origin.x) * shown.zoom).round() as usize - offset[0]; + let y = ((point.y - shown.origin.y) * shown.zoom).round() as usize - offset[1]; + image[(x, y)] +} + +#[test] +fn switching_parent_editing_repaints_pages_and_document_objects() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let left_page = Point::new(-32.0, 32.0); + let document_object = Point::new(32.0, 32.0); + let before = render(&mut app, &ctx); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&before, shown, left_page), Color32::TRANSPARENT); + let document_color = pixel(&before, shown, document_object); + assert_ne!(document_color, Color32::WHITE); + assert_eq!(document_color.a(), 255); + + let doc = app.session.active().unwrap().doc.clone(); + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + assert!(std::sync::Arc::ptr_eq(&doc, &app.session.active().unwrap().doc), "switching canvas mode does not edit the document"); + let parent = render(&mut app, &ctx); + assert!(parent.pos.is_none(), "the parent canvas needs a complete repaint"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&parent, shown, left_page), Color32::WHITE, "the parent's left page must become visible"); + assert_eq!(pixel(&parent, shown, document_object), Color32::WHITE, "document-only objects must disappear in parent editing"); + + app.run("layout.parents.edit", json!({"on": false})).unwrap(); + let restored = render(&mut app, &ctx); + assert!(restored.pos.is_none()); + let shown = app.canvas.shown.unwrap(); + assert_eq!(pixel(&restored, shown, left_page), Color32::TRANSPARENT); + assert_eq!(pixel(&restored, shown, document_object), document_color); +} + +#[cfg(not(target_arch = "wasm32"))] +#[test] +fn switching_parent_editing_with_a_new_zoom_repaints_before_the_worker() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + app.canvas.worker = Some(crate::render_worker::Worker::spawn(ctx.clone()).expect("render worker must start")); + let before = render(&mut app, &ctx); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), Point::new(32.0, 32.0)), Color32::WHITE); + + // Fitting the parent spread can change the zoom as well as the canvas mode. + app.run("layout.parents.edit", json!({"on": true})).unwrap(); + let parent = render_at_zoom(&mut app, &ctx, 0.75); + assert!(parent.pos.is_none(), "a mode switch must upload the new canvas in this frame"); + assert!(app.canvas.pending.is_none(), "parent pages must not wait behind an asynchronous zoom render"); + let shown = app.canvas.shown.unwrap(); + assert_eq!(shown.zoom, 0.75); + assert_eq!(pixel(&parent, shown, Point::new(-32.0, 32.0)), Color32::WHITE); + assert_eq!(pixel(&parent, shown, Point::new(32.0, 32.0)), Color32::WHITE); +} + +#[test] +fn small_edits_in_the_same_canvas_mode_still_repaint_partially() { + let mut app = app_with_document_object(); + let ctx = egui::Context::default(); + let before = render(&mut app, &ctx); + let point = Point::new(32.0, 32.0); + assert_ne!(pixel(&before, app.canvas.shown.unwrap(), point), Color32::WHITE); + + app.run("object.fill", json!({"swatch": "[Paper]"})).unwrap(); + let patch = render(&mut app, &ctx); + assert!(patch.pos.is_some(), "an ordinary small edit should keep the partial repaint optimization"); + assert_eq!(app.canvas.patches, 1); + assert_eq!(pixel(&patch, app.canvas.shown.unwrap(), point), Color32::WHITE); +}