Merge #855: fix: repaint canvas when entering and leaving parent editing

This commit is contained in:
aiongg
2026-10-13 22:35:26 +00:00
5 changed files with 123 additions and 5 deletions
+1
View File
@@ -128,6 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m
Newest first.
- **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.
- **2026-10-13** · Commands refuse parameter keys their params description doesn't name (#776): the control channel, MCP (`-32602`, every `batch` step checked before the first runs) and `designcraft-cli --cmd` answer with an “unknown parameter” error naming the nearest or the accepted keys instead of running with defaults; journals still replay. Descriptions of the table, anchored-object, data-merge and placing commands now name every key they read. No readiness percentage change.
- **2026-10-13** · File ▸ Open Recent lists the documents last opened or saved (newest first, no duplicates; Save a Copy and exports don't count), with Clear Recent Files; the start screen shows the last eight, below the community card, as cards with a picture of the first page: saving puts one in the file (`preview.png`), so the start screen reads it without opening the document. A file that is gone is dimmed and says so when chosen, and stays listed. Preferences ▸ File Handling sets how many are kept (default 20, 0–100). `file.openRecent {index}`, `file.recentFiles`, `file.clearRecent` (#706). No readiness percentage change.
- **2026-10-13** · Dropping an `.ase` file on the window loads its swatches, as Load Swatches does (the drop failed with "Can't Place the File"). No readiness percentage change.
+1
View File
@@ -32,6 +32,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()
+15 -5
View File
@@ -640,12 +640,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),
@@ -653,7 +655,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
@@ -675,6 +677,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()?;
@@ -700,7 +703,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);
@@ -708,6 +713,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;
}
@@ -715,7 +721,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
@@ -738,7 +744,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 {
if sh.doc.0 != doc_key.0 || sh.doc.3 != doc_key.3 || sh.editing_parents != st.editing_parents {
return false;
}
let (tw, th) = ((sh.size.0 as f64 * ppp).round() as i64, (sh.size.1 as f64 * ppp).round() as i64);
@@ -2644,6 +2650,10 @@ fn release_clicks(press: Clicks, pos: Pos2, now: f64, dist: f32) -> Clicks {
Clicks { at: now, pos, n }
}
#[cfg(test)]
#[path = "parent_canvas_tests.rs"]
mod parent_canvas_tests;
#[cfg(test)]
mod tests {
use designcraft_geom::Unit;
@@ -2684,7 +2694,7 @@ mod tests {
#[test]
fn display_option_change_drops_the_render_in_flight() {
let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default());
let shown = crate::Shown { doc: (1, 2, 3, false), origin: Point::new(0.0, 0.0), zoom: 1.0, size: (10.0, 10.0) };
let shown = crate::Shown { doc: (1, 2, 3, false), origin: Point::new(0.0, 0.0), zoom: 1.0, size: (10.0, 10.0), editing_parents: false };
invalidate_on_display_change(&mut app);
app.canvas.shown = Some(shown);
app.canvas.pending = Some((7, shown));
+2
View File
@@ -453,6 +453,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);
}