mirror of
https://github.com/storytold/designcraft.git
synced 2026-10-14 19:27:07 -04:00
fix: refresh canvas when switching parent editing
This commit is contained in:
1 parent
e862e0a613
commit
2d07bc5295
5 files changed
+124
-5
No files matched your search
+3
-1
@@ -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.
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
Reference in new issue
Block a user