From cc13e80c9d3ebfe3453b8fcb3aad0165aa6b027e Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 00:29:55 +0200 Subject: [PATCH 1/8] feat(tools): Page tool selects pages; handles preview the liquid rule, Alt-drag resizes 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" (0:42– 2:06) selects a page with the Page tool, gets its bounding box with handles, drags a handle (the page springs back: a liquid-rule preview), holds Option to really resize it, Shift for proportions, and reads the size in a tooltip by the cursor. DesignCraft's Page tool only opened a generic Page Size dialog on click. - tools: the Page tool selects the page under a click (none on the pasteboard); the selected page shows its outline, eight handles and its liquid page rule ("Scale", "Object-Based"…) above it. Dragging a handle previews layout.pageSize (the liquid rule re-flows the objects) and springs back on release; Alt resizes, Shift keeps the proportions; a W/H readout follows the pointer; resize cursors on the handles. Double-click a page for the Page Size dialog. - doc: Selection.page (the Page tool's page); engine: layout.selectPage (no undo step). - layout.pageSize `anchor` (0-8): the page grows away from the reference point and its objects and guides keep their place relative to it (a handle drag anchors the opposite edge); liquid rules work from that. - canvas: PageHandles and Tag overlays; translations for the tags and the Resize Page undo label. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/selection.rs | 3 + crates/engine/src/cmd/layout.rs | 61 +++++++++++- crates/engine/src/tests.rs | 73 ++++++++++++++ crates/tools/src/lib.rs | 10 +- crates/tools/src/nav.rs | 160 ++++++++++++++++++++++++++++--- crates/tools/src/tests.rs | 12 ++- crates/ui-egui/src/canvas.rs | 21 ++++ crates/ui-egui/src/i18n.rs | 3 + crates/ui-egui/src/i18n/ar.rs | 3 + crates/ui-egui/src/i18n/it.rs | 3 + crates/ui-egui/src/i18n/ja.rs | 3 + crates/ui-egui/src/i18n/pt_br.rs | 3 + crates/ui-egui/src/i18n/uk.rs | 3 + docs/target-app-parity.md | 2 +- 15 files changed, 340 insertions(+), 21 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 8a0f82071..050c07d9a 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** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. 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/doc/src/selection.rs b/crates/doc/src/selection.rs index d9e9b7a3d..2d28bf2c2 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -80,6 +80,9 @@ pub struct Selection { /// Key object for Align. #[serde(default, skip_serializing_if = "Option::is_none")] pub key: Option, + /// The page selected with the Page tool (absolute index). + #[serde(default, skip_serializing_if = "Option::is_none")] + pub page: Option, } impl Selection { diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index bd5320799..2c23640f1 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -376,10 +376,24 @@ pub fn specs() -> Vec { "Page Size", [], None, - "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…} — pages of their own size (Page tool); objects on the pages keep their place on them", + "{pages: [1-based page numbers], width?, height?, preset?: \"A4\"|\"Letter\"|…, anchor?: 0–8 (reference point that stays put, row-major from the top-left; default 0)} — pages of their own size (Page tool); objects keep their place relative to the anchor, or follow the page's liquid rule", has_doc, page_size ), + cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { + let n = s.doc()?.doc.page_count(); + let page = match p.get("page") { + None | Some(Value::Null) => None, + Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), + }; + let st = s.doc_mut()?; + if page.is_some() { + st.selection = Default::default(); + } + st.selection.page = page; + st.revision += 1; + Ok(json!({"page": page.map(|p| p + 1)})) + }), cmd!( "layout.section", "Numbering & Section Options…", @@ -640,8 +654,12 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { w = Some(pr.width); h = Some(pr.height); } + let anchor = match p.get("anchor") { + None | Some(Value::Null) => 0, + Some(v) => v.as_u64().filter(|a| *a <= 8).ok_or_else(|| bad("layout.pageSize", "`anchor`: 0–8"))? as usize, + }; s.edit(|d, _| { - resize_pages(d, &pages, w, h); + resize_pages_from(d, &pages, w, h, anchor); Ok(json!({"pages": pages.len()})) }) } @@ -649,14 +667,35 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { + resize_pages_from(d, pages, w, h, 0); +} + +/// [`resize_pages`] keeping reference point `anchor` (0–8, row-major from the top-left) in place: +/// the page grows away from it, so objects keep their place relative to it. +pub(crate) fn resize_pages_from(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option, anchor: usize) { + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3).min(2) as f64 / 2.0); { for page in pages { let Some((si, pi)) = d.page_loc(page - 1) else { continue }; let sp = std::sync::Arc::make_mut(&mut d.spreads[si]); let old: Vec = sp.pages.iter().map(|x| x.x).collect(); let old_bounds = sp.pages[pi].bounds(); + // The page's own objects (outer pages take the pasteboard beside them). + let last = sp.pages.len() - 1; + let (lo, hi) = (if pi == 0 { f64::NEG_INFINITY } else { old_bounds.x0 }, if pi == last { f64::INFINITY } else { old_bounds.x1 }); + let own: Vec = sp + .items + .iter() + .enumerate() + .filter(|(_, it)| { + let c = it.bounds().center().x; + c >= lo && c < hi + }) + .map(|(i, _)| i) + .collect(); let pg = &mut sp.pages[pi]; let dw = w.map_or(0.0, |w| w.max(1.0) - pg.width); + let dh = h.map_or(0.0, |h| h.max(1.0) - pg.height); if let Some(w) = w { pg.width = w.max(1.0); } @@ -693,8 +732,22 @@ pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w } } } - // Liquid rules: objects were carried along with the page's left edge. - let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0); + // Growing away from the anchor: the page's objects and guides keep their place + // relative to it. + let shift = designcraft_geom::Vec2::new(fx * dw, fy * dh); + if shift != designcraft_geom::Vec2::ZERO { + for i in &own { + if let Some(it) = sp.items.get_mut(*i) { + let it = std::sync::Arc::make_mut(it); + it.xf = designcraft_geom::Affine::translate(shift) * it.xf; + } + } + for g in &mut sp.pages[pi].guides { + g.position += if g.orientation == designcraft_doc::Orientation::Vertical { shift.x } else { shift.y }; + } + } + // Liquid rules: objects were carried along with the page's left edge (and the anchor). + let moved = old_bounds + designcraft_geom::Vec2::new(new[pi] - old[pi], 0.0) + shift; super::liquid::apply(d, designcraft_doc::SpreadRef::Doc(si), pi, moved); } } diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 17d1ed098..deec4b9b1 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -569,3 +569,76 @@ fn commands_take_non_object_params_without_panicking() { assert!(r.is_ok(), "{p}: {r:?}"); } } + +#[test] +fn page_tool_selects_a_page_and_its_handles_preview_or_resize_it() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [400, 100, 500, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + let width = |s: &Session| s.doc().unwrap().doc.spreads[0].pages[0].width; + // The page on the canvas (a single page is centred on the spine). + let page = |s: &Session| { + let d = &s.doc().unwrap().doc; + CanvasLayout::new(d, false).page_rect(d, 0).unwrap() + }; + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = |kind, x: f64, y: f64, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, x, y) }; + let drag = |s: &mut Session, from: (f64, f64), by: f64, alt: bool, check: &dyn Fn(&Session)| { + s.pointer(&ev(PointerKind::Down, from.0, from.1, alt), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, from.0 + by, from.1, alt), v).unwrap(); + check(s); + s.pointer(&ev(PointerKind::Up, from.0 + by, from.1, alt), v).unwrap(); + }; + // A click selects the page. + let c = page(&s).center(); + s.pointer(&ev(PointerKind::Down, c.x, c.y, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, c.x, c.y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, Some(0)); + // A plain drag of the right handle previews the page's liquid rule and springs back. + s.execute("liquid.pageRule", &json!({"rule": "scale"})).unwrap(); + let undo = s.doc().unwrap().history.undo.len(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 612.0, false, &|s| { + assert_eq!(width(s), 1224.0, "previewed"); + // Scale keeps proportions: only wider, so the frame is centred on the wider page. + assert_eq!(frame(s).x0, 706.0, "the Scale rule in the preview: {:?}", frame(s)); + }); + assert_eq!((width(&s), frame(&s)), (612.0, designcraft_geom::Rect::new(400.0, 100.0, 500.0, 200.0)), "sprung back"); + assert_eq!(s.doc().unwrap().history.undo.len(), undo, "nothing to undo"); + // Alt-drag resizes; with the rule off the frame stays where it is. + s.execute("liquid.pageRule", &json!({"rule": "off"})).unwrap(); + let r = page(&s); + drag(&mut s, (r.x1, r.center().y), 100.0, true, &|_| {}); + assert_eq!(width(&s), 712.0); + assert_eq!(frame(&s).x0, 400.0); + // The left handle grows the page to the left: the frame keeps its place by the right edge. + let r = page(&s); + drag(&mut s, (r.x0, r.center().y), -100.0, true, &|_| {}); + let pg = s.doc().unwrap().doc.spreads[0].pages[0].clone(); + assert_eq!(pg.width, 812.0); + assert_eq!(frame(&s).x0 - pg.x, 500.0, "100 pt further from the page's left edge"); + // A click on the pasteboard deselects the page. + let r = page(&s); + s.pointer(&ev(PointerKind::Down, r.x0 - 200.0, r.center().y, false), v).unwrap(); + assert_eq!(s.doc().unwrap().selection.page, None); +} + +#[test] +fn page_size_keeps_objects_by_its_anchor() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // Bottom-right stays put: 100 wider and 50 taller moves the frame 100 right and 50 down. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 500, "height": 450, "anchor": 8})).unwrap(); + assert_eq!(frame(&s), designcraft_geom::Rect::new(200.0, 150.0, 300.0, 250.0)); + // The centre: half of it. + s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "height": 450, "anchor": 4})).unwrap(); + assert_eq!(frame(&s).x0, 300.0); + assert!(s.execute("layout.pageSize", &json!({"pages": [1], "width": 700, "anchor": 9})).is_err()); + assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err()); + assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index f3de390a4..dd09e9c25 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -314,6 +314,14 @@ pub enum Overlay { b: Point, label: String, }, + /// A page selected with the Page tool (canvas rect): its outline and eight handles. + PageHandles(Rect), + /// A small tag whose bottom-left is at `p` (the Page tool's liquid page rule; English, the UI + /// translates it). + Tag { + p: Point, + text: String, + }, } /// Correction from a snap, in spread coordinates for `delta`. @@ -400,7 +408,7 @@ pub fn create(id: &str) -> Box { "hand" => Box::new(nav::HandTool::default()), "placeGun" => Box::new(nav::PlaceGun::default()), "zoom" => Box::new(nav::ZoomTool::default()), - "page" => Box::new(nav::PageTool), + "page" => Box::new(nav::PageTool::default()), "typeOnPath" => Box::new(nav::PathTypeTool), "gap" => Box::new(nav::GapTool::default()), "contentCollector" => Box::new(nav::ConveyorTool::new(false)), diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index d892b090a..a94a93881 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -3,7 +3,7 @@ use designcraft_geom::Point; use serde_json::json; -use crate::{Action, Cursor, Mods, PointerEvent, PointerKind, Tool, ToolContext}; +use crate::{Action, Cursor, Mods, Overlay, PointerEvent, PointerKind, Tool, ToolContext}; /// Hand tool: drag to pan. Alt-press starts Power Zoom: the view zooms out to the spread, the /// pointer aims a rectangle of the previous view, and releasing zooms back in there. @@ -162,27 +162,161 @@ impl Tool for PlaceGun { } } -/// Page tool (Shift+P): click a page to set its own size. +/// Page tool (Shift+P): click a page to select it; drag one of its handles to preview its liquid +/// page rule (the page springs back on release), Alt-drag to resize it, Shift to keep its +/// proportions; double-click a page for exact sizes. #[derive(Default)] -pub struct PageTool; +pub struct PageTool { + drag: Option, +} + +/// A page handle being dragged. +#[derive(Clone, Copy, Debug)] +struct PageDrag { + /// Absolute page index. + page: usize, + /// Reference-point index of the handle (0–8, row-major; 4 is the centre and has none). + handle: usize, + start: Point, + cur: Point, + /// The page's size when the drag began. + size: (f64, f64), + alt: bool, +} + +/// The eight handles of a page rect: (reference-point index, canvas point). +fn page_handles(r: designcraft_geom::Rect) -> impl Iterator { + (0..9).filter(|i| *i != 4).map(move |i| (i, Point::new(r.x0 + r.width() * (i % 3) as f64 / 2.0, r.y0 + r.height() * (i / 3) as f64 / 2.0))) +} + +/// The liquid page rule as the Page tool tags a page. +pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + use designcraft_doc::LiquidRule as R; + match rule { + R::Off => "Off", + R::Scale => "Scale", + R::ReCenter => "Re-center", + R::ObjectBased => "Object-Based", + R::GuideBased => "Guide-Based", + } +} + +impl PageTool { + /// The selected page's handle under `p`. + fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { + let page = cx.selection.page?; + let r = cx.layout.page_rect(cx.doc, page)?; + page_handles(r).find(|(_, h)| (h.x - p.x).abs() <= cx.tol(5.0) && (h.y - p.y).abs() <= cx.tol(5.0)).map(|(i, _)| (page, i)) + } + + /// The new page size for the drag at `p`: the handle's edges follow the pointer (Shift: in + /// proportion). + fn size_at(cx: &ToolContext, d: &PageDrag, p: Point, shift: bool) -> (f64, f64) { + let si = cx.doc.page_loc(d.page).map_or(0, |l| l.0); + let v = cx.layout.delta_to_spread(designcraft_doc::SpreadRef::Doc(si), p - d.start); + let (col, row) = (d.handle % 3, d.handle / 3); + let dx = match col { + 0 => -v.x, + 2 => v.x, + _ => 0.0, + }; + let dy = match row { + 0 => -v.y, + 2 => v.y, + _ => 0.0, + }; + let (w0, h0) = d.size; + let (mut w, mut h) = ((w0 + dx).max(1.0), (h0 + dy).max(1.0)); + if shift { + // The axis being dragged (the larger change at a corner) sets the scale. + let k = match (col == 1, row == 1) { + (true, _) => h / h0, + (_, true) => w / w0, + _ if (w / w0 - 1.0).abs() >= (h / h0 - 1.0).abs() => w / w0, + _ => h / h0, + }; + (w, h) = ((w0 * k).max(1.0), (h0 * k).max(1.0)); + } + (w, h) + } +} impl Tool for PageTool { fn id(&self) -> &'static str { "page" } fn pointer(&mut self, cx: &ToolContext, ev: &PointerEvent) -> Vec { - if ev.kind != PointerKind::Up { - return vec![]; + match ev.kind { + PointerKind::Down => { + if let Some((page, handle)) = self.handle_at(cx, ev.pos) { + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); + return vec![Action::Begin("Resize Page".into())]; + } + // A click selects the page under the pointer (none on the pasteboard). + let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); + if page == cx.selection.page { + return vec![]; + } + vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1)}))] + } + PointerKind::Drag => { + let Some(d) = self.drag.as_mut() else { return vec![] }; + d.cur = ev.pos; + d.alt |= ev.mods.alt; + let d = *d; + let (w, h) = Self::size_at(cx, &d, ev.pos, ev.mods.shift); + vec![Action::Preview("layout.pageSize".into(), json!({"pages": [d.page + 1], "width": w, "height": h, "anchor": 8 - d.handle}))] + } + PointerKind::Up => match self.drag.take() { + // Alt-drag resizes; a plain drag previews the liquid rule and springs back. + Some(d) if d.alt || ev.mods.alt => vec![Action::Commit], + Some(_) => vec![Action::Cancel], + None => vec![], + }, + PointerKind::DoubleClick => { + // Exact sizes: the Page Size dialog for the page. + let Some(page) = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))) else { + return vec![]; + }; + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] + } + PointerKind::Move => vec![], } - let Some((designcraft_doc::SpreadRef::Doc(si), sp)) = cx.layout.spread_at(ev.pos) else { return vec![] }; - let Some(spread) = cx.doc.spreads.get(si) else { return vec![] }; - let Some(pi) = spread.pages.iter().position(|p| p.bounds().contains(sp)) else { return vec![] }; - let pg = &spread.pages[pi]; - let n = cx.doc.first_page_of_spread(si) + pi + 1; - vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [n], "width": pg.width, "height": pg.height}))] } - fn cursor(&self, _cx: &ToolContext, _p: Point, _m: Mods) -> Cursor { - Cursor::Arrow + fn overlays(&self, cx: &ToolContext) -> Vec { + let Some(page) = cx.selection.page else { return vec![] }; + let Some(r) = cx.layout.page_rect(cx.doc, page) else { return vec![] }; + let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; + let pg = &cx.doc.spreads[si].pages[pi]; + let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; + if let Some(d) = self.drag { + v.push(Overlay::Measure { + p: d.cur, + text: format!( + "W: {}\nH: {}", + designcraft_geom::format_measure(pg.width, cx.unit), + designcraft_geom::format_measure(pg.height, cx.unit) + ), + }); + } + v + } + fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); + match handle { + Some(1 | 7) => Cursor::ResizeV, + Some(3 | 5) => Cursor::ResizeH, + Some(0 | 8) => Cursor::ResizeNwSe, + Some(_) => Cursor::ResizeNeSw, + None => Cursor::Arrow, + } + } + fn busy(&self) -> bool { + self.drag.is_some() } } diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index 6776902aa..b2e597242 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -227,14 +227,22 @@ fn hand_tool_alt_press_power_zooms() { } #[test] -fn page_tool_opens_page_size() { +fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { let d = Document::new(&NewDocument::default()); let (s, c, l) = (Selection::default(), Cache::new(), CanvasLayout::new(&d, false)); let cx = ctx(&d, &s, &c, &l); let off = l.xf(SpreadRef::Doc(0)).translation(); + let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); - let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y)); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); + assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1}))]); + let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); + // The selected page shows its handles and its liquid page rule. + let s = Selection { page: Some(0), ..Default::default() }; + let cx = ctx(&d, &s, &c, &l); + let o = t.overlays(&cx); + assert!(matches!(o.as_slice(), [Overlay::PageHandles(_), Overlay::Tag { text, .. }] if text == "Off"), "{o:?}"); } #[test] diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 077ae2944..b3fa92336 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1237,6 +1237,27 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_filled(r, 3.0, tok.measure_bg); painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); } + Overlay::PageHandles(r) => { + // The Page tool's selected page: outline and eight handles, as InDesign draws them. + let r = xf.rect(r); + let col = Color32::from_rgb(79, 153, 255); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, col), StrokeKind::Middle); + for i in (0..9).filter(|i| *i != 4) { + let c = pos2(r.min.x + r.width() * (i % 3) as f32 / 2.0, r.min.y + r.height() * (i / 3) as f32 / 2.0); + let h = Rect::from_center_size(c, vec2(7.0, 7.0)); + painter.rect_filled(h, 0.0, Color32::WHITE); + painter.rect_stroke(h, 0.0, Stroke::new(1.0, col), StrokeKind::Inside); + } + } + Overlay::Tag { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p); + let g = painter.layout_no_wrap(text, egui::FontId::proportional(10.0), tok.measure_text); + let r = Rect::from_min_size(s - vec2(0.0, g.size().y + 4.0), g.size() + vec2(8.0, 4.0)); + painter.rect_filled(r, 2.0, tok.measure_bg); + painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(79, 153, 255)), StrokeKind::Inside); + painter.galley(r.min + vec2(4.0, 2.0), g, tok.measure_text); + } Overlay::Path { .. } => {} } } diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 5252c3881..63c70d4a7 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2498,6 +2498,9 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Added", ["Added", "Added", "Added", "Added", "已添加"]), ("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]), ("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]), + ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), + ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), + ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index a182d09ec..e0c17c88a 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -709,6 +709,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "مضاف"), ("Deleted", "محذوف"), ("Guide-based", "استنادًا إلى الأدلة"), + ("Object-Based", "استنادًا إلى الكائنات"), + ("Guide-Based", "استنادًا إلى الأدلة"), + ("Resize Page", "تغيير حجم الصفحة"), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 75ece03f6..3f0915aa3 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1295,6 +1295,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Aggiunto"), ("Deleted", "Eliminato"), ("Guide-based", "Basato su guide"), + ("Object-Based", "Basato su oggetti"), + ("Guide-Based", "Basato su guide"), + ("Resize Page", "Ridimensiona pagina"), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 642b4a4c0..9e0f7a953 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1291,6 +1291,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "追加済み"), ("Deleted", "削除済み"), ("Guide-based", "ガイド基準"), + ("Object-Based", "オブジェクト基準"), + ("Guide-Based", "ガイド基準"), + ("Resize Page", "ページのサイズを変更"), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 982dfb20d..e1fbf308a 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -708,6 +708,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Adicionado"), ("Deleted", "Excluído"), ("Guide-based", "Baseado em guias"), + ("Object-Based", "Baseado em objetos"), + ("Guide-Based", "Baseado em guias"), + ("Resize Page", "Redimensionar página"), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index f3bc8fb35..5994dc135 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1292,6 +1292,9 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Додано"), ("Deleted", "Видалено"), ("Guide-based", "На основі напрямних"), + ("Object-Based", "На основі об’єктів"), + ("Guide-Based", "На основі напрямних"), + ("Resize Page", "Змінити розмір сторінки"), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index eb7d7e5c1..84b602d54 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 59b6125af1c5402d6950ab8a92ff28125373a2f0 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 00:51:47 +0200 Subject: [PATCH 2/8] feat(ui): Control panel for the Page tool; Controlled by Parent; Show Parent Page Overlay 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" (1:02– 2:06, 13:47) sizes pages from the Control panel with the Page tool selected: X/Y, W/H, the page-size preset ("US Business Card"), orientation, Liquid Page Rule ("Controlled by Parent"), Objects Move with Page and Show Parent Page Overlay. DesignCraft's Control panel showed the (empty) object controls. - ui: with the Page tool, the Control panel shows the selected page (or the page in view): the reference point (W/H, preset and orientation keep it in place via layout.pageSize's anchor), X/Y (read-only), W/H, the preset dropdown, portrait/landscape, Liquid Page Rule, Objects Move with Page (always on: pages don't move within spreads yet) and Show Parent Page Overlay. - doc: LiquidRule::ControlledByParent (the parent page's rule, Off without one; resolved by liquid::effective_rule); Page.show_parent_overlay. - engine: layout.pageOptions {pages, showParentOverlay}; liquid.pageRule takes controlledByParent. - canvas: the parent page's size over a page with the overlay on. - Liquid Layout panel and Control panel share InDesign's rule list (Off, Scale, Re-center, Object-based, Guide-based, Controlled by Parent); "US Business Card" preset; translations. Builds on the Page tool PR (#862) for the selected page. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/doc/src/build.rs | 1 + crates/doc/src/page.rs | 6 ++ crates/engine/src/cmd/layout.rs | 29 ++++++ crates/engine/src/cmd/liquid.rs | 24 ++++- crates/engine/src/tests.rs | 25 +++++ crates/idml/src/import.rs | 1 + crates/tools/src/nav.rs | 1 + crates/ui-egui/src/canvas.rs | 12 ++- crates/ui-egui/src/chrome.rs | 111 ++++++++++++++++++++++- crates/ui-egui/src/i18n.rs | 29 ++++++ crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/panels/interactive.rs | 33 +++---- crates/ui-egui/src/panels/properties.rs | 7 +- 18 files changed, 280 insertions(+), 25 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index 050c07d9a..d9957d5d1 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, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. 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. diff --git a/crates/doc/src/build.rs b/crates/doc/src/build.rs index c274f2130..574049d76 100644 --- a/crates/doc/src/build.rs +++ b/crates/doc/src/build.rs @@ -226,6 +226,7 @@ impl Document { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/doc/src/page.rs b/crates/doc/src/page.rs index f1eaa48ad..6aa325798 100644 --- a/crates/doc/src/page.rs +++ b/crates/doc/src/page.rs @@ -114,6 +114,9 @@ pub struct Page { /// Liquid page rule: how objects follow when the page changes size. #[serde(default, skip_serializing_if = "LiquidRule::is_off")] pub liquid: LiquidRule, + /// Page tool › Show Parent Page Overlay: the parent page's size drawn over this page. + #[serde(default, skip_serializing_if = "std::ops::Not::not")] + pub show_parent_overlay: bool, /// Window › Interactive › Page Transitions (the spread's first page holds the spread's). #[serde(default, skip_serializing_if = "Option::is_none")] pub transition: Option, @@ -208,6 +211,8 @@ pub enum LiquidRule { GuideBased, /// Each object's pins and resize settings decide (`Item::liquid`). ObjectBased, + /// The rule of the page's parent page (InDesign's "Controlled by Parent"; Off without one). + ControlledByParent, } impl LiquidRule { @@ -367,6 +372,7 @@ mod tests { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, } } diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 2c23640f1..4055b0809 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -380,6 +380,15 @@ pub fn specs() -> Vec { has_doc, page_size ), + cmd!( + "layout.pageOptions", + "Page Options", + [], + None, + "{pages: [1-based], showParentOverlay?: bool} — Page tool options (Show Parent Page Overlay: the parent page's size drawn over the page)", + has_doc, + page_options + ), cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { let n = s.doc()?.doc.page_count(); let page = match p.get("page") { @@ -664,6 +673,26 @@ fn page_size(s: &mut crate::Session, p: &Value) -> Result { }) } +fn page_options(s: &mut crate::Session, p: &Value) -> Result { + let pages: Vec = + p.get("pages").and_then(Value::as_array).map(|a| a.iter().filter_map(Value::as_u64).map(|n| n as usize).collect()).unwrap_or_default(); + let n = s.doc()?.doc.page_count(); + if pages.is_empty() || pages.iter().any(|x| *x == 0 || *x > n) { + return Err(bad("layout.pageOptions", "give `pages` (1-based)")); + } + let overlay = p.get("showParentOverlay").and_then(Value::as_bool); + s.edit(|d, _| { + for page in &pages { + let Some((si, pi)) = d.page_loc(page - 1) else { continue }; + let pg = &mut std::sync::Arc::make_mut(&mut d.spreads[si]).pages[pi]; + if let Some(v) = overlay { + pg.show_parent_overlay = v; + } + } + Ok(json!({"pages": pages.len()})) + }) +} + /// Give `pages` (1-based) their own size. Objects stay on their page; pages with a liquid rule /// re-flow their objects by it. pub(crate) fn resize_pages(d: &mut designcraft_doc::Document, pages: &[usize], w: Option, h: Option) { diff --git a/crates/engine/src/cmd/liquid.rs b/crates/engine/src/cmd/liquid.rs index 879fdb368..8c8d4be23 100644 --- a/crates/engine/src/cmd/liquid.rs +++ b/crates/engine/src/cmd/liquid.rs @@ -35,7 +35,7 @@ fn axis(lo: f64, hi: f64, o: (f64, f64), n: (f64, f64), pin_lo: bool, pin_hi: bo fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option, vguides: &[f64], hguides: &[f64]) -> Rect { let (ox, oy, nx, ny) = ((old.x0, old.x1), (old.y0, old.y1), (new.x0, new.x1), (new.y0, new.y1)); match rule { - LiquidRule::Off => b, + LiquidRule::Off | LiquidRule::ControlledByParent => b, LiquidRule::ReCenter => b + (new.center() - old.center()), LiquidRule::Scale => { let k = (new.width() / old.width()).min(new.height() / old.height()); @@ -63,7 +63,7 @@ fn target(rule: LiquidRule, b: Rect, old: Rect, new: Rect, liquid: Option LiquidRule { + let Some(pg) = d.spread(sr).and_then(|sp| sp.pages.get(pi)) else { return LiquidRule::Off }; + if pg.liquid != LiquidRule::ControlledByParent { + return pg.liquid; + } + let SpreadRef::Doc(si) = sr else { return LiquidRule::Off }; + let abs = d.first_page_of_spread(si) + pi; + match d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid)) { + Some(LiquidRule::ControlledByParent) | None => LiquidRule::Off, + Some(r) => r, + } +} + fn rule_param(p: &Value, id: &str) -> Result { - serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))).map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased")) + serde_json::from_value(p.get("rule").cloned().unwrap_or(json!("off"))) + .map_err(|_| bad(id, "`rule`: off|scale|reCenter|guideBased|objectBased|controlledByParent")) } pub fn specs() -> Vec { @@ -119,7 +135,7 @@ pub fn specs() -> Vec { "Liquid Page Rule", ["Layout", "Liquid Layout"], None, - "{rule: off|scale|reCenter|guideBased|objectBased, pages?: [1-based] (default: all)}", + "{rule: off|scale|reCenter|guideBased|objectBased|controlledByParent (the parent page's rule), pages?: [1-based] (default: all)}", has_doc, |s, p| { let rule = rule_param(p, "liquid.pageRule")?; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index deec4b9b1..9557e03cf 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -642,3 +642,28 @@ fn page_size_keeps_objects_by_its_anchor() { assert!(s.execute("layout.selectPage", &json!({"page": 2})).is_err()); assert_eq!(s.execute("layout.selectPage", &json!({"page": 1})).unwrap()["page"], 1); } + +#[test] +fn controlled_by_parent_takes_the_parent_pages_rule() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let frame = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().bounds(); + // The page follows its parent; the parent page re-centres. + s.execute("liquid.pageRule", &json!({"rule": "controlledByParent", "pages": [1]})).unwrap(); + s.edit(|d, _| { + std::sync::Arc::make_mut(&mut d.parents[0]).pages[0].liquid = designcraft_doc::LiquidRule::ReCenter; + Ok(()) + }) + .unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 600})).unwrap(); + assert_eq!(frame(&s).x0, 200.0, "re-centred: {:?}", frame(&s)); + // Without a parent (or a parent that's also controlled by its parent) nothing moves. + s.execute("layout.pages.applyParent", &json!({"pages": [0], "parent": null})).unwrap(); + s.execute("layout.pageSize", &json!({"pages": [1], "width": 800})).unwrap(); + assert_eq!(frame(&s).x0, 200.0); + // Show Parent Page Overlay is a page option. + s.execute("layout.pageOptions", &json!({"pages": [1], "showParentOverlay": true})).unwrap(); + assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err()); +} diff --git a/crates/idml/src/import.rs b/crates/idml/src/import.rs index 59ca44be7..5bb4387de 100644 --- a/crates/idml/src/import.rs +++ b/crates/idml/src/import.rs @@ -1963,6 +1963,7 @@ impl<'r> Importer<'r> { liquid: Default::default(), transition: None, view_rotation: 0, + show_parent_overlay: false, }); } let overrides: Vec> = diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index a94a93881..54db73d06 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -198,6 +198,7 @@ pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { R::ReCenter => "Re-center", R::ObjectBased => "Object-Based", R::GuideBased => "Guide-Based", + R::ControlledByParent => "Controlled by Parent", } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index b3fa92336..003549a42 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -604,8 +604,18 @@ fn draw_guides(app: &DesignApp, painter: &egui::Painter, xf: &Xf, doc: &Document painter.rect_stroke(rect(br), 0.0, Stroke::new(hair(painter), c32(s.bleed_color)), StrokeKind::Middle); } let pasteboard = m.inverse().transform_rect_bbox(layout.pasteboard); - for p in &sp.pages { + for (pi, p) in sp.pages.iter().enumerate() { let pr = p.bounds(); + // Page tool › Show Parent Page Overlay: the parent page's size over the page. + if p.show_parent_overlay + && let SpreadRef::Doc(si) = slot.spread + && let Some((ppi, ppg)) = doc.parent_page_for(doc.first_page_of_spread(si) + pi) + && let Some(pp) = doc.parents.get(ppi).and_then(|ps| ps.pages.get(ppg)) + { + let r = rect(DRect::new(pr.x0, pr.y0, pr.x0 + pp.width, pr.y0 + pp.height)); + painter.rect_filled(r, 0.0, Color32::from_rgba_unmultiplied(110, 110, 230, 28)); + painter.rect_stroke(r, 0.0, Stroke::new(1.0, Color32::from_rgb(110, 110, 230)), StrokeKind::Middle); + } if !app.ui.guides { continue; } diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index c2f1cb48e..f3a17ebf9 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -182,7 +182,9 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { ui.horizontal_top(|ui| { let text_mode = matches!(app.session.tool_id(), "type" | "verticalType") || app.session.active().is_some_and(|d| d.selection.text.is_some()); - if text_mode { + if app.session.tool_id() == "page" { + control_page(app, ui); + } else if text_mode { control_text(app, ui); } else { control_object(app, ui); @@ -192,6 +194,96 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { }); } +/// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size +/// preset, orientation, Liquid Page Rule, Objects Move with Page and Show Parent Page Overlay, for +/// the page the Page tool selected (else the page in view). W/H keep the reference point in place. +fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { + let lang = app.ui.language.clone(); + let Some(st) = app.session.active() else { return }; + let units = st.doc.settings.horizontal_units; + let Some(page) = st.selection.page.or_else(|| crate::canvas::current_page(app)) else { return }; + let Some(st) = app.session.active() else { return }; + let d = st.doc.clone(); + let Some((si, pi)) = d.page_loc(page) else { return }; + let pg = d.spreads[si].pages[pi].clone(); + let n = page + 1; + // Reference point: W/H edits keep it in place. + let (r, _) = ui.allocate_exact_size(vec2(30.0, 44.0), Sense::hover()); + if let Some(p) = panels::properties::ref_point_proxy(ui, egui::Rect::from_center_size(r.center(), vec2(27.0, 27.0)), app.ui.ref_point) { + app.ui.ref_point = p; + } + let anchor = app.ui.ref_point.min(8); + let (fx, fy) = ((anchor % 3) as f64 / 2.0, (anchor / 3) as f64 / 2.0); + // The reference point's position: from the spine across, from the top down. + let (x, y) = (pg.x - d.spreads[si].spine_x() + fx * pg.width, fy * pg.height); + egui::Grid::new("ctl_page_xywh").num_columns(4).spacing(vec2(4.0, 4.0)).show(ui, |ui| { + caption(ui, crate::i18n::tr(&lang, "X:")); + // Pages don't move within their spread yet. + ui.add_enabled_ui(false, |ui| measure(ui, "cpx", Some(x), units, 64.0)); + caption(ui, crate::i18n::tr(&lang, "W:")); + if let Some(v) = measure(ui, "cpw", Some(pg.width), units, 64.0) { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": v, "anchor": anchor})); + } + ui.end_row(); + caption(ui, crate::i18n::tr(&lang, "Y:")); + ui.add_enabled_ui(false, |ui| measure(ui, "cpy", Some(y), units, 64.0)); + caption(ui, crate::i18n::tr(&lang, "H:")); + if let Some(v) = measure(ui, "cph", Some(pg.height), units, 64.0) { + let _ = app.run("layout.pageSize", json!({"pages": [n], "height": v, "anchor": anchor})); + } + ui.end_row(); + }); + vsep(ui); + // Page-size preset and orientation. + let landscape = pg.width > pg.height; + ui.vertical(|ui| { + let cur = panels::properties::preset_name(pg.width, pg.height); + egui::ComboBox::from_id_salt("ctl_page_preset").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, cur))).width(130.0).show_ui( + ui, + |ui| { + for (name, w, h) in panels::properties::PAGE_PRESETS { + if ui.selectable_label(cur == *name, crate::rtl::widget(ui, crate::i18n::tr(&lang, name))).clicked() { + let (w, h) = if landscape { (h, w) } else { (w, h) }; + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": w, "height": h, "anchor": anchor})); + } + } + }, + ); + ui.horizontal(|ui| { + for (icon, tip, want) in [("orient-portrait", "Portrait", false), ("orient-landscape", "Landscape", true)] { + if crate::widgets::icon_toggle(ui, icon, landscape == want, crate::i18n::tr(&lang, tip)).clicked() && landscape != want { + let _ = app.run("layout.pageSize", json!({"pages": [n], "width": pg.height, "height": pg.width, "anchor": anchor})); + } + } + }); + }); + vsep(ui); + ui.vertical(|ui| { + caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); + let label = panels::interactive::liquid_rule_label(pg.liquid); + egui::ComboBox::from_id_salt("ctl_page_rule").selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).width(150.0).show_ui( + ui, + |ui| { + for (r, l) in panels::interactive::LIQUID_RULES { + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, l))).clicked() && pg.liquid != r { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [n]})); + } + } + }, + ); + }); + vsep(ui); + ui.vertical(|ui| { + // Objects follow a page that moves in its spread; pages don't move yet, so it's always on. + let mut follow = true; + ui.add_enabled(false, egui::Checkbox::new(&mut follow, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Objects Move with Page")))); + let mut overlay = pg.show_parent_overlay; + if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { + let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); + } + }); +} + /// A field label drawn as a tool icon (hover for its name). fn icon_caption(ui: &mut egui::Ui, icon: &str, tip: &str) { let (r, resp) = ui.allocate_exact_size(vec2(16.0, 16.0), Sense::hover()); @@ -830,6 +922,23 @@ mod tests { assert!(short.min.x - first.min.x <= 2.0 * super::DOC_TAB_MAX_W, "the last tab {short:?} starts too far right"); } + #[test] + fn the_page_tool_has_its_own_control_panel() { + // Chris Cicchino's Page Tool and Liquid Layout video (1:02): with the Page tool, the + // Control panel shows the page's size, preset, orientation and liquid page rule. + let mut app = app(); + app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); + app.session.set_tool("page"); + let mut h = test_window::open(app, vec2(1800.0, 600.0)); + for label in ["Liquid Page Rule:", "Objects Move with Page"] { + let _ = h.get_by_label(label); + } + assert_eq!(crate::panels::properties::preset_name(252.0, 144.0), "US Business Card"); + h.get_by_label("Show Parent Page Overlay").click(); + h.run_steps(4); + assert!(h.state().app.session.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); + } + #[test] fn a_narrow_window_scrolls_the_control_bar() { let w = 700.0; diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index 63c70d4a7..ab6b52fc2 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2501,6 +2501,35 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), + ( + "Liquid Page Rule:", + ["Regel für flüssige Seiten:", "Règle de page liquide :", "Regla de página líquida:", "リキッドページルール:", "自适应页面规则:"], + ), + ( + "Objects Move with Page", + [ + "Objekte mit Seite verschieben", + "Déplacer les objets avec la page", + "Los objetos se mueven con la página", + "オブジェクトをページと共に移動", + "对象随页面移动", + ], + ), + ( + "Show Parent Page Overlay", + [ + "Überlagerung der übergeordneten Seite anzeigen", + "Afficher la superposition de la page parent", + "Mostrar superposición de página principal", + "親ページオーバーレイを表示", + "显示父页面叠加", + ], + ), + ( + "Controlled by Parent", + ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], + ), + ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index e0c17c88a..84b003e07 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -712,6 +712,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "استنادًا إلى الكائنات"), ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), + ("Liquid Page Rule:", "قاعدة الصفحة المرنة:"), + ("Objects Move with Page", "تحريك الكائنات مع الصفحة"), + ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), + ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), + ("US Business Card", "بطاقة عمل أمريكية"), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 3f0915aa3..8195bac61 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1298,6 +1298,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "Basato su oggetti"), ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), + ("Liquid Page Rule:", "Regola pagina liquida:"), + ("Objects Move with Page", "Sposta oggetti con la pagina"), + ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), + ("Controlled by Parent", "Controllato dalla pagina principale"), + ("US Business Card", "Biglietto da visita USA"), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 9e0f7a953..ead7f576d 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1294,6 +1294,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "オブジェクト基準"), ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), + ("Liquid Page Rule:", "リキッドページルール:"), + ("Objects Move with Page", "オブジェクトをページと共に移動"), + ("Show Parent Page Overlay", "親ページオーバーレイを表示"), + ("Controlled by Parent", "親ページで制御"), + ("US Business Card", "US 名刺"), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index e1fbf308a..52874f549 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -711,6 +711,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "Baseado em objetos"), ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), + ("Liquid Page Rule:", "Regra de página líquida:"), + ("Objects Move with Page", "Objetos se movem com a página"), + ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), + ("Controlled by Parent", "Controlado pela página principal"), + ("US Business Card", "Cartão de visita EUA"), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 5994dc135..68e15e629 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Object-Based", "На основі об’єктів"), ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), + ("Liquid Page Rule:", "Правило гнучкої сторінки:"), + ("Objects Move with Page", "Об’єкти рухаються зі сторінкою"), + ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), + ("Controlled by Parent", "Керується батьківською сторінкою"), + ("US Business Card", "Візитна картка (США)"), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 5b770aec8..cd36926fa 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -486,6 +486,21 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } } +/// Liquid page rules in InDesign's order, with their menu labels. +pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 6] = [ + (designcraft_doc::LiquidRule::Off, "Off"), + (designcraft_doc::LiquidRule::Scale, "Scale"), + (designcraft_doc::LiquidRule::ReCenter, "Re-center"), + (designcraft_doc::LiquidRule::ObjectBased, "Object-based"), + (designcraft_doc::LiquidRule::GuideBased, "Guide-based"), + (designcraft_doc::LiquidRule::ControlledByParent, "Controlled by Parent"), +]; + +/// A liquid page rule's menu label. +pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { + LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l) +} + /// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's /// liquid guides, and Create Alternate Layout. pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { @@ -501,23 +516,9 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong()); let mut rule = pg.liquid; egui::ComboBox::from_id_salt("liquid_rule") - .selected_text(crate::rtl::widget( - ui, - crate::i18n::tr( - &app.ui.language, - match rule { - L::Off => "Off", - L::Scale => "Scale", - L::ReCenter => "Re-center", - L::GuideBased => "Guide-based", - L::ObjectBased => "Object-based", - }, - ), - )) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule)))) .show_ui(ui, |ui| { - for (r, label) in - [(L::Off, "Off"), (L::Scale, "Scale"), (L::ReCenter, "Re-center"), (L::GuideBased, "Guide-based"), (L::ObjectBased, "Object-based")] - { + for (r, label) in LIQUID_RULES { ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))); } }); diff --git a/crates/ui-egui/src/panels/properties.rs b/crates/ui-egui/src/panels/properties.rs index d9c74e40d..b67be2d8e 100644 --- a/crates/ui-egui/src/panels/properties.rs +++ b/crates/ui-egui/src/panels/properties.rs @@ -160,7 +160,7 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { // ---------------------------------------------------------------- No Selection /// Page-size presets for the Document dropdown (portrait, points). -const PAGE_PRESETS: &[(&str, f64, f64)] = &[ +pub(crate) const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("Letter", 612.0, 792.0), ("Legal", 612.0, 1008.0), ("Tabloid", 792.0, 1224.0), @@ -171,9 +171,10 @@ const PAGE_PRESETS: &[(&str, f64, f64)] = &[ ("A5", 419.528, 595.276), ("A6", 297.638, 419.528), ("B5", 498.898, 708.661), + ("US Business Card", 144.0, 252.0), ]; -fn preset_name(w: f64, h: f64) -> &'static str { +pub(crate) fn preset_name(w: f64, h: f64) -> &'static str { let (a, b) = (w.min(h), w.max(h)); PAGE_PRESETS.iter().find(|p| (p.1 - a).abs() < 0.6 && (p.2 - b).abs() < 0.6).map(|p| p.0).unwrap_or("Custom") } @@ -386,7 +387,7 @@ fn document_sections(app: &mut DesignApp, ui: &mut Ui) { // ---------------------------------------------------------------- Objects /// The 9-point reference-point proxy; returns a newly picked point (0..8, row-major). -fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { +pub(crate) fn ref_point_proxy(ui: &mut Ui, r: Rect, cur: u8) -> Option { let t = Tokens::get(ui.ctx()); let mut out = None; let pitch = r.width() / 3.0; From 8864307d7c8567c80c8825aa4a13a13b9202ec92 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 00:52:06 +0200 Subject: [PATCH 3/8] docs: the Page tool's Control panel in the parity notes Co-Authored-By: Claude Opus 5.5 --- docs/target-app-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index 84b602d54..cbcebc37b 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 8f71219533f325779c678835dc8c4bca5c26f446 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:13:23 +0200 Subject: [PATCH 4/8] feat(tools): Page tool pins and locks objects on object-based pages, on the canvas 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" (6:32– 9:58) clicks an object with the Page tool on an object-based page and gets InDesign's on-canvas liquid controls: a pin outside each edge ("Object is pinned relative to the page edge. Click to release.") and a padlock on the width and height lines ("The object's width is locked. Click to let the object change width as the page changes width."). The pins and resize settings existed (liquid.object, the Liquid Layout panel) but the canvas showed none of it. - tools: on an object-based page a Page tool click selects the object with its page (layout.selectPage takes `ids`); its pins (14 px outside each edge's middle) and width/height locks (beside the centre) toggle liquid.object on click, show InDesign's tooltips on hover and a hand cursor. - canvas: LiquidPins (orange dashed width/height lines with padlocks, open when the object follows the page; hollow pins, or a line to the page edge when pinned) and Tip overlays; tooltip translations. Builds on the Page tool PR (#862). Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/engine/src/cmd/layout.rs | 21 ++++- crates/engine/src/tests.rs | 56 +++++++++++++ crates/tools/src/lib.rs | 15 ++++ crates/tools/src/nav.rs | 139 ++++++++++++++++++++++++++++++- crates/tools/src/tests.rs | 2 +- crates/ui-egui/src/canvas.rs | 52 ++++++++++++ crates/ui-egui/src/i18n.rs | 60 +++++++++++++ crates/ui-egui/src/i18n/ar.rs | 18 ++++ crates/ui-egui/src/i18n/it.rs | 18 ++++ crates/ui-egui/src/i18n/ja.rs | 18 ++++ crates/ui-egui/src/i18n/pt_br.rs | 18 ++++ crates/ui-egui/src/i18n/uk.rs | 18 ++++ docs/target-app-parity.md | 2 +- 14 files changed, 429 insertions(+), 9 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d9957d5d1..5c16da0c0 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -130,6 +130,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. - **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · On an object-based page the Page tool shows a selected object's liquid pins and resize locks as InDesign does: a click pins a side to the page edge or locks the width or height, with InDesign's tooltips. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. 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. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 4055b0809..1650d389c 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -389,20 +389,35 @@ pub fn specs() -> Vec { has_doc, page_options ), - cmd!(noundo "layout.selectPage", "Select Page", [], None, "{page: 1-based | null} — the page the Page tool selects (its handles, the Control panel)", has_doc, |s, p| { + cmd!( + noundo "layout.selectPage", + "Select Page", + [], + None, + "{page: 1-based | null, ids?: [item ids] (objects selected with it: the Page tool on an object-based page)} — the page the Page tool selects (its handles, the Control panel)", + has_doc, + |s, p| { let n = s.doc()?.doc.page_count(); let page = match p.get("page") { None | Some(Value::Null) => None, Some(v) => Some(v.as_u64().filter(|v| *v >= 1 && *v as usize <= n).ok_or_else(|| bad("layout.selectPage", format!("`page`: 1–{n}")))? as usize - 1), }; + let ids: Vec = { + let d = &s.doc()?.doc; + p.get("ids") + .and_then(Value::as_array) + .map(|a| a.iter().filter_map(Value::as_u64).map(designcraft_doc::ItemId).filter(|id| d.item(*id).is_some()).collect()) + .unwrap_or_default() + }; let st = s.doc_mut()?; if page.is_some() { - st.selection = Default::default(); + st.selection = designcraft_doc::Selection::items(ids); } st.selection.page = page; st.revision += 1; Ok(json!({"page": page.map(|p| p + 1)})) - }), + } + ), cmd!( "layout.section", "Numbering & Section Options…", diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 9557e03cf..af54bb942 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -667,3 +667,59 @@ fn controlled_by_parent_takes_the_parent_pages_rule() { assert!(s.doc().unwrap().doc.spreads[0].pages[0].show_parent_overlay); assert!(s.execute("layout.pageOptions", &json!({"pages": [2], "showParentOverlay": true})).is_err()); } + +#[test] +fn page_tool_pins_and_locks_objects_on_object_based_pages() { + use designcraft_tools::{CanvasLayout, Mods, PointerEvent, PointerKind}; + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 612, "height": 792, "facingPages": false})).unwrap(); + let id = s.execute("frame.create", &json!({"rect": [256, 72, 356, 132]})).unwrap()["id"].as_u64().unwrap(); + s.execute("liquid.pageRule", &json!({"rule": "objectBased"})).unwrap(); + s.execute("edit.deselectAll", &json!({})).unwrap(); + s.set_tool("page"); + let v = ViewInfo::at_zoom(1.0); + let ev = + |kind, p: designcraft_geom::Point, alt: bool| PointerEvent { mods: Mods { alt, ..Default::default() }, ..PointerEvent::new(kind, p.x, p.y) }; + let canvas = |s: &Session| { + let d = &s.doc().unwrap().doc; + let l = CanvasLayout::new(d, false); + ( + l.page_rect(d, 0).unwrap(), + l.xf(designcraft_doc::SpreadRef::Doc(0)).transform_rect_bbox(d.item(designcraft_doc::ItemId(id)).unwrap().bounds()), + ) + }; + let click = |s: &mut Session, p: designcraft_geom::Point| { + s.pointer(&ev(PointerKind::Down, p, false), v).unwrap(); + s.pointer(&ev(PointerKind::Up, p, false), v).unwrap(); + }; + let liquid = |s: &Session| s.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // A click on the object selects it with its page; its pins and locks show. + let (_, o) = canvas(&s); + click(&mut s, o.center()); + let sel = s.doc().unwrap().selection.clone(); + assert_eq!((sel.page, sel.items.clone()), (Some(0), vec![designcraft_doc::ItemId(id)])); + assert!( + s.overlays(v) + .iter() + .any(|o| matches!(o, designcraft_tools::Overlay::LiquidPins { pins: [false, false, false, false], resize: [true, true], .. })) + ); + // The right pin (14 px outside the right edge) and the width lock (10 px right of the centre). + click(&mut s, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y)); + click(&mut s, designcraft_geom::Point::new(o.center().x + 10.0, o.center().y)); + let l = liquid(&s).unwrap(); + assert!(l.pin_right && !l.resize_width && l.resize_height && !l.pin_left, "{l:?}"); + // Alt-drag the page 100 pt wider: the pinned, width-locked frame keeps its size by the right edge. + let (p, _) = canvas(&s); + let right = designcraft_geom::Point::new(p.x1, p.center().y); + s.pointer(&ev(PointerKind::Down, right, true), v).unwrap(); + s.pointer(&ev(PointerKind::Drag, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + s.pointer(&ev(PointerKind::Up, right + designcraft_geom::Vec2::new(100.0, 0.0), true), v).unwrap(); + let d = &s.doc().unwrap().doc; + let (pg, b) = (&d.spreads[0].pages[0], d.item(designcraft_doc::ItemId(id)).unwrap().bounds()); + assert_eq!(pg.width, 712.0); + assert!((b.width() - 100.0).abs() < 1e-6 && (pg.x + pg.width - b.x1 - 256.0).abs() < 1e-6, "{b:?}"); + // Hovering a pin shows its tooltip. + let (_, o) = canvas(&s); + s.pointer(&ev(PointerKind::Move, designcraft_geom::Point::new(o.x1 + 14.0, o.center().y), false), v).unwrap(); + assert!(s.overlays(v).iter().any(|o| matches!(o, designcraft_tools::Overlay::Tip { text, .. } if text.starts_with("Object is pinned")))); +} diff --git a/crates/tools/src/lib.rs b/crates/tools/src/lib.rs index dd09e9c25..db983fab5 100644 --- a/crates/tools/src/lib.rs +++ b/crates/tools/src/lib.rs @@ -15,6 +15,7 @@ mod frame; mod gradient; pub mod layout; mod nav; +pub use nav::{LOCK_OFFSET_PX, PIN_OFFSET_PX}; mod pen; mod pencil; pub mod select; @@ -322,6 +323,20 @@ pub enum Overlay { p: Point, text: String, }, + /// A tooltip near the pointer (English; the UI translates it). + Tip { + p: Point, + text: String, + }, + /// An object's liquid pins and resize locks on an object-based page (Page tool), canvas + /// coordinates: pinned sides (top, right, bottom, left) and whether width and height follow the + /// page. + LiquidPins { + object: Rect, + page: Rect, + pins: [bool; 4], + resize: [bool; 2], + }, } /// Correction from a snap, in spread coordinates for `delta`. diff --git a/crates/tools/src/nav.rs b/crates/tools/src/nav.rs index 54db73d06..bd53e4cac 100644 --- a/crates/tools/src/nav.rs +++ b/crates/tools/src/nav.rs @@ -168,6 +168,75 @@ impl Tool for PlaceGun { #[derive(Default)] pub struct PageTool { drag: Option, + /// The pin or lock under the pointer (for its tooltip). + hover: Option<(LiquidControl, Point)>, +} + +/// An object's liquid control on an object-based page: a side's pin (top, right, bottom, left) or +/// the width/height lock. +#[derive(Clone, Copy, Debug, PartialEq)] +enum LiquidControl { + Pin(usize), + Width, + Height, +} + +impl LiquidControl { + /// The `liquid.object` parameter it toggles. + fn key(self) -> &'static str { + match self { + LiquidControl::Pin(0) => "pinTop", + LiquidControl::Pin(1) => "pinRight", + LiquidControl::Pin(2) => "pinBottom", + LiquidControl::Pin(_) => "pinLeft", + LiquidControl::Width => "resizeWidth", + LiquidControl::Height => "resizeHeight", + } + } + + /// InDesign's tooltip for the control in its current state. + fn tip(self, l: &designcraft_doc::ObjectLiquid) -> &'static str { + match self { + LiquidControl::Pin(i) if [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left][i.min(3)] => { + "Object is pinned relative to the page edge. Click to release." + } + LiquidControl::Pin(_) => "Click to pin the object relative to the page edge.", + LiquidControl::Width if l.resize_width => "The object changes width as the page changes width. Click to lock the object width.", + LiquidControl::Width => "The object's width is locked. Click to let the object change width as the page changes width.", + LiquidControl::Height if l.resize_height => "The object changes height as the page changes height. Click to lock the object height.", + LiquidControl::Height => "The object's height is locked. Click to let the object change height as the page changes height.", + } + } + + fn value(self, l: &designcraft_doc::ObjectLiquid) -> bool { + match self { + LiquidControl::Pin(0) => l.pin_top, + LiquidControl::Pin(1) => l.pin_right, + LiquidControl::Pin(2) => l.pin_bottom, + LiquidControl::Pin(_) => l.pin_left, + LiquidControl::Width => l.resize_width, + LiquidControl::Height => l.resize_height, + } + } +} + +/// Screen pixels between an object's edge and its pin (the canvas draws them there too). +pub const PIN_OFFSET_PX: f64 = 14.0; +/// Screen pixels between an object's centre and its width/height locks. +pub const LOCK_OFFSET_PX: f64 = 10.0; + +/// Where an object's liquid controls sit (canvas): pins outside each edge's middle, the width lock +/// right of the centre, the height lock above it. +fn liquid_control_points(cx: &ToolContext, r: designcraft_geom::Rect) -> [(LiquidControl, Point); 6] { + let (c, o, k) = (r.center(), cx.tol(PIN_OFFSET_PX), cx.tol(LOCK_OFFSET_PX)); + [ + (LiquidControl::Pin(0), Point::new(c.x, r.y0 - o)), + (LiquidControl::Pin(1), Point::new(r.x1 + o, c.y)), + (LiquidControl::Pin(2), Point::new(c.x, r.y1 + o)), + (LiquidControl::Pin(3), Point::new(r.x0 - o, c.y)), + (LiquidControl::Width, Point::new(c.x + k, c.y)), + (LiquidControl::Height, Point::new(c.x, c.y - k)), + ] } /// A page handle being dragged. @@ -202,7 +271,43 @@ pub fn rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { } } +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(doc: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as R; + let Some((si, pi)) = doc.page_loc(abs) else { return false }; + match doc.spreads[si].pages[pi].liquid { + R::ObjectBased => true, + R::ControlledByParent => { + doc.parent_page_for(abs).and_then(|(ppi, ppg)| doc.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == R::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + impl PageTool { + /// The selected object's liquid settings, canvas bounds and page bounds when the Page tool + /// shows its pins: one object selected on the selected page, whose rule is object-based. + fn liquid_target( + cx: &ToolContext, + ) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, designcraft_geom::Rect, designcraft_geom::Rect)> { + let page = cx.selection.page?; + let [id] = cx.selection.items.as_slice() else { return None }; + let (si, _) = cx.doc.page_loc(page)?; + if !object_based(cx.doc, page) || cx.doc.page_of_item(*id) != Some(page) { + return None; + } + let it = cx.doc.item(*id)?; + let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); + let r = cx.layout.xf(designcraft_doc::SpreadRef::Doc(si)).transform_rect_bbox(it.bounds()); + Some((*id, l, r, cx.layout.page_rect(cx.doc, page)?)) + } + + /// The selected object's pin or lock under `p`. + fn control_at(cx: &ToolContext, p: Point) -> Option<(designcraft_doc::ItemId, designcraft_doc::ObjectLiquid, LiquidControl)> { + let (id, l, r, _) = Self::liquid_target(cx)?; + liquid_control_points(cx, r).into_iter().find(|(_, q)| (q.x - p.x).hypot(q.y - p.y) <= cx.tol(6.0)).map(|(c, _)| (id, l, c)) + } + /// The selected page's handle under `p`. fn handle_at(&self, cx: &ToolContext, p: Point) -> Option<(usize, usize)> { let page = cx.selection.page?; @@ -255,12 +360,21 @@ impl Tool for PageTool { self.drag = Some(PageDrag { page, handle, start: ev.pos, cur: ev.pos, size: (pg.width, pg.height), alt: ev.mods.alt }); return vec![Action::Begin("Resize Page".into())]; } - // A click selects the page under the pointer (none on the pasteboard). + // An object-based page's object: a click on a pin or lock toggles it. + if let Some((id, l, c)) = Self::control_at(cx, ev.pos) { + return vec![Action::Exec("liquid.object".into(), json!({"ids": [id.0], c.key(): !c.value(&l)}))]; + } + // A click selects the page under the pointer (none on the pasteboard), and on an + // object-based page the object clicked too. let page = (0..cx.doc.page_count()).find(|p| cx.layout.page_rect(cx.doc, *p).is_some_and(|r| r.contains(ev.pos))); - if page == cx.selection.page { + let object = page + .filter(|p| object_based(cx.doc, *p)) + .and_then(|p| cx.hit(ev.pos).map(|(_, id)| id).filter(|id| cx.doc.page_of_item(*id) == Some(p))); + let ids: Vec = object.map(|id| id.0).into_iter().collect(); + if page == cx.selection.page && cx.selection.items.iter().map(|i| i.0).eq(ids.iter().copied()) { return vec![]; } - vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1)}))] + vec![Action::Exec("layout.selectPage".into(), json!({"page": page.map(|p| p + 1), "ids": ids}))] } PointerKind::Drag => { let Some(d) = self.drag.as_mut() else { return vec![] }; @@ -285,7 +399,10 @@ impl Tool for PageTool { let pg = &cx.doc.spreads[si].pages[pi]; vec![Action::Dialog("cmd:layout.pageSize".into(), json!({"pages": [page + 1], "width": pg.width, "height": pg.height}))] } - PointerKind::Move => vec![], + PointerKind::Move => { + self.hover = Self::control_at(cx, ev.pos).map(|(_, _, c)| (c, ev.pos)); + vec![] + } } } fn overlays(&self, cx: &ToolContext) -> Vec { @@ -294,6 +411,17 @@ impl Tool for PageTool { let Some((si, pi)) = cx.doc.page_loc(page) else { return vec![] }; let pg = &cx.doc.spreads[si].pages[pi]; let mut v = vec![Overlay::PageHandles(r), Overlay::Tag { p: Point::new(r.x0, r.y0 - cx.tol(6.0)), text: rule_label(pg.liquid).into() }]; + if let Some((_, l, object, page)) = Self::liquid_target(cx) { + v.push(Overlay::LiquidPins { + object, + page, + pins: [l.pin_top, l.pin_right, l.pin_bottom, l.pin_left], + resize: [l.resize_width, l.resize_height], + }); + if let Some((c, p)) = self.hover { + v.push(Overlay::Tip { p, text: c.tip(&l).into() }); + } + } if let Some(d) = self.drag { v.push(Overlay::Measure { p: d.cur, @@ -307,6 +435,9 @@ impl Tool for PageTool { v } fn cursor(&self, cx: &ToolContext, p: Point, _m: Mods) -> Cursor { + if self.drag.is_none() && Self::control_at(cx, p).is_some() { + return Cursor::Hand; + } let handle = self.drag.map(|d| d.handle).or_else(|| self.handle_at(cx, p).map(|(_, h)| h)); match handle { Some(1 | 7) => Cursor::ResizeV, diff --git a/crates/tools/src/tests.rs b/crates/tools/src/tests.rs index b2e597242..81d67c309 100644 --- a/crates/tools/src/tests.rs +++ b/crates/tools/src/tests.rs @@ -235,7 +235,7 @@ fn page_tool_selects_a_page_and_opens_page_size_on_double_click() { let (x, y) = (200.0 + off.x + d.spreads[0].pages[0].x, 200.0 + off.y); let mut t = create("page"); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, x, y)); - assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1}))]); + assert_eq!(a, vec![Action::Exec("layout.selectPage".into(), serde_json::json!({"page": 1, "ids": []}))]); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::DoubleClick, x, y)); assert_eq!(a, vec![Action::Dialog("cmd:layout.pageSize".into(), serde_json::json!({"pages": [1], "width": 612.0, "height": 792.0}))]); // The selected page shows its handles and its liquid page rule. diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 003549a42..f20e64022 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -1268,11 +1268,63 @@ fn draw_tool_overlays(app: &mut DesignApp, painter: &egui::Painter, xf: &Xf) { painter.rect_stroke(r, 2.0, Stroke::new(1.0, Color32::from_rgb(79, 153, 255)), StrokeKind::Inside); painter.galley(r.min + vec2(4.0, 2.0), g, tok.measure_text); } + Overlay::Tip { p, text } => { + let text = crate::i18n::tr(&app.ui.language, &text).to_owned(); + let s = xf.to_screen(p) + vec2(14.0, 14.0); + let g = painter.layout(text, egui::FontId::proportional(11.0), tok.measure_text, 260.0); + let r = Rect::from_min_size(s, g.size() + vec2(10.0, 6.0)); + painter.rect_filled(r, 3.0, tok.measure_bg); + painter.galley(r.min + vec2(5.0, 3.0), g, tok.measure_text); + } + Overlay::LiquidPins { object, page, pins, resize } => draw_liquid_pins(painter, xf.rect(object), xf.rect(page), pins, resize), Overlay::Path { .. } => {} } } } +/// An object's liquid controls on an object-based page, as InDesign draws them in orange: a +/// dashed line across the object for width and height with a padlock (closed: locked), and a pin +/// outside each edge (pinned: a line to the page edge). +fn draw_liquid_pins(painter: &egui::Painter, o: Rect, page: Rect, pins: [bool; 4], resize: [bool; 2]) { + let col = Color32::from_rgb(178, 88, 18); + let st = Stroke::new(1.5, col); + let c = o.center(); + let mids = [pos2(c.x, o.min.y), pos2(o.max.x, c.y), pos2(c.x, o.max.y), pos2(o.min.x, c.y)]; + // The width and height lines between the edges' middles. + for (a, b) in [(mids[3], mids[1]), (mids[0], mids[2])] { + dashed(painter, a, b, Stroke::new(1.0, col), 3.0, 3.0); + } + for m in mids { + painter.circle_filled(m, 3.5, col); + } + let (po, lo) = (designcraft_tools::PIN_OFFSET_PX as f32, designcraft_tools::LOCK_OFFSET_PX as f32); + let outward = [vec2(0.0, -1.0), vec2(1.0, 0.0), vec2(0.0, 1.0), vec2(-1.0, 0.0)]; + let edge = [pos2(c.x, page.min.y), pos2(page.max.x, c.y), pos2(c.x, page.max.y), pos2(page.min.x, c.y)]; + for i in 0..4 { + if pins[i] { + painter.line_segment([mids[i], edge[i]], st); + painter.circle_filled(edge[i], 4.0, col); + } else { + painter.circle_stroke(mids[i] + outward[i] * po, 4.0, st); + } + } + // Padlocks: the width lock right of the centre, the height lock above it. + for (at, locked) in [(c + vec2(lo, 0.0), !resize[0]), (c + vec2(0.0, -lo), !resize[1])] { + let body = Rect::from_center_size(at + vec2(0.0, 2.0), vec2(8.0, 6.0)); + painter.rect_filled(body.expand(1.0), 1.0, Color32::WHITE); + painter.rect_filled(body, 1.0, col); + // The shackle: over the body when locked, swung open when not. + let base = if locked { body.center().x } else { body.center().x + 3.0 }; + let pts: Vec<_> = (0..=8) + .map(|k| { + let a = std::f32::consts::PI * k as f32 / 8.0; + pos2(base - 2.6 * a.cos(), body.min.y - 3.0 * a.sin()) + }) + .collect(); + painter.add(egui::Shape::line(pts, Stroke::new(1.3, col))); + } +} + #[allow(clippy::too_many_arguments)] fn draw_rulers(app: &DesignApp, ui: &egui::Ui, full: Rect, rect: Rect, xf: &Xf, doc: &Document, layout: &CanvasLayout, t: &Tokens) { let painter = ui.painter_at(full); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ab6b52fc2..5fd1d5fd8 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2530,6 +2530,66 @@ const TABLE: &[(&str, [&str; 5])] = &[ ["Durch übergeordnete Seite gesteuert", "Contrôlé par le parent", "Controlado por principal", "親ページで制御", "由父页面控制"], ), ("US Business Card", ["US-Visitenkarte", "Carte de visite US", "Tarjeta de visita EE. UU.", "US 名刺", "美国名片"]), + ( + "Object is pinned relative to the page edge. Click to release.", + [ + "Das Objekt ist relativ zum Seitenrand fixiert. Zum Lösen klicken.", + "L’objet est épinglé par rapport au bord de la page. Cliquez pour le libérer.", + "El objeto está fijado respecto al borde de la página. Haga clic para soltarlo.", + "オブジェクトはページの端を基準に固定されています。クリックすると解除します。", + "对象已相对于页面边缘固定。单击可释放。", + ], + ), + ( + "Click to pin the object relative to the page edge.", + [ + "Klicken, um das Objekt relativ zum Seitenrand zu fixieren.", + "Cliquez pour épingler l’objet par rapport au bord de la page.", + "Haga clic para fijar el objeto respecto al borde de la página.", + "クリックすると、オブジェクトをページの端を基準に固定します。", + "单击可将对象相对于页面边缘固定。", + ], + ), + ( + "The object changes width as the page changes width. Click to lock the object width.", + [ + "Die Objektbreite ändert sich mit der Seitenbreite. Zum Sperren der Breite klicken.", + "La largeur de l’objet suit celle de la page. Cliquez pour verrouiller la largeur.", + "El ancho del objeto cambia con el de la página. Haga clic para bloquear el ancho.", + "オブジェクトの幅はページの幅に合わせて変わります。クリックすると幅をロックします。", + "对象宽度随页面宽度变化。单击可锁定对象宽度。", + ], + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + [ + "Die Objektbreite ist gesperrt. Klicken, damit sie sich mit der Seitenbreite ändert.", + "La largeur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El ancho del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの幅はロックされています。クリックすると、ページの幅に合わせて変わります。", + "对象宽度已锁定。单击可让其随页面宽度变化。", + ], + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + [ + "Die Objekthöhe ändert sich mit der Seitenhöhe. Zum Sperren der Höhe klicken.", + "La hauteur de l’objet suit celle de la page. Cliquez pour verrouiller la hauteur.", + "El alto del objeto cambia con el de la página. Haga clic para bloquear el alto.", + "オブジェクトの高さはページの高さに合わせて変わります。クリックすると高さをロックします。", + "对象高度随页面高度变化。单击可锁定对象高度。", + ], + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + [ + "Die Objekthöhe ist gesperrt. Klicken, damit sie sich mit der Seitenhöhe ändert.", + "La hauteur de l’objet est verrouillée. Cliquez pour qu’elle suive celle de la page.", + "El alto del objeto está bloqueado. Haga clic para que cambie con el de la página.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + "对象高度已锁定。单击可让其随页面高度变化。", + ], + ), ("Loop", ["Loop", "Loop", "Loop", "Loop", "循环播放"]), ("Object-based", ["Object-based", "Object-based", "Object-based", "Object-based", "基于对象"]), ("Off", ["Off", "Off", "Off", "Off", "关闭"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 84b003e07..b94d63e74 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -717,6 +717,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), ("US Business Card", "بطاقة عمل أمريكية"), + ("Object is pinned relative to the page edge. Click to release.", "الكائن مثبت بالنسبة إلى حافة الصفحة. انقر لتحريره."), + ("Click to pin the object relative to the page edge.", "انقر لتثبيت الكائن بالنسبة إلى حافة الصفحة."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "يتغير عرض الكائن مع تغير عرض الصفحة. انقر لقفل عرض الكائن.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "عرض الكائن مقفل. انقر للسماح بتغيره مع عرض الصفحة.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "يتغير ارتفاع الكائن مع تغير ارتفاع الصفحة. انقر لقفل ارتفاع الكائن.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "ارتفاع الكائن مقفل. انقر للسماح بتغيره مع ارتفاع الصفحة.", + ), ("Loop", "تكرار"), ("Object-based", "استنادًا إلى الكائنات"), ("Play on Page Load", "تشغيل عند تحميل الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8195bac61..eb93f1a91 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1303,6 +1303,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), ("Controlled by Parent", "Controllato dalla pagina principale"), ("US Business Card", "Biglietto da visita USA"), + ("Object is pinned relative to the page edge. Click to release.", "L’oggetto è fissato rispetto al bordo della pagina. Fai clic per sbloccarlo."), + ("Click to pin the object relative to the page edge.", "Fai clic per fissare l’oggetto rispetto al bordo della pagina."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "La larghezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "La larghezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "L’altezza dell’oggetto cambia con quella della pagina. Fai clic per bloccarla.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "L’altezza dell’oggetto è bloccata. Fai clic perché cambi con quella della pagina.", + ), ("Loop", "Ciclo continuo"), ("Object-based", "Basato su oggetti"), ("Off", "Disattivato"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ead7f576d..6d805ec39 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1299,6 +1299,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "親ページオーバーレイを表示"), ("Controlled by Parent", "親ページで制御"), ("US Business Card", "US 名刺"), + ("Object is pinned relative to the page edge. Click to release.", "オブジェクトはページの端を基準に固定されています。クリックすると解除します。"), + ("Click to pin the object relative to the page edge.", "クリックすると、オブジェクトをページの端を基準に固定します。"), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "オブジェクトの幅はページの幅に合わせて変わります。クリックすると幅をロックします。", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "オブジェクトの幅はロックされています。クリックすると、ページの幅に合わせて変わります。", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "オブジェクトの高さはページの高さに合わせて変わります。クリックすると高さをロックします。", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "オブジェクトの高さはロックされています。クリックすると、ページの高さに合わせて変わります。", + ), ("Loop", "繰り返し再生"), ("Object-based", "オブジェクト基準"), ("Off", "オフ"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 52874f549..5b61cc42e 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -716,6 +716,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), ("Controlled by Parent", "Controlado pela página principal"), ("US Business Card", "Cartão de visita EUA"), + ("Object is pinned relative to the page edge. Click to release.", "O objeto está fixado em relação à borda da página. Clique para liberar."), + ("Click to pin the object relative to the page edge.", "Clique para fixar o objeto em relação à borda da página."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "A largura do objeto muda com a da página. Clique para bloquear a largura.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "A largura do objeto está bloqueada. Clique para que mude com a da página.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "A altura do objeto muda com a da página. Clique para bloquear a altura.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "A altura do objeto está bloqueada. Clique para que mude com a da página.", + ), ("Loop", "Repetir"), ("Object-based", "Baseado em objetos"), ("Play on Page Load", "Reproduzir ao carregar a página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 68e15e629..85ab20294 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1300,6 +1300,24 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), ("Controlled by Parent", "Керується батьківською сторінкою"), ("US Business Card", "Візитна картка (США)"), + ("Object is pinned relative to the page edge. Click to release.", "Об’єкт закріплено відносно краю сторінки. Клацніть, щоб звільнити."), + ("Click to pin the object relative to the page edge.", "Клацніть, щоб закріпити об’єкт відносно краю сторінки."), + ( + "The object changes width as the page changes width. Click to lock the object width.", + "Ширина об’єкта змінюється разом зі сторінкою. Клацніть, щоб заблокувати ширину.", + ), + ( + "The object's width is locked. Click to let the object change width as the page changes width.", + "Ширину об’єкта заблоковано. Клацніть, щоб вона змінювалася разом зі сторінкою.", + ), + ( + "The object changes height as the page changes height. Click to lock the object height.", + "Висота об’єкта змінюється разом зі сторінкою. Клацніть, щоб заблокувати висоту.", + ), + ( + "The object's height is locked. Click to let the object change height as the page changes height.", + "Висоту об’єкта заблоковано. Клацніть, щоб вона змінювалася разом зі сторінкою.", + ), ("Loop", "Зациклити"), ("Object-based", "На основі об’єктів"), ("Off", "Вимкнено"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index cbcebc37b..816f5940d 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -396,7 +396,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. On an object-based page it selects objects and shows their pins and width/height locks on the canvas (click to toggle, with tooltips). - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 9da44a042ff0d4d3eff1a5c5bb7a8dd234995960 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:29:44 +0200 Subject: [PATCH 5/8] feat(ui): Liquid Layout panel in InDesign's layout; Create Alternate Layout in the Layout menu 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" (2:47, 7:24) uses the Liquid Layout panel: "Liquid Page Rule:" with "Page: n", Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top, Left, Bottom, Right), greyed until an object on an object-based page is selected. DesignCraft's panel had a "Page 1" label, the rule, six wrapped checkboxes ("Resize width", "Pin top"…) only once an object was selected, and a Create Alternate Layout form. - Liquid Layout panel: InDesign's layout and labels; the page is the one the Page tool selected (else the page in view); Auto-Fit (graphic frames) runs object.fittingOptions; the constraints apply on object-based pages (also through Controlled by Parent) and stay greyed otherwise. The page's liquid guides stay listed below for now. - Layout menu: Create Alternate Layout… (as in InDesign). - Translations for the new labels. Builds on #864 (rule list, Controlled by Parent) and #862. Co-Authored-By: Claude Opus 5.5 --- ROADMAP.md | 1 + crates/ui-egui/src/i18n.rs | 8 ++ crates/ui-egui/src/i18n/ar.rs | 5 + crates/ui-egui/src/i18n/it.rs | 5 + crates/ui-egui/src/i18n/ja.rs | 5 + crates/ui-egui/src/i18n/pt_br.rs | 5 + crates/ui-egui/src/i18n/uk.rs | 5 + crates/ui-egui/src/menus.rs | 1 + crates/ui-egui/src/panels/interactive.rs | 170 +++++++++++++++++------ 9 files changed, 160 insertions(+), 45 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index d9957d5d1..522ea8daa 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** · The Liquid Layout panel follows InDesign's: Liquid Page Rule with the page number, Content › Auto-Fit, and Object Constraints › Resize with Page and Pin for the selected object on an object-based page; Create Alternate Layout moved to the Layout menu. No readiness percentage change. - **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. 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. diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index ab6b52fc2..4d49c3152 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -2498,6 +2498,14 @@ const TABLE: &[(&str, [&str; 5])] = &[ ("Added", ["Added", "Added", "Added", "Added", "已添加"]), ("Deleted", ["Deleted", "Deleted", "Deleted", "Deleted", "已删除"]), ("Guide-based", ["Guide-based", "Guide-based", "Guide-based", "Guide-based", "基于参考线"]), + ("Page:", ["Seite:", "Page :", "Página:", "ページ:", "页面:"]), + ("Content:", ["Inhalt:", "Contenu :", "Contenido:", "コンテンツ:", "内容:"]), + ("Object Constraints:", ["Objektbeschränkungen:", "Contraintes d’objet :", "Restricciones de objeto:", "オブジェクトの制約:", "对象约束:"]), + ( + "Resize with Page:", + ["Mit Seite skalieren:", "Redimensionner avec la page :", "Cambiar tamaño con la página:", "ページと共にサイズ変更:", "随页面调整大小:"], + ), + ("Pin:", ["Fixieren:", "Épingler :", "Fijar:", "固定:", "固定:"]), ("Object-Based", ["Object-Based", "Object-Based", "Object-Based", "Object-Based", "基于对象"]), ("Guide-Based", ["Guide-Based", "Guide-Based", "Guide-Based", "Guide-Based", "基于参考线"]), ("Resize Page", ["Seitengröße ändern", "Redimensionner la page", "Cambiar tamaño de página", "ページのサイズを変更", "调整页面大小"]), diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 84b003e07..9231209ed 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -709,6 +709,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "مضاف"), ("Deleted", "محذوف"), ("Guide-based", "استنادًا إلى الأدلة"), + ("Page:", "الصفحة:"), + ("Content:", "المحتوى:"), + ("Object Constraints:", "قيود الكائن:"), + ("Resize with Page:", "تغيير الحجم مع الصفحة:"), + ("Pin:", "تثبيت:"), ("Object-Based", "استنادًا إلى الكائنات"), ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8195bac61..591b4904e 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1295,6 +1295,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Aggiunto"), ("Deleted", "Eliminato"), ("Guide-based", "Basato su guide"), + ("Page:", "Pagina:"), + ("Content:", "Contenuto:"), + ("Object Constraints:", "Vincoli oggetto:"), + ("Resize with Page:", "Ridimensiona con la pagina:"), + ("Pin:", "Fissa:"), ("Object-Based", "Basato su oggetti"), ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index ead7f576d..c125db3f0 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1291,6 +1291,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "追加済み"), ("Deleted", "削除済み"), ("Guide-based", "ガイド基準"), + ("Page:", "ページ:"), + ("Content:", "コンテンツ:"), + ("Object Constraints:", "オブジェクトの制約:"), + ("Resize with Page:", "ページと共にサイズ変更:"), + ("Pin:", "固定:"), ("Object-Based", "オブジェクト基準"), ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 52874f549..0f5d32322 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -708,6 +708,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Adicionado"), ("Deleted", "Excluído"), ("Guide-based", "Baseado em guias"), + ("Page:", "Página:"), + ("Content:", "Conteúdo:"), + ("Object Constraints:", "Restrições de objeto:"), + ("Resize with Page:", "Redimensionar com a página:"), + ("Pin:", "Fixar:"), ("Object-Based", "Baseado em objetos"), ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 68e15e629..c7e73bd4e 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1292,6 +1292,11 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Added", "Додано"), ("Deleted", "Видалено"), ("Guide-based", "На основі напрямних"), + ("Page:", "Сторінка:"), + ("Content:", "Вміст:"), + ("Object Constraints:", "Обмеження об’єкта:"), + ("Resize with Page:", "Змінювати розмір зі сторінкою:"), + ("Pin:", "Закріпити:"), ("Object-Based", "На основі об’єктів"), ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), diff --git a/crates/ui-egui/src/menus.rs b/crates/ui-egui/src/menus.rs index 872cb81d0..26715dcd1 100644 --- a/crates/ui-egui/src/menus.rs +++ b/crates/ui-egui/src/menus.rs @@ -308,6 +308,7 @@ pub const MENUS: &[(&str, &[&str])] = &[ "<", "cmd:layout.marginsAndColumns", "cmd:layout.createGuides", + "cmd:layout.createAlternate", "-", "ui:view.goToPage", "-", diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index cd36926fa..0968c9017 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -501,50 +501,104 @@ pub fn liquid_rule_label(rule: designcraft_doc::LiquidRule) -> &'static str { LIQUID_RULES.iter().find(|(r, _)| *r == rule).map_or("Off", |(_, l)| l) } -/// Liquid Layout panel: the current page's rule, the selection's pins (object-based), the page's -/// liquid guides, and Create Alternate Layout. +/// Whether page `abs` follows the object-based rule (its own, or its parent page's). +fn object_based(d: &designcraft_doc::Document, abs: usize) -> bool { + use designcraft_doc::LiquidRule as L; + let Some((si, pi)) = d.page_loc(abs) else { return false }; + match d.spreads[si].pages[pi].liquid { + L::ObjectBased => true, + L::ControlledByParent => { + d.parent_page_for(abs).and_then(|(ppi, ppg)| d.parents.get(ppi)?.pages.get(ppg).map(|p| p.liquid == L::ObjectBased)).unwrap_or(false) + } + _ => false, + } +} + +/// Liquid Layout panel, laid out as InDesign's: the page's Liquid Page Rule (with "Page: n"), +/// Content › Auto-Fit, and Object Constraints › Resize with Page (Height, Width) and Pin (Top, +/// Left, Bottom, Right) for the selected object on an object-based page. The page's liquid +/// guides follow (Create Alternate Layout is in the Layout menu). pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { let t = Tokens::get(ui.ctx()); - let Some(page) = crate::canvas::current_page(app) else { - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "No document.")).size(11.0).color(t.text_dim)); + let lang = app.ui.language.clone(); + // The Page tool's page, the page in view, or (before the canvas lays out) the first. + let Some(page) = app.session.active().map(|s| s.selection.page.or_else(|| crate::canvas::current_page(app)).unwrap_or(0)) else { + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "No document.")).size(11.0).color(t.text_dim)); return; }; let Some(d) = app.session.active().map(|d| d.doc.clone()) else { return }; let Some((si, pi)) = d.page_loc(page) else { return }; let pg = d.spreads[si].pages[pi].clone(); - use designcraft_doc::LiquidRule as L; - crate::rtl::label(ui, egui::RichText::new(format!("{} {}", crate::i18n::tr(&app.ui.language, "Page"), d.page_name(page))).strong()); - let mut rule = pg.liquid; + ui.horizontal(|ui| { + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Liquid Page Rule:")); + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + crate::widgets::caption(ui, &format!("{} {}", crate::i18n::tr(&lang, "Page:"), d.page_name(page))); + }); + }); egui::ComboBox::from_id_salt("liquid_rule") - .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, liquid_rule_label(rule)))) + .selected_text(crate::rtl::widget(ui, crate::i18n::tr(&lang, liquid_rule_label(pg.liquid)))) + .width(190.0) .show_ui(ui, |ui| { for (r, label) in LIQUID_RULES { - ui.selectable_value(&mut rule, r, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))); - } - }); - if rule != pg.liquid { - let _ = app.run("liquid.pageRule", json!({"rule": rule, "pages": [page + 1]})); - } - if rule == L::ObjectBased - && let Some(id) = app.session.active().and_then(|s| s.selection.items.first().copied()) - && let Some(it) = d.item(id) - { - ui.separator(); - let l = it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() }); - let mut v = [l.resize_width, l.resize_height, l.pin_top, l.pin_bottom, l.pin_left, l.pin_right]; - let keys = ["resizeWidth", "resizeHeight", "pinTop", "pinBottom", "pinLeft", "pinRight"]; - let labels = ["Resize width", "Resize height", "Pin top", "Pin bottom", "Pin left", "Pin right"]; - ui.horizontal_wrapped(|ui| { - for ((on, key), label) in v.iter_mut().zip(keys).zip(labels) { - if ui.checkbox(on, crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, label))).changed() { - let _ = app.run("liquid.object", json!({key: *on})); + if ui.selectable_label(pg.liquid == r, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).clicked() && r != pg.liquid { + let _ = app.run("liquid.pageRule", json!({"rule": r, "pages": [page + 1]})); } } }); - } + ui.separator(); + // The selected object (one on this page). + let item = app + .session + .active() + .and_then(|s| s.selection.items.first().copied()) + .and_then(|id| d.item(id)) + .filter(|it| d.page_of_item(it.id) == Some(page)); + // Content: Auto-Fit (graphic frames). + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Content:")); + let graphic = item.and_then(|it| if let designcraft_doc::Content::Graphic(g) = &it.content { Some(g.auto_fit) } else { None }); + let mut auto = graphic.is_some_and(|f| f != designcraft_doc::Fitting::None); + ui.indent("liquid_content", |ui| { + let r = ui.add_enabled(graphic.is_some(), egui::Checkbox::new(&mut auto, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Auto-Fit")))); + if r.changed() { + let _ = app.run("object.fittingOptions", json!({"autoFit": auto})); + } + }); + // Object Constraints: for an object on an object-based page. + crate::widgets::caption(ui, crate::i18n::tr(&lang, "Object Constraints:")); + let on = item.is_some() && object_based(&d, page); + let l = item + .filter(|_| on) + .map(|it| it.liquid.unwrap_or(designcraft_doc::ObjectLiquid { resize_width: true, resize_height: true, ..Default::default() })) + .unwrap_or_default(); + ui.indent("liquid_constraints", |ui| { + ui.add_enabled_ui(on, |ui| { + let rows: [(&str, [(&str, &str, bool); 2]); 2] = [ + ("Resize with Page:", [("Height", "resizeHeight", l.resize_height), ("Width", "resizeWidth", l.resize_width)]), + ("Pin:", [("Top", "pinTop", l.pin_top), ("Left", "pinLeft", l.pin_left)]), + ]; + for (title, boxes) in rows { + crate::widgets::caption(ui, crate::i18n::tr(&lang, title)); + let mut grid_boxes = vec![boxes.to_vec()]; + if title == "Pin:" { + grid_boxes.push(vec![("Bottom", "pinBottom", l.pin_bottom), ("Right", "pinRight", l.pin_right)]); + } + egui::Grid::new(("liquid_grid", title)).num_columns(2).min_col_width(90.0).show(ui, |ui| { + for row in grid_boxes { + for (label, key, value) in row { + let mut v = value; + if ui.checkbox(&mut v, crate::rtl::widget(ui, crate::i18n::tr(&lang, label))).changed() { + let _ = app.run("liquid.object", json!({key: v})); + } + } + ui.end_row(); + } + }); + } + }); + }); if !pg.guides.is_empty() { ui.separator(); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Liquid guides")).size(11.0).color(t.text_dim)); + crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&lang, "Liquid guides")).size(11.0).color(t.text_dim)); for (gi, g) in pg.guides.iter().enumerate() { let mut on = g.liquid; let label = format!("{:?} at {:.1}", g.orientation, g.position); @@ -553,22 +607,6 @@ pub fn liquid(app: &mut DesignApp, ui: &mut egui::Ui) { } } } - ui.separator(); - let key = egui::Id::new("alt_layout"); - let (mut name, mut w, mut h): (String, f64, f64) = ui.data(|x| x.get_temp(key)).unwrap_or(("Tablet H".into(), 1024.0, 768.0)); - crate::rtl::label(ui, egui::RichText::new(crate::i18n::tr(&app.ui.language, "Create Alternate Layout")).size(11.0).color(t.text_dim)); - ui.horizontal(|ui| { - ui.add(egui::TextEdit::singleline(&mut name).desired_width(90.0)); - ui.add(egui::DragValue::new(&mut w).range(1.0..=15552.0).suffix(" pt")); - crate::rtl::label(ui, crate::i18n::tr(&app.ui.language, "×")); - ui.add(egui::DragValue::new(&mut h).range(1.0..=15552.0).suffix(" pt")); - }); - if ui.button(crate::rtl::widget(ui, crate::i18n::tr(&app.ui.language, "Create"))).clicked() - && let Err(e) = app.run("layout.createAlternate", json!({"name": name, "width": w, "height": h})) - { - app.status(format!("Alternate layout: {e}")); - } - ui.data_mut(|x| x.insert_temp(key, (name, w, h))); } /// Media panel: the selected video or sound's playback options and poster. @@ -734,3 +772,45 @@ pub fn track_changes(app: &mut DesignApp, ui: &mut egui::Ui) { } }); } + +#[cfg(test)] +mod liquid_panel_tests { + use egui_kittest::{Harness, kittest::Queryable}; + use serde_json::json; + + use super::*; + + #[test] + fn object_constraints_set_the_selected_objects_pins_and_resizing() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + let id = app.run("frame.create", json!({"rect": [100, 100, 200, 200]})).unwrap()["id"].as_u64().unwrap(); + let mut h = Harness::builder().with_size(egui::vec2(260.0, 420.0)).build_ui_state( + |ui, app: &mut DesignApp| { + let key = egui::Id::new("test_fonts"); + if ui.data(|d| d.get_temp::(key)).is_none() { + crate::theme::install_fonts(ui.ctx(), ""); + ui.data_mut(|d| d.insert_temp(key, true)); + return; + } + liquid(app, ui); + }, + app, + ); + h.run_steps(3); + let l = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.item(designcraft_doc::ItemId(id)).unwrap().liquid; + // With the rule off the constraints don't apply. + h.get_by_label("Right").click(); + h.run_steps(2); + assert_eq!(l(&h), None); + h.state_mut().run("liquid.pageRule", json!({"rule": "objectBased"})).unwrap(); + h.run_steps(2); + h.get_by_label("Right").click(); + h.run_steps(2); + h.get_by_label("Width").click(); + h.run_steps(2); + let v = l(&h).unwrap(); + assert!(v.pin_right && !v.resize_width && v.resize_height, "{v:?}"); + let _ = h.get_by_label("Auto-Fit"); + } +} From db19963bad7969fb6832483c8ae1fe6c79508c09 Mon Sep 17 00:00:00 2001 From: moppeeer Date: Wed, 14 Oct 2026 01:37:14 +0200 Subject: [PATCH 6/8] 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(); From 47b51d637756a468555feeac60965d3c94ab05d1 Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:39:08 +0000 Subject: [PATCH 7/8] Integration: #864 drops the Controlled by Parent rule from the menus and the Objects Move with Page checkbox (neither has an effect yet) --- ROADMAP.md | 2 +- crates/ui-egui/src/chrome.rs | 13 ++++++------- crates/ui-egui/src/i18n.rs | 10 ---------- crates/ui-egui/src/i18n/ar.rs | 1 - crates/ui-egui/src/i18n/it.rs | 1 - crates/ui-egui/src/i18n/ja.rs | 1 - crates/ui-egui/src/i18n/pt_br.rs | 1 - crates/ui-egui/src/i18n/uk.rs | 1 - crates/ui-egui/src/panels/interactive.rs | 3 +-- docs/target-app-parity.md | 2 +- 10 files changed, 9 insertions(+), 26 deletions(-) diff --git a/ROADMAP.md b/ROADMAP.md index de7b0edeb..f425e9e18 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -128,7 +128,7 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. -- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule (now with Controlled by Parent) and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. +- **2026-10-14** · With the Page tool, the Control panel shows the page as InDesign's does: W/H from a reference point, page-size preset (US Business Card added), orientation, Liquid Page Rule and Show Parent Page Overlay, which draws the parent page's size over a resized page. No readiness percentage change. - **2026-10-14** · The Page tool works as InDesign's: a click selects a page and shows its handles and liquid page rule; dragging a handle previews the rule and springs back, Alt-drag resizes the page from the opposite edge, Shift keeps proportions, with a W/H readout. No readiness percentage change. - **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-13** · Control-channel clicks and drags validate positive counts and the complete pending-event budget before queuing input (#732). Oversized requests leave pending gestures intact, preventing unbounded click-count allocation. No readiness percentage change. diff --git a/crates/ui-egui/src/chrome.rs b/crates/ui-egui/src/chrome.rs index 55a999ae4..c6fa89796 100644 --- a/crates/ui-egui/src/chrome.rs +++ b/crates/ui-egui/src/chrome.rs @@ -478,7 +478,7 @@ pub fn control_bar(app: &mut DesignApp, ui: &mut egui::Ui) { } /// The Control panel with the Page tool, as InDesign's: reference point, X/Y, W/H, page-size -/// preset, orientation, Liquid Page Rule, Objects Move with Page and Show Parent Page Overlay, for +/// preset, orientation, Liquid Page Rule and Show Parent Page Overlay, for /// the page the Page tool selected (else the page in view). W/H keep the reference point in place. fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { let lang = app.ui.language.clone(); @@ -557,9 +557,6 @@ fn control_page(app: &mut DesignApp, ui: &mut egui::Ui) { }); vsep(ui); ui.vertical(|ui| { - // Objects follow a page that moves in its spread; pages don't move yet, so it's always on. - let mut follow = true; - ui.add_enabled(false, egui::Checkbox::new(&mut follow, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Objects Move with Page")))); let mut overlay = pg.show_parent_overlay; if ui.checkbox(&mut overlay, crate::rtl::widget(ui, crate::i18n::tr(&lang, "Show Parent Page Overlay"))).changed() { let _ = app.run("layout.pageOptions", json!({"pages": [n], "showParentOverlay": overlay})); @@ -1253,9 +1250,11 @@ mod tests { app.run("file.new", serde_json::json!({"width": 252, "height": 144, "facingPages": false})).unwrap(); app.session.set_tool("page"); let mut h = test_window::open(app, vec2(1800.0, 600.0)); - for label in ["Liquid Page Rule:", "Objects Move with Page"] { - let _ = h.get_by_label(label); - } + let _ = h.get_by_label("Liquid Page Rule:"); + // Pages don't move within their spread, so there is no "Objects Move with Page" option, and + // no "Controlled by Parent" rule until parent pages have rules of their own. + assert!(h.query_by_label("Objects Move with Page").is_none()); + assert!(!crate::panels::interactive::LIQUID_RULES.iter().any(|(r, _)| *r == designcraft_doc::LiquidRule::ControlledByParent)); assert_eq!(crate::panels::properties::preset_name(252.0, 144.0), "US Business Card"); h.get_by_label("Show Parent Page Overlay").click(); h.run_steps(4); diff --git a/crates/ui-egui/src/i18n.rs b/crates/ui-egui/src/i18n.rs index b76469c2a..fd625594a 100644 --- a/crates/ui-egui/src/i18n.rs +++ b/crates/ui-egui/src/i18n.rs @@ -3283,16 +3283,6 @@ const TABLE: &[(&str, [&str; 5])] = &[ "Liquid Page Rule:", ["Regel für flüssige Seiten:", "Règle de page liquide :", "Regla de página líquida:", "リキッドページルール:", "自适应页面规则:"], ), - ( - "Objects Move with Page", - [ - "Objekte mit Seite verschieben", - "Déplacer les objets avec la page", - "Los objetos se mueven con la página", - "オブジェクトをページと共に移動", - "对象随页面移动", - ], - ), ( "Show Parent Page Overlay", [ diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 363ed2576..0f165ece8 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -770,7 +770,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "استنادًا إلى الأدلة"), ("Resize Page", "تغيير حجم الصفحة"), ("Liquid Page Rule:", "قاعدة الصفحة المرنة:"), - ("Objects Move with Page", "تحريك الكائنات مع الصفحة"), ("Show Parent Page Overlay", "إظهار تراكب الصفحة الأصل"), ("Controlled by Parent", "تتحكم فيه الصفحة الأصل"), ("US Business Card", "بطاقة عمل أمريكية"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index d1055616f..b595c542b 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -1358,7 +1358,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "Basato su guide"), ("Resize Page", "Ridimensiona pagina"), ("Liquid Page Rule:", "Regola pagina liquida:"), - ("Objects Move with Page", "Sposta oggetti con la pagina"), ("Show Parent Page Overlay", "Mostra sovrapposizione pagina principale"), ("Controlled by Parent", "Controllato dalla pagina principale"), ("US Business Card", "Biglietto da visita USA"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index 78133baf2..978cf8a62 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1478,7 +1478,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "ガイド基準"), ("Resize Page", "ページのサイズを変更"), ("Liquid Page Rule:", "リキッドページルール:"), - ("Objects Move with Page", "オブジェクトをページと共に移動"), ("Show Parent Page Overlay", "親ページオーバーレイを表示"), ("Controlled by Parent", "親ページで制御"), ("US Business Card", "US 名刺"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index 437ac3ed3..08cf8b556 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -762,7 +762,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "Baseado em guias"), ("Resize Page", "Redimensionar página"), ("Liquid Page Rule:", "Regra de página líquida:"), - ("Objects Move with Page", "Objetos se movem com a página"), ("Show Parent Page Overlay", "Mostrar sobreposição da página principal"), ("Controlled by Parent", "Controlado pela página principal"), ("US Business Card", "Cartão de visita EUA"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index 3ac49b5b7..bd8ed7d40 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -1353,7 +1353,6 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Guide-Based", "На основі напрямних"), ("Resize Page", "Змінити розмір сторінки"), ("Liquid Page Rule:", "Правило гнучкої сторінки:"), - ("Objects Move with Page", "Об’єкти рухаються зі сторінкою"), ("Show Parent Page Overlay", "Показати накладення батьківської сторінки"), ("Controlled by Parent", "Керується батьківською сторінкою"), ("US Business Card", "Візитна картка (США)"), diff --git a/crates/ui-egui/src/panels/interactive.rs b/crates/ui-egui/src/panels/interactive.rs index 30822a06b..f967e5128 100644 --- a/crates/ui-egui/src/panels/interactive.rs +++ b/crates/ui-egui/src/panels/interactive.rs @@ -490,13 +490,12 @@ pub fn buttons(app: &mut DesignApp, ui: &mut egui::Ui) { } /// Liquid page rules in InDesign's order, with their menu labels. -pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 6] = [ +pub const LIQUID_RULES: [(designcraft_doc::LiquidRule, &str); 5] = [ (designcraft_doc::LiquidRule::Off, "Off"), (designcraft_doc::LiquidRule::Scale, "Scale"), (designcraft_doc::LiquidRule::ReCenter, "Re-center"), (designcraft_doc::LiquidRule::ObjectBased, "Object-based"), (designcraft_doc::LiquidRule::GuideBased, "Guide-based"), - (designcraft_doc::LiquidRule::ControlledByParent, "Controlled by Parent"), ]; /// A liquid page rule's menu label. diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index d7bcf2327..67c331053 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -405,7 +405,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Type on a Path (⇧T tool, `type.onPath`): a story along any line, curve or shape on screen and in PDF; Type on a Path Options (start, flip, baseline/ascender/descender/centre) and Delete Type from Path. Round-trips through IDML as TextPath; on-path caret drawing is still to come. - Preferences › Appearance of Black: 100% K on screen accurately (dark grey) or as rich black (`window.richBlack`); the printing/export side is still to come. - Spreads of up to 10 pages: Add to Previous Spread and Allow Spread to Shuffle (Pages panel; `layout.pages.toSpread`, `layout.spreadShuffle`); inserted pages go around kept spreads. -- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule including Controlled by Parent, Show Parent Page Overlay); X/Y and Objects Move with Page are shown, but pages don't move within their spread yet. +- Page tool (⇧P) and `layout.pageSize`: pages of their own size (or a preset); the spine stays put and objects keep their place on their page. A click selects a page (handles, its liquid page rule tagged above it); dragging a handle previews the liquid rule and springs back, Alt-drag resizes, Shift keeps proportions, with a W/H readout; `anchor` keeps a reference point in place; double-click for exact sizes. With the Page tool the Control panel shows the page (reference point, W/H, preset, orientation, Liquid Page Rule, Show Parent Page Overlay); X/Y are shown, but pages don't move within their spread yet. - File › Print (⌘P): printer, copies, page range, spreads, marks, bleed; printed as PDF through the system spooler (`file.print`, `file.printers`, `dryRun` for agents). Shortcuts now act like choosing the menu item (dialogs open), and UI commands win shortcut ties (⌘N opens New Document). - Nested styles (through/up to N sentences, words, characters, letters, digits, tabs, breaks, spaces or given characters) and GREP styles (regex → character style), edited in Paragraph Style Options and composed under local formatting; `ui.dialog.open` for agents. Nested line styles are still to come; nested and GREP styles round-trip through IDML. - Table and cell styles (round-trip through IDML): cell styles (fill, insets, vertical justification, strokes, paragraph style) and table styles (region cell styles, border, alternating rows, spacing); apply from the Table panel, edits re-apply to every user; `style.cell.*`, `style.table.*`. From 1c3af71eb79eec14d63fec42ab22f63bd88a322e Mon Sep 17 00:00:00 2001 From: aiongg Date: Wed, 14 Oct 2026 01:41:51 +0000 Subject: [PATCH 8/8] Integration: cargo fmt --- crates/engine/src/cmd/guides.rs | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/cmd/guides.rs b/crates/engine/src/cmd/guides.rs index fa921fd11..92f956a1d 100644 --- a/crates/engine/src/cmd/guides.rs +++ b/crates/engine/src/cmd/guides.rs @@ -133,7 +133,14 @@ fn add(s: &mut Session, p: &Value) -> Result { let mut out = Vec::new(); for (o, pos, pi) in placed { 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: liquid && !spread_guide }); + pg.guides.push(Guide { + orientation: o, + position: pos, + spread: spread_guide, + locked: false, + layer: Some(layer), + liquid: liquid && !spread_guide, + }); out.push(json!({"page": pi, "index": pg.guides.len() - 1})); } match <[Value; 1]>::try_from(out) {