fix: refresh canvas when switching parent editing

This commit is contained in:
harrisonoconnorhover committed 2026-10-13 17:28:38 -04:00
1 parent e862e0a613
commit 2d07bc5295
5 files changed
+124 -5

No files matched your search

+3 -1
View File
@@ -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.
+1
View File
@@ -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()
+14 -4
View File
@@ -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;
+2
View File
@@ -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,
+104
View File
@@ -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);
}