From db19963bad7969fb6832483c8ae1fe6c79508c09 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:37:14 +0200 Subject: [PATCH] feat(ui): the Page tool draws liquid guides from the rulers MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Chris Cicchino's "Adobe InDesign – Page Tool and Liquid Layout" (11:00– 13:30) drags guides out of the rulers with the Page tool on a guide-based page: "these guides are a little bit different... they are a dashed line, a cyan dashed line" - liquid guides, which let the objects they cross stretch with the page. DesignCraft drew liquid guides dashed and applied them, but a guide could only become liquid through the Liquid Layout panel's list: ruler drags always made ruler guides. - canvas: with the Page tool, a page guide dragged from a ruler is a liquid guide; its preview is dashed while it's dragged. - engine: guide.add takes `liquid`. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/guides.rs | 12 +++++++-- crates/ui-egui/src/canvas.rs | 48 +++++++++++++++++++++++++++++++-- 3 files changed, 57 insertions(+), 4 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..92513fc39 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -129,6 +129,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-14** · With the Page tool, a guide dragged from a ruler is a liquid guide (dashed), as in InDesign; guide-based pages stretch the objects it crosses. No readiness percentage change. - **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. - **2026-10-12** · Orderly native exit writes the latest recovery state after finalizing current quit approvals; incomplete or stale plans keep their documents, and Undo to the saved state clears stale recovery. Unavailable or failed recovery is reported. Cancelable macOS Dock Quit remains open (#544). No readiness percentage change. - **2026-10-12** · Recovered documents keep a checkpoint until saved, closed or reverted, including a second crash before autosave. Restarted document IDs and explicit recovery folders preserve other snapshots; failed republishing retains the original. No readiness percentage change. diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index 1111cb3b0..f4fb8e3dd 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -21,7 +21,7 @@ pub fn specs() -> Vec { "New Guide", [], None, - "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool} → {page, index}", + "{orientation: horizontal|vertical, position (spread coordinate), spread?, page? (index in the spread; default: the page under `at` or the position), at? (the other coordinate), spreadGuide?: bool, liquid?: bool (a liquid guide, as the Page tool draws from the rulers)} → {page, index}", has_doc, add ), @@ -98,6 +98,7 @@ fn add(s: &mut Session, p: &Value) -> Result { let pos = finite_number(p, "position", "guide.add")?.ok_or_else(|| bad("guide.add", "missing position"))?; let r = super::spread_param(p, "guide.add")?; let spread_guide = p.get("spreadGuide").and_then(Value::as_bool).unwrap_or(false); + let liquid = p.get("liquid").and_then(Value::as_bool).unwrap_or(false); let d = &s.doc()?.doc; let pi = match optional_usize(p, "page", "guide.add")? { Some(pi) => pi, @@ -114,7 +115,14 @@ fn add(s: &mut Session, p: &Value) -> Result { s.edit(|d, _| { let sp = d.spread_mut(r).ok_or_else(|| bad("guide.add", "no such spread"))?; let pg = sp.pages.get_mut(pi).ok_or_else(|| bad("guide.add", "no such page"))?; - pg.guides.push(Guide { orientation: o, position: pos, spread: spread_guide, locked: false, layer: Some(layer), liquid: false }); + pg.guides.push(Guide { + orientation: o, + position: pos, + spread: spread_guide, + locked: false, + layer: Some(layer), + liquid: liquid && !spread_guide, + }); Ok(json!({"page": pi, "index": pg.guides.len() - 1})) }) } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..eb74b4f5e 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -301,7 +301,12 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { designcraft_doc::Orientation::Horizontal => (pos2(rect.min.x, p.y), pos2(rect.max.x, p.y)), designcraft_doc::Orientation::Vertical => (pos2(p.x, rect.min.y), pos2(p.x, rect.max.y)), }; - painter.line_segment([a, b], Stroke::new(1.0, col)); + // A new guide from the rulers with the Page tool is a liquid guide: dashed. + if g.from.is_none() && app.session.tool_id() == "page" { + dashed(&painter, a, b, Stroke::new(1.0, col), 6.0, 3.0); + } else { + painter.line_segment([a, b], Stroke::new(1.0, col)); + } } draw_frames(app, &painter, &xf, &doc, &layout); if app.ui.hidden_characters { @@ -1760,7 +1765,10 @@ fn guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rec let _ = app.run("guide.move", json!({"spread": r, "page": pi, "index": gi, "position": pos_of(sp)})); } (None, true, Some((r, sp, on_page))) => { - let mut params = json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none()}); + // The Page tool draws liquid guides from the rulers. + let liquid = app.session.tool_id() == "page" && on_page.is_some(); + let mut params = + json!({"spread": r, "orientation": orient, "position": pos_of(sp), "at": sp.x, "spreadGuide": on_page.is_none(), "liquid": liquid}); if let Some(pi) = on_page { params["page"] = json!(pi); } @@ -1791,6 +1799,42 @@ mod tests { h } + /// A primary-button drag from `a` to `b` over a few frames. + fn drag(h: &mut Harness<'_, DesignApp>, a: Pos2, b: Pos2) { + h.hover_at(a); + h.step(); + h.event(egui::Event::PointerButton { pos: a, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + for k in 1..=4 { + h.hover_at(a + (b - a) * (k as f32 / 4.0)); + h.step(); + } + h.event(egui::Event::PointerButton { pos: b, button: egui::PointerButton::Primary, pressed: false, modifiers: Default::default() }); + h.run_steps(3); + } + + #[test] + fn the_page_tool_draws_liquid_guides_from_the_rulers() { + // Chris Cicchino's Page Tool and Liquid Layout video (11:00): with the Page tool, a guide + // dragged from the ruler is a liquid guide (dashed). + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + app.session.set_tool("page"); + let mut h = harness(app); + let r = h.state().canvas_rect.unwrap(); + let guides = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].pages[0].guides.clone(); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center()); + let g = guides(&h); + assert_eq!(g.len(), 1, "{g:?}"); + assert!(g[0].liquid && g[0].orientation == designcraft_doc::Orientation::Horizontal); + // With the Selection tool it's a ruler guide. + h.state_mut().session.set_tool("selection"); + drag(&mut h, pos2(r.center().x, r.min.y - 8.0), r.center() + vec2(0.0, 40.0)); + let g = guides(&h); + assert_eq!(g.len(), 2); + assert!(!g[1].liquid); + } + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { h.hover_at(p); h.step();