From 333f2a04e31e0bff7eff4739640f0e765e6e14e9 Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 02:52:17 -0400 Subject: [PATCH 1/2] Selection: drag a column guide to move its gutter (#1096) (#1176) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * IDML: keep column guides that were dragged off centre (#1096) InDesign records dragged column guides in `MarginPreference ColumnsPositions`, as pairs of offsets from the margin box's left edge. The importer discarded them and divided the columns evenly, so a page whose gutter the designer had moved came back with it in the middle: on a 450 pt margin box, `0 150 170 450` was read as columns at 215 and 235. `Columns::positions` already existed, documented as "Custom column positions (after dragging column guides)", but nothing set it and nothing read it. Now `Page::column_rects` returns the custom rects when they are set — that one method is what the canvas guides, snapping, threading and the first frame of a placed story all go through, so they follow the dragged gutter — the importer fills it, and the exporter writes the page's real positions instead of recomputing an even split, so the round trip keeps them. `layout.marginsAndColumns` clears them when a count or gutter is set, which is what InDesign's Margins and Columns does. Without that the stale positions would outrank the new value and the dialog would look broken. Verified against InDesign-authored IDML rather than assumed, which settled two things. The attribute is written whether or not anything was dragged — a real page's values are exactly its even split — so an even set is dropped rather than marking every imported page custom and freezing its columns; the tolerance for that comparison only absorbs float noise, about 1e-14 pt on that page. And `` is a `` child, not a `` one, which is where this importer already looks: the reporter's missing ruler guide was 099fbd2, landed the day the ticket was filed, not a second bug. That commit left `lib.rs` still listing guides as ignored on import; corrected. * IDML: test a ruler guide in a column gutter without facing pages (#1096) * View: Lock Guides and Lock Column Guides are commands (#1096) view.lockGuides {on?} (Cmd+Alt+;) sets the Lock Guides UI state the Properties panel and ruler-guide drags use. view.lockColumnGuides {on?} is a document setting, on by default, saved with the document, not an undo step, and read from and written to IDML ColumnGuideLocked. Both are in View > Grids & Guides with checkmarks. * IDML: custom column positions are validated and round-trip (#1096) Columns::positions are used only when they are 2 x count finite edges, ascending from 0 to the margin box width (within 0.01 pt); anything else is the even split, in Page::column_rects, on IDML import (bounded parse, checked against the page's margin box) and on IDML export. Columns::with_gutter_moved moves one gutter's pair of guides between its neighbours for the coming column-guide command. ROADMAP: drop four progress-log lines the #1121 merge duplicated and move its line to the top. * Layout: Margins and Columns keeps dragged columns unless the count or gutter changes (#1096) * Layout: autoflow, inserted pages and new parents follow dragged columns (#1096) * Integration: close #1121's round-trip test beside the ruler-guide test * View: Lock Column Guides is translated wherever Lock Guides is (#1096) * View: Lock Column Guides is translated wherever Lock Guides is (#1096) * Layout: a new margin or page width resets dragged columns (#1096) * IDML: a page without its own margins checks the default's column positions against its width (#1096) * Layout: #1121's margin case follows the new-width rule (#1096) * View: Lock Column Guides is undoable (#1096) * Layout: dragged columns scale with a new margin or page width (#1096) * Layout: dragged columns mirror when a page changes side (#1096) * Layout: the page-width test drags a page whose columns are its own (#1096) * Selection: drag a column guide to move its gutter (#1096) * Layout: pages that follow a one-page parent take its dragged columns mirrored (#1096) * Selection: column-guide drag review fixes (#1096) * ROADMAP: the progress line lands in one commit after the guide PRs --------- Co-authored-by: Hamza --- ROADMAP.md | 8 + crates/engine/src/cmd/layout.rs | 163 ++++++++++++++ crates/engine/src/tooling.rs | 14 ++ crates/ui-egui/src/canvas.rs | 369 +++++++++++++++++++++++++++++++ crates/ui-egui/src/i18n/ar.rs | 1 + crates/ui-egui/src/i18n/cs.rs | 1 + crates/ui-egui/src/i18n/de.rs | 1 + crates/ui-egui/src/i18n/it.rs | 1 + crates/ui-egui/src/i18n/ja.rs | 1 + crates/ui-egui/src/i18n/ka.rs | 1 + crates/ui-egui/src/i18n/pl.rs | 1 + crates/ui-egui/src/i18n/pt_br.rs | 1 + crates/ui-egui/src/i18n/ru.rs | 1 + crates/ui-egui/src/i18n/uk.rs | 1 + docs/target-app-parity.md | 1 + 15 files changed, 565 insertions(+) diff --git a/ROADMAP.md b/ROADMAP.md index 9c4cb465c..f9be21cc9 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -358,6 +358,14 @@ Newest first. - **2026-10-17** · Object strokes reject malformed types and unknown type fields before changing the document; command help lists every stroke kind and its required fields. No readiness percentage change. - **2026-10-17** · Docs: documented a persistent source CLI install with Cargo, MCP-client PATH setup and bare-name server registration in `docs/mcp.md`. No readiness percentage change. +- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. +- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. +- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. +- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. +- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. +- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. +- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. +- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. - **2026-10-16** · Threading: the loaded text cursor threads into a text frame inside a group (it hit the group and showed it could not thread); a plain click still selects the group (#1075). Gap 27 closed. No readiness percentage change. - **2026-10-16** · Greek text hyphenates. The patterns were never compiled in, so Greek set unhyphenated however narrow the column; `"Greek"`, `"Greek: Greece"`, `el` and `el-GR` all resolve now and ανθρωπος breaks αν-θρω-πος. They are the monotonic patterns (hyph-el-monoton, Dimitrios Filippou, under MIT), which is what polytonic text hyphenates by too. Five of InDesign's ~40 hyphenation languages. No readiness percentage change. - **2026-10-16** · A swatch applied to a placed greyscale or bitmap image colours it, as in InDesign: IDML's `` is read and written, greyscale pixels become a tint of the swatch on opaque white and a bitmap's white lets the frame show through, on screen and in PDF (spot swatches print on their plate). Colour images keep their colours. Applying the swatch from the Swatches panel comes next. No readiness percentage change. diff --git a/crates/engine/src/cmd/layout.rs b/crates/engine/src/cmd/layout.rs index 9016c3848..047bd90ba 100644 --- a/crates/engine/src/cmd/layout.rs +++ b/crates/engine/src/cmd/layout.rs @@ -430,6 +430,15 @@ pub fn specs() -> Vec { }) } ), + cmd!( + "layout.moveColumnGuide", + "Move Column Guide", + [], + None, + "{spread?, parent? (a parent's prefix, label or index: its spread instead of `spread`), page (index in the spread), gutter (0 = between the first two columns), position (the gutter's new left edge, spread x)} → {positions (each column's left and right edge from the margin box's left edge), left (where the gutter's left edge went, spread x)} — both guides of the gutter move, keeping its width, and stop at the neighbouring guides and the margins; a document page takes the result as its own columns, a parent page passes it to the pages that follow it; refused while column guides are locked", + column_guides_unlocked, + move_column_guide + ), cmd!( "layout.documentSetup", "Document Setup…", @@ -870,6 +879,52 @@ pub fn specs() -> Vec { ] } +/// Column guides can be dragged: a document is open and View › Grids & Guides › Lock Column +/// Guides is off. +fn column_guides_unlocked(s: &Session) -> std::result::Result<(), String> { + has_doc(s)?; + match s.active() { + Some(st) if st.doc.settings.column_guides_locked => Err("column guides are locked (View › Grids & Guides › Lock Column Guides)".into()), + _ => Ok(()), + } +} + +/// A column guide dragged with the Selection tool: gutter `gutter` of a page moves to `position`. +fn move_column_guide(s: &mut Session, p: &Value) -> Result { + const CMD: &str = "layout.moveColumnGuide"; + let index = |key: &str| -> Result { + let v = p.get(key).ok_or_else(|| bad(CMD, format!("missing `{key}`")))?; + v.as_u64().and_then(|n| usize::try_from(n).ok()).ok_or_else(|| bad(CMD, format!("`{key}` must be a non-negative integer"))) + }; + let (page, gutter) = (index("page")?, index("gutter")?); + let position = + p.get("position").and_then(Value::as_f64).filter(|v| v.is_finite()).ok_or_else(|| bad(CMD, "`position` must be a finite number"))?; + let r = match p.get("parent") { + None | Some(Value::Null) => super::spread_param(p, CMD)?, + Some(v) => designcraft_doc::SpreadRef::Parent(parent_spread_index(&s.doc()?.doc, v).ok_or_else(|| bad(CMD, "no such parent"))?), + }; + s.edit(|d, _| { + if d.settings.column_guides_locked { + return Err(bad(CMD, "column guides are locked")); + } + let pg = d.spread_mut(r).ok_or_else(|| bad(CMD, "no such spread"))?.pages.get_mut(page).ok_or_else(|| bad(CMD, "no such page"))?; + let m = pg.margin_rect(); + let count = pg.columns.count; + if gutter.checked_add(1).is_none_or(|g| g >= count as usize) { + return Err(bad(CMD, format!("the page has {} gutters", count.saturating_sub(1)))); + } + let positions = + pg.columns.with_gutter_moved(m.width(), gutter, position - m.x0).ok_or_else(|| bad(CMD, "the columns cannot take that position"))?; + let left = positions.get(gutter * 2 + 1).map_or(position, |v| m.x0 + v); + pg.columns.positions = Some(positions.clone()); + match r { + designcraft_doc::SpreadRef::Doc(_) => pg.local_margins = true, + designcraft_doc::SpreadRef::Parent(_) => d.sync_parent_margins(), + } + Ok(json!({"positions": positions, "left": left})) + }) +} + /// A parent spread named by prefix, label (`A-Parent`) or index. fn parent_spread_index(d: &designcraft_doc::Document, v: &Value) -> Option { match v { @@ -2227,6 +2282,114 @@ mod parent_tests { assert_eq!((page["columns"].clone(), page["customColumns"].clone()), (json!(2), json!(true)), "{page}"); assert_eq!(page["columnRects"][0], json!([36.0, 36.0, 136.0, 756.0]), "{page}"); } + + fn columns_of(s: &crate::Session, abs: usize) -> (Option>, bool) { + let pg = s.doc().unwrap().doc.page(abs).unwrap().clone(); + (pg.columns.positions, pg.local_margins) + } + + /// Three columns across a 540 pt margin box (36 pt margins), column guides unlocked. + fn three_columns(pages: u64) -> crate::Session { + let mut s = crate::Session::new(); + s.execute("file.new", &json!({"pages": pages})).unwrap(); + s.execute("layout.marginsAndColumns", &json!({"parent": "A", "columns": 3, "gutter": 12})).unwrap(); + s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); + s + } + + /// Dragging a column guide moves its gutter's two guides together, stops at the neighbouring + /// gutter and the margin, makes a following page's columns its own, and undoes in one step. + #[test] + fn move_column_guide_moves_the_gutter_pair_within_its_neighbours() { + let mut s = three_columns(1); + assert_eq!(columns_of(&s, 0), (None, false), "the page follows A"); + let mv = + |s: &mut crate::Session, gutter: u64, x: f64| s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": gutter, "position": x})); + let r = mv(&mut s, 0, 236.0).unwrap(); + assert_eq!(r, json!({"positions": [0.0, 200.0, 212.0, 356.0, 368.0, 540.0], "left": 236.0})); + assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); + let inspect = s.execute("document.inspect", &json!({})).unwrap(); + assert_eq!(inspect["spreads"][0]["pages"][0]["columnRects"][0], json!([36.0, 36.0, 236.0, 756.0])); + // Past the next gutter: it stops there, and the middle column has no width. + assert_eq!(mv(&mut s, 0, 1000.0).unwrap()["left"], json!(36.0 + 344.0)); + // Past the right margin: the last gutter stops 12 pt (its width) inside it. + assert_eq!(mv(&mut s, 1, 1e12).unwrap()["positions"], json!([0.0, 344.0, 356.0, 528.0, 540.0, 540.0])); + // Past the left margin. + assert_eq!(mv(&mut s, 0, -1e12).unwrap()["positions"], json!([0.0, 0.0, 12.0, 528.0, 540.0, 540.0])); + for _ in 0..3 { + s.execute("edit.undo", &json!({})).unwrap(); + } + assert_eq!(columns_of(&s, 0), (Some(vec![0.0, 200.0, 212.0, 356.0, 368.0, 540.0]), true)); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(columns_of(&s, 0), (None, false), "one undo step per drag"); + } + + /// On a parent page the drag changes the parent, and the document pages that follow it get + /// the new columns; a page with columns of its own keeps them. + #[test] + fn move_column_guide_on_a_parent_reaches_the_pages_that_follow_it() { + let mut s = three_columns(3); + // Page 1, the left page of spread 1, gets columns of its own. + let x = s.doc().unwrap().doc.spreads[1].pages[0].margin_rect().x0 + 300.0; + s.execute("layout.moveColumnGuide", &json!({"spread": 1, "page": 0, "gutter": 1, "position": x})).unwrap(); + let own = columns_of(&s, 1); + assert!(own.1 && own.0.is_some()); + let d = &s.doc().unwrap().doc; + // A's right page: the one page 0 (a right page) and page 2 follow. + let x0 = d.parents[0].pages[1].margin_rect().x0; + s.execute("layout.moveColumnGuide", &json!({"parent": "A", "page": 1, "gutter": 0, "position": x0 + 100.0})).unwrap(); + let moved = vec![0.0, 100.0, 112.0, 356.0, 368.0, 540.0]; + assert_eq!(s.doc().unwrap().doc.parents[0].pages[1].columns.positions, Some(moved.clone())); + assert_eq!(columns_of(&s, 0), (Some(moved.clone()), false)); + assert_eq!(columns_of(&s, 2), (Some(moved), false)); + assert_eq!(columns_of(&s, 1), own, "page 1's columns are its own"); + // The same parent page by spread reference. + let spread = json!({"kind": "parent", "index": 0}); + s.execute("layout.moveColumnGuide", &json!({"spread": spread, "page": 1, "gutter": 0, "position": x0 + 50.0})).unwrap(); + assert_eq!(columns_of(&s, 0).0.unwrap()[1], 50.0); + } + + /// A drag's preview in one document is put back there when another document became active. + #[test] + fn a_column_guide_preview_is_cancelled_in_its_own_document() { + let mut s = three_columns(1); + let uid = s.doc().unwrap().uid; + s.begin_interaction("Move Column Guide").unwrap(); + s.preview("layout.moveColumnGuide", json!({"page": 0, "gutter": 0, "position": 236})).unwrap(); + s.execute("file.new", &json!({})).unwrap(); + s.cancel_interaction_of(uid); + s.set_active(0); + assert!(s.doc().unwrap().interaction.is_none()); + assert_eq!(columns_of(&s, 0), (None, false)); + } + + #[test] + fn move_column_guide_refuses_while_locked_and_hostile_params() { + let mut s = three_columns(1); + s.execute("view.lockColumnGuides", &json!({"on": true})).unwrap(); + assert!(s.execute("layout.moveColumnGuide", &json!({"page": 0, "gutter": 0, "position": 200})).is_err()); + assert_eq!(columns_of(&s, 0), (None, false)); + s.execute("view.lockColumnGuides", &json!({"on": false})).unwrap(); + for p in [ + json!({"page": 0, "gutter": 0}), + json!({"page": 0, "gutter": 0, "position": f64::NAN}), + json!({"page": 0, "gutter": 0, "position": "200"}), + json!({"page": 0, "gutter": 2, "position": 200}), + json!({"page": 0, "gutter": u64::MAX, "position": 200}), + json!({"page": 0, "gutter": -1, "position": 200}), + json!({"page": 0, "gutter": 0.5, "position": 200}), + json!({"page": 9, "gutter": 0, "position": 200}), + json!({"page": u64::MAX, "gutter": 0, "position": 200}), + json!({"gutter": 0, "position": 200}), + json!({"spread": 99, "page": 0, "gutter": 0, "position": 200}), + json!({"parent": "Z", "page": 0, "gutter": 0, "position": 200}), + json!({"page": 0, "gutter": 0, "position": f64::MAX}), + ] { + let r = s.execute("layout.moveColumnGuide", &p); + // A huge but finite position is clamped to the margin; everything else is refused. + assert!(r.is_err() || p["position"] == json!(f64::MAX), "{p}: {r:?}"); + } + } } #[cfg(test)] diff --git a/crates/engine/src/tooling.rs b/crates/engine/src/tooling.rs index c58236090..65d069bac 100644 --- a/crates/engine/src/tooling.rs +++ b/crates/engine/src/tooling.rs @@ -331,4 +331,18 @@ impl Session { } } } + + /// Cancel the interaction of the document with `uid`, active or not: a gesture that began in + /// one document and lost it to another tab ends there, leaving the document as it began. + pub fn cancel_interaction_of(&mut self, uid: u64) { + if self.active().is_some_and(|st| st.uid == uid) { + self.cancel_interaction(); + } else if let Some(st) = self.docs.iter_mut().find(|st| st.uid == uid) + && let Some(it) = st.interaction.take() + { + st.doc = it.doc; + st.selection = it.selection; + st.revision = it.revision; + } + } } diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index f8ddc9487..9868d4d8b 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -623,6 +623,19 @@ pub fn show(app: &mut DesignApp, ui: &mut egui::Ui) { } else { app.session.cursor(xf.to_canvas(p), ui.input(|i| mods(i, false)), app.view_info()) }; + // A column guide that a press would drag, or one being dragged: a resize cursor across it. + let dragging = ui.data(|d| d.get_temp::(egui::Id::new((COLUMN_DRAG, app.pane)))).map(|g| g.spread); + let column = dragging.or_else(|| { + let menu_open = context_menu_open(ui.ctx()); + (!space && !zoom && !middle && !alt_hand && !tool_drag && !menu_open && xf.rot == 0) + .then(|| column_guide_at(app, &xf, p).map(|h| h.0)) + .flatten() + }); + let c = match column { + Some(r) if spread_x_across(app, r) => Cursor::ResizeH, + Some(_) => Cursor::ResizeV, + None => c, + }; let icon = crate::cursors::show(ui.ctx(), &mut app.cursors, c, p); ui.ctx().set_cursor_icon(icon); draw_cursor_badge(&painter, p, c, &t); @@ -2393,6 +2406,11 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r // Space-drag = hand (unless a tool drag is under way: then Space is a modifier, e.g. Live // Distribute while resizing). Middle-drag pans too, with any tool; tools only // see the primary button. + // A column guide drag keeps the pointer until the button is up, whatever Space or a menu does + // meanwhile. + if ui.data(|d| d.get_temp::(egui::Id::new((COLUMN_DRAG, app.pane)))).is_some() && column_guide_drag(app, ui, resp, rect, &xf) { + return; + } let tool_drag: bool = ui.data(|d| d.get_temp(egui::Id::new(("canvas_pointer_down", app.pane)))).unwrap_or(false); let hand = space && !ui.input(|i| i.modifiers.command); if !tool_drag && ((hand && resp.dragged()) || resp.dragged_by(egui::PointerButton::Middle)) { @@ -2428,6 +2446,9 @@ fn handle_input(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, r if !space && !menu_open && xf.rot == 0 && guide_drag(app, ui, resp, rect, &xf) { return; } + if !space && !menu_open && xf.rot == 0 && column_guide_drag(app, ui, resp, rect, &xf) { + return; + } let m = ui.input(|i| mods(i, space)); let vi = app.view_info(); let pos = |p: Pos2| xf.to_canvas(p); @@ -3263,6 +3284,222 @@ fn moving_guides(app: &DesignApp, xf: &Xf, rect: Rect, g: &GuideDrag, press: Gui Some(MovingGuides { guides, dx, dy }) } +/// An object is under screen point `p` where the Selection tool would pick it (a document +/// spread's, or a parent spread's while editing parents): it takes the press before the guides +/// behind it. +fn over_item(app: &DesignApp, xf: &Xf, p: Pos2) -> bool { + let Some(st) = app.session.active() else { return false }; + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let cx = designcraft_tools::ToolContext { + doc: &st.doc, + selection: &st.selection, + cache: &app.session.cache, + layout: &layout, + zoom: xf.zoom.max(1e-6), + layer: st.active_layer, + snap: app.ui.snap_view(), + unit: app.ui.units, + stroke_in_front: false, + }; + cx.hit(xf.to_canvas(p)).is_some() +} + +const COLUMN_DRAG: &str = "canvas_column_drag"; + +/// A column guide being dragged with a Selection tool: its gutter follows the pointer. +#[derive(Clone, Debug)] +struct ColumnDrag { + /// The document the drag's interaction belongs to. + doc: u64, + spread: SpreadRef, + page: usize, + gutter: usize, + /// The pointer's spread x minus the gutter's left edge at the press: the gutter keeps that + /// distance from the pointer. + grab: f64, + /// The left edge last previewed. + last: Option, +} + +/// The interior column guide under screen point `p` (within 3 px) that a press there would drag, +/// as (spread, page, gutter, the gutter's left edge in spread x): a Selection tool, guides shown +/// (not in Preview or Presentation), column guides unlocked, and no object or ruler guide that +/// takes the press there. +fn column_guide_at(app: &DesignApp, xf: &Xf, p: Pos2) -> Option<(SpreadRef, usize, usize, f64)> { + let preview = matches!(app.ui.screen_mode, ScreenMode::Preview | ScreenMode::Presentation); + if !app.ui.guides || preview || !matches!(app.session.tool_id(), "selection" | "directSelection") { + return None; + } + let st = app.session.active()?; + if st.doc.settings.column_guides_locked { + return None; + } + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let (r, sp) = layout.spread_at(xf.to_canvas(p))?; + let tol = 3.0 / xf.zoom.max(1e-9); + let hit = st.doc.spread(r)?.pages.iter().enumerate().find_map(|(pi, pg)| { + let m = pg.margin_rect(); + if sp.y < m.y0 - tol || sp.y > m.y1 + tol { + return None; + } + pg.column_rects().windows(2).enumerate().find_map(|(gi, w)| match w { + // Columns that cannot move (gutters wider than the margin box) offer no guide. + [a, b] + if ((sp.x - a.x1).abs() <= tol || (sp.x - b.x0).abs() <= tol) + && pg.columns.with_gutter_moved(m.width(), gi, a.x1 - m.x0).is_some() => + { + Some((r, pi, gi, a.x1)) + } + _ => None, + }) + })?; + // A ruler guide there takes the press first (see `guide_drag`). + let ruler_guide = !app.ui.guides_locked && guide_at(app, xf, p).is_some(); + (!ruler_guide && !over_item(app, xf, p)).then_some(hit) +} + +/// Whether spread `r`'s x axis runs across the screen (the spread view is not turned a quarter). +fn spread_x_across(app: &DesignApp, r: SpreadRef) -> bool { + app.session.active().is_none_or(|st| { + let [a, b, ..] = CanvasLayout::new(&st.doc, st.editing_parents).xf(r).as_coeffs(); + a.abs() >= b.abs() + }) +} + +/// Canvas x `x` moved to the nearest tick mark of the horizontal ruler (its finest increment). +fn ruler_tick_x(app: &DesignApp, doc: &Document, layout: &CanvasLayout, zoom: f64, x: f64) -> Option { + let pieces = ruler_pieces(app, doc, layout, doc.zero_point()); + let &(_, _, o) = pieces.iter().find(|(from, to, _)| *from <= x && x < *to).or(pieces.first())?; + let (major, sub) = doc.settings.horizontal_units.ruler_ticks(zoom); + let step = major / f64::from(sub.max(1)); + (step.is_finite() && step > 0.0).then(|| o.x + ((x - o.x) / step).round() * step).filter(|v| v.is_finite()) +} + +/// Where a dragged gutter's left edge goes for spread x `want`: Shift snaps it to the ruler's tick +/// marks; otherwise its edges snap to the document grid, guides and objects as those snaps are +/// set. Measured against the document as it was when the drag began. +fn snap_gutter(app: &DesignApp, g: &ColumnDrag, want: f64, shift: bool, zoom: f64) -> f64 { + let Some(st) = app.session.active() else { return want }; + let doc = st.interaction.as_ref().map_or_else(|| st.doc.clone(), |i| i.doc.clone()); + let layout = CanvasLayout::new(&doc, st.editing_parents); + let Some(slot) = layout.slots.iter().find(|s| s.spread == g.spread) else { return want }; + let Some(pg) = doc.spread(g.spread).and_then(|sp| sp.pages.get(g.page)) else { return want }; + let m = pg.margin_rect(); + if shift { + if !spread_x_across(app, g.spread) { + return want; + } + let c = slot.to_canvas(Point::new(want, m.y0)); + return ruler_tick_x(app, &doc, &layout, zoom, c.x).map_or(want, |x| slot.to_spread(Point::new(x, c.y)).x); + } + let cols = pg.column_rects(); + let width = match (cols.get(g.gutter), cols.get(g.gutter + 1)) { + (Some(a), Some(b)) => (b.x0 - a.x1).max(0.0), + _ => 0.0, + }; + let cx = designcraft_tools::ToolContext { + doc: &doc, + selection: &st.selection, + cache: &app.session.cache, + layout: &layout, + zoom: zoom.max(1e-6), + layer: st.active_layer, + // A line has no size or spacing to match. + snap: designcraft_tools::SnapView { smart_dimensions: false, smart_spacing: false, ..app.ui.snap_view() }, + unit: app.ui.units, + stroke_in_front: false, + }; + let rect = DRect::new(want, m.y0, want + width, m.y1); + let s = designcraft_tools::snap::snap( + &cx, + designcraft_tools::SnapRequest { + spread: g.spread, + gesture: designcraft_tools::Gesture::Move, + rect, + x_edges: [true, false, true], + y_edges: [false; 3], + exclude: &[], + exclude_guides: &[], + copying: false, + lengths: [None, None], + angle: None, + radius: 0.0, + pointer: Point::new(want, m.center().y), + }, + ); + let x = want + s.delta.x; + if x.is_finite() { x } else { want } +} + +/// Column guides: with a Selection tool, drag an interior column guide (not over an object) to +/// move its gutter, both guides together; Escape puts it back. One undo step per drag. Returns +/// true while it owns the pointer. +fn column_guide_drag(app: &mut DesignApp, ui: &mut egui::Ui, resp: &egui::Response, rect: Rect, xf: &Xf) -> bool { + let id = egui::Id::new((COLUMN_DRAG, app.pane)); + let (pressed, released, origin, latest, shift, escape) = ui.input(|i| { + ( + i.pointer.primary_pressed(), + // A release this frame, or one missed while something else had the pointer. + i.pointer.primary_released() || !i.pointer.primary_down(), + i.pointer.press_origin(), + i.pointer.latest_pos(), + i.modifiers.shift, + i.key_pressed(egui::Key::Escape), + ) + }); + let mut drag: Option = ui.data(|d| d.get_temp(id)); + if drag.is_none() + && pressed + && resp.hovered() + && let Some(o) = origin.filter(|o| rect.contains(*o)) + && let Some((spread, page, gutter, left)) = column_guide_at(app, xf, o) + // Another gesture or a dialog's preview owns the document until it ends. + && let Some((doc, slot_x)) = app + .session + .active() + .filter(|st| st.interaction.is_none()) + .map(|st| (st.uid, CanvasLayout::new(&st.doc, st.editing_parents).to_spread(spread, xf.to_canvas(o)).x)) + && app.session.begin_interaction("Move Column Guide").is_ok() + { + drag = Some(ColumnDrag { doc, spread, page, gutter, grab: slot_x - left, last: None }); + } + let Some(mut g) = drag else { return false }; + // Another document became active: the drag ends in its own, as it began. + if app.session.active().is_none_or(|st| st.uid != g.doc) { + app.session.cancel_interaction_of(g.doc); + ui.data_mut(|d| d.remove::(id)); + return false; + } + if escape { + app.session.cancel_interaction(); + ui.data_mut(|d| d.remove::(id)); + return true; + } + if let Some(p) = latest + && let Some(x) = app.session.active().map(|st| CanvasLayout::new(&st.doc, st.editing_parents).to_spread(g.spread, xf.to_canvas(p)).x) + && x.is_finite() + { + let want = snap_gutter(app, &g, x - g.grab, shift, xf.zoom); + if g.last != Some(want) && (g.last.is_some() || (p - origin.unwrap_or(p)).length() > 0.0) { + let params = json!({"spread": g.spread, "page": g.page, "gutter": g.gutter, "position": want}); + if let Err(e) = app.session.preview("layout.moveColumnGuide", params) { + app.status(e.to_string()); + } + g.last = Some(want); + } + } + if released { + ui.data_mut(|d| d.remove::(id)); + if let Err(e) = app.session.commit_interaction() { + app.status(e.to_string()); + } + app.after_engine(); + return true; + } + ui.data_mut(|d| d.insert_temp(id, g)); + true +} + /// Presses in a row on the canvas: the last press's count, and where and when it was released /// (`at` is −∞ while the button is down). #[derive(Clone, Copy, Debug)] @@ -3498,6 +3735,138 @@ mod tests { assert!(!g[1].liquid); } + /// Dragging a column guide with the Selection tool moves its gutter (one undo step); with + /// column guides locked the same drag leaves the columns alone. + #[test] + fn the_selection_tool_drags_a_column_guide() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({})).unwrap(); + // Two columns across the 540 pt margin box: the gutter's left edge is at x = 300. + app.run("layout.marginsAndColumns", json!({"parent": "A", "columns": 2, "gutter": 12})).unwrap(); + let mut h = harness(app); + let page = |h: &Harness<'_, DesignApp>| h.state().session.doc().unwrap().doc.spreads[0].pages[0].clone(); + let screen = |h: &Harness<'_, DesignApp>, x: f64| { + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let layout = CanvasLayout::new(&app.session.active().unwrap().doc, false); + let slot = layout.slots.iter().find(|s| s.spread == SpreadRef::Doc(0)).unwrap(); + (xf.to_screen(slot.xf * Point::new(x, 400.0)), xf.zoom as f32) + }; + let (at, zoom) = screen(&h, 300.0); + drag(&mut h, at, at + vec2(72.0 * zoom, 0.0)); + assert_eq!(page(&h).columns.positions, None, "column guides are locked in a new document"); + h.hover_at(at); + h.run_steps(2); + assert_ne!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + + h.state_mut().run("view.lockColumnGuides", json!({"on": false})).unwrap(); + h.run_steps(2); + let (at, zoom) = screen(&h, 300.0); + drag(&mut h, at, at + vec2(72.0 * zoom, 0.0)); + let pg = page(&h); + let cols = pg.column_rects(); + assert!((cols[0].x1 - 372.0).abs() < 1.0 && (cols[1].x0 - cols[0].x1 - 12.0).abs() < 1e-9, "{cols:?}"); + assert!(pg.local_margins, "the dragged page's columns are its own"); + // Over the moved guide: a resize cursor. + h.hover_at(screen(&h, 372.0).0); + h.run_steps(2); + assert_eq!(h.output().platform_output.cursor_icon, egui::CursorIcon::ResizeHorizontal); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(page(&h).columns.positions, None, "one undo step"); + + // Escape during the drag puts the gutter back. + h.hover_at(at); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.hover_at(at + vec2(40.0, 0.0)); + h.step(); + assert!(page(&h).columns.positions.is_some(), "the drag previews"); + h.event(egui::Event::Key { key: egui::Key::Escape, physical_key: None, pressed: true, repeat: false, modifiers: Default::default() }); + h.step(); + h.event(egui::Event::PointerButton { + pos: at + vec2(40.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.run_steps(3); + assert_eq!(page(&h).columns.positions, None); + assert!(!page(&h).local_margins); + + // Space pressed during the drag and the button released under it: the drag ends with the + // release and the gutter stops following the pointer. + let key = |pressed| egui::Event::Key { key: egui::Key::Space, physical_key: None, pressed, repeat: false, modifiers: Default::default() }; + h.hover_at(at); + h.step(); + h.event(egui::Event::PointerButton { pos: at, button: egui::PointerButton::Primary, pressed: true, modifiers: Default::default() }); + h.step(); + h.hover_at(at + vec2(40.0, 0.0)); + h.step(); + h.event(key(true)); + h.step(); + h.event(egui::Event::PointerButton { + pos: at + vec2(40.0, 0.0), + button: egui::PointerButton::Primary, + pressed: false, + modifiers: Default::default(), + }); + h.step(); + h.event(key(false)); + h.run_steps(2); + let moved = page(&h).columns.positions; + assert!(moved.is_some()); + h.hover_at(at + vec2(120.0, 0.0)); + h.run_steps(3); + assert_eq!(page(&h).columns.positions, moved, "the gutter stays where the drag ended"); + assert!(h.state().session.active().unwrap().interaction.is_none()); + h.state_mut().run("edit.undo", json!({})).unwrap(); + assert_eq!(page(&h).columns.positions, None, "the drag was one undo step"); + } + + /// A column guide is not picked where the screen mode hides guides, where an object on the + /// parent being edited lies over it, or where its columns cannot move. + #[test] + fn column_guides_are_picked_only_where_a_drag_can_move_them() { + let mut app = DesignApp::new(designcraft_engine::Session::new(), crate::Services::default()); + app.run("file.new", json!({"pages": 1})).unwrap(); + app.run("layout.marginsAndColumns", json!({"parent": "A", "columns": 2, "gutter": 12})).unwrap(); + app.run("view.lockColumnGuides", json!({"on": false})).unwrap(); + let mut h = harness(app); + let hit = |h: &Harness<'_, DesignApp>, r: SpreadRef, x: f64| { + let app = h.state(); + let xf = Xf::new(app.canvas_rect.unwrap(), app.view().unwrap()); + let st = app.session.active().unwrap(); + let layout = CanvasLayout::new(&st.doc, st.editing_parents); + let slot = layout.slots.iter().find(|s| s.spread == r).unwrap(); + column_guide_at(app, &xf, xf.to_screen(slot.xf * Point::new(x, 400.0))) + }; + assert!(hit(&h, SpreadRef::Doc(0), 300.0).is_some()); + h.state_mut().ui.screen_mode = ScreenMode::Preview; + assert!(hit(&h, SpreadRef::Doc(0), 300.0).is_none(), "Preview hides the guides"); + h.state_mut().ui.screen_mode = ScreenMode::Normal; + + // Editing parent A: its right page (the one page 0 follows) and a frame over its gutter. + h.state_mut().run("layout.parents.edit", json!({"on": true, "parent": "A-Parent"})).unwrap(); + h.run_steps(2); + let parent = SpreadRef::Parent(0); + let x = h.state().session.active().unwrap().doc.parents[0].pages[1].column_rects()[0].x1; + assert!(hit(&h, parent, x).is_some()); + h.state_mut() + .run("frame.create", json!({"spread": {"kind": "parent", "index": 0}, "rect": [x - 20.0, 300.0, x + 20.0, 500.0], "content": "text"})) + .unwrap(); + h.run_steps(2); + assert!(hit(&h, parent, x).is_none(), "the parent's frame takes the press"); + h.state_mut().run("layout.parents.edit", json!({"on": false})).unwrap(); + h.run_steps(2); + + // Gutters wider than the margin box: the even split cannot be moved. + h.state_mut().run("layout.marginsAndColumns", json!({"pages": [0], "columns": 2, "gutter": 1000})).unwrap(); + h.run_steps(2); + let x = h.state().session.active().unwrap().doc.spreads[0].pages[0].column_rects()[0].x1; + assert!(hit(&h, SpreadRef::Doc(0), x).is_none()); + } + fn right_click(h: &mut Harness<'_, DesignApp>, p: Pos2) { h.hover_at(p); h.step(); diff --git a/crates/ui-egui/src/i18n/ar.rs b/crates/ui-egui/src/i18n/ar.rs index 587ed775b..31f7fb70d 100644 --- a/crates/ui-egui/src/i18n/ar.rs +++ b/crates/ui-egui/src/i18n/ar.rs @@ -1606,6 +1606,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "تحريك نقاط الارتساء"), ("Move Column", "نقل عمود"), ("Move Guide", "تحريك دليل"), + ("Move Column Guide", "تحريك دليل العمود"), ("Move Layer", "نقل طبقة"), ("Move Pages…", "نقل صفحات…"), ("Move to Layer", "نقل إلى طبقة"), diff --git a/crates/ui-egui/src/i18n/cs.rs b/crates/ui-egui/src/i18n/cs.rs index c052b8f33..4d41c30dc 100644 --- a/crates/ui-egui/src/i18n/cs.rs +++ b/crates/ui-egui/src/i18n/cs.rs @@ -775,6 +775,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Přesunout kotvy"), ("Move Column", "Přesunout sloupec"), ("Move Guide", "Přesunout vodítko"), + ("Move Column Guide", "Přesunout vodítko sloupce"), ("Move Layer", "Přesunout vrstvu"), ("Move Pages…", "Přesunout stránky…"), ("Move to Layer", "Přesunout do vrstvy"), diff --git a/crates/ui-egui/src/i18n/de.rs b/crates/ui-egui/src/i18n/de.rs index 7065c6f33..d47657b09 100644 --- a/crates/ui-egui/src/i18n/de.rs +++ b/crates/ui-egui/src/i18n/de.rs @@ -832,6 +832,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Anker verschieben"), ("Move Column", "Spalte verschieben"), ("Move Guide", "Hilfslinie verschieben"), + ("Move Column Guide", "Spaltenhilfslinie verschieben"), ("Move Layer", "Ebene verschieben"), ("Move Pages…", "Seiten verschieben …"), ("Move to Layer", "Auf Ebene verschieben"), diff --git a/crates/ui-egui/src/i18n/it.rs b/crates/ui-egui/src/i18n/it.rs index 8199e2ebb..755272fe5 100644 --- a/crates/ui-egui/src/i18n/it.rs +++ b/crates/ui-egui/src/i18n/it.rs @@ -905,6 +905,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Sposta ancoraggi"), ("Move Column", "Sposta colonna"), ("Move Guide", "Sposta guida"), + ("Move Column Guide", "Sposta guida colonna"), ("Move Layer", "Sposta livello"), ("Move Pages…", "Sposta pagine…"), ("Move to Layer", "Sposta su livello"), diff --git a/crates/ui-egui/src/i18n/ja.rs b/crates/ui-egui/src/i18n/ja.rs index e79f4ba06..e23ef15c7 100644 --- a/crates/ui-egui/src/i18n/ja.rs +++ b/crates/ui-egui/src/i18n/ja.rs @@ -1020,6 +1020,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "アンカーポイントを移動"), ("Move Column", "列を移動"), ("Move Guide", "ガイドを移動"), + ("Move Column Guide", "列ガイドを移動"), ("Move Layer", "レイヤーを移動"), ("Move Pages…", "ページを移動…"), ("Move to Layer", "レイヤーへ移動"), diff --git a/crates/ui-egui/src/i18n/ka.rs b/crates/ui-egui/src/i18n/ka.rs index c8405c16e..0300b62d7 100644 --- a/crates/ui-egui/src/i18n/ka.rs +++ b/crates/ui-egui/src/i18n/ka.rs @@ -837,6 +837,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "საყრდენი წერტილების გადაადგილება"), ("Move Column", "სვეტის გადატანა"), ("Move Guide", "დამხმარე ხაზის გადაადგილება"), + ("Move Column Guide", "სვეტის დამხმარე ხაზის გადაადგილება"), ("Move Layer", "შრის გადაადგილება"), ("Move Pages…", "გვერდების გადატანა…"), ("Move to Layer", "შრეზე გადატანა"), diff --git a/crates/ui-egui/src/i18n/pl.rs b/crates/ui-egui/src/i18n/pl.rs index 51bb7fc5c..7d1d12c67 100644 --- a/crates/ui-egui/src/i18n/pl.rs +++ b/crates/ui-egui/src/i18n/pl.rs @@ -788,6 +788,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Przesuń kotwice"), ("Move Column", "Przesuń kolumnę"), ("Move Guide", "Przesuń linię pomocniczą"), + ("Move Column Guide", "Przesuń linię pomocniczą łamu"), ("Move Layer", "Przesuń warstwę"), ("Move Pages…", "Przesuń strony…"), ("Move to Layer", "Przenieś na warstwę"), diff --git a/crates/ui-egui/src/i18n/pt_br.rs b/crates/ui-egui/src/i18n/pt_br.rs index bb3d21831..f552e9a63 100644 --- a/crates/ui-egui/src/i18n/pt_br.rs +++ b/crates/ui-egui/src/i18n/pt_br.rs @@ -1733,6 +1733,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Mover âncoras"), ("Move Column", "Mover coluna"), ("Move Guide", "Mover guia"), + ("Move Column Guide", "Mover guia de coluna"), ("Move Layer", "Mover camada"), ("Move to Layer", "Mover para a camada"), ("New Article", "Novo artigo"), diff --git a/crates/ui-egui/src/i18n/ru.rs b/crates/ui-egui/src/i18n/ru.rs index 27ce1fed3..3f6ef1be0 100644 --- a/crates/ui-egui/src/i18n/ru.rs +++ b/crates/ui-egui/src/i18n/ru.rs @@ -762,6 +762,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Переместить опорные точки"), ("Move Column", "Переместить столбец"), ("Move Guide", "Переместить направляющую"), + ("Move Column Guide", "Переместить направляющую колонки"), ("Move Layer", "Переместить слой"), ("Move Pages…", "Переместить страницы…"), ("Move to Layer", "Переместить на слой"), diff --git a/crates/ui-egui/src/i18n/uk.rs b/crates/ui-egui/src/i18n/uk.rs index d7ac7ce01..9c7cc855a 100644 --- a/crates/ui-egui/src/i18n/uk.rs +++ b/crates/ui-egui/src/i18n/uk.rs @@ -908,6 +908,7 @@ pub(super) const TABLE: &[(&str, &str)] = &[ ("Move Anchors", "Перемістити опорні точки"), ("Move Column", "Перемістити стовпець"), ("Move Guide", "Перемістити напрямну"), + ("Move Column Guide", "Перемістити напрямну колонки"), ("Move Layer", "Перемістити шар"), ("Move Pages…", "Перемістити сторінки…"), ("Move to Layer", "Перемістити до шару"), diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index f61acacba..c7e0678d7 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -284,6 +284,7 @@ It is evidence of breadth: each item exists. It is not a claim of InDesign-level - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. - Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects with the Selection tools (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), drawn highlighted, moved together by dragging or the arrow keys, deleted with Delete, and placed exactly with the X or Y field of the Control and Properties panels; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Column guides: with the Selection tool, drag an interior column guide to move its gutter (both guides together, keeping its width, stopped by the neighbouring guides and the margins), snapping to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels; one undo step. Refused while Lock Column Guides is on (the default). A document page takes the result as its own columns; on a parent page it reaches the pages that follow it. Objects stay where they are (there is no persistent Layout Adjustment setting yet). `layout.moveColumnGuide`. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Linked text files: `file.place {link: true}` or Preferences › File Handling › Create Links When Placing Text Files keeps a placed .docx/.rtf/.txt story linked to its file (path, size and hash, and the style map and conflict option used). The Links panel shows it OK / Modified / Missing; Update imports the file again into the same frames and thread with the saved style map, replacing edits made in the layout (the Links panel and menu ask first; the `links.update` command updates and returns a warning; undoable); Relink, Go To and Unlink. Saved in the native format; IDML exports the text unlinked. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. Type › Reload Fonts (`font.reload`) scans the font folders again and reads again the installed font files edited or removed since they were read, then recomposes the open documents: a font installed or edited while DesignCraft runs needs no restart (#328, #547). InDesign notices such changes by itself. From f3b7c3ee34e402b1e5c1854e7e8a86e876bd286e Mon Sep 17 00:00:00 2001 From: aiongg <67543387+aiongg@users.noreply.github.com> Date: Thu, 22 Oct 2026 03:06:48 -0400 Subject: [PATCH 2/2] ROADMAP: progress lines for guide selection, guide snapping and dragged column guides (#1543) --- ROADMAP.md | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/ROADMAP.md b/ROADMAP.md index f9be21cc9..8a2352e86 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -33,7 +33,7 @@ DesignCraft aims at full Adobe InDesign parity — and to be better: faster, ope - Index: page references (topic from the selection or up to 4 levels, sort keys; current page, to end of story, next n paragraphs, suppressed, See / See also), Generate / Update Index (section headings, nested or run-in, page ranges merged, Index Title / Section Head / Level 1–4 styles); IDML export/import as InDesign topics, page references and topic cross-references (verified in InDesign 2026); `index.*` commands. - Text clipboard keeps formatting (character/paragraph formats, footnotes, cross-references, index markers, tables) and falls back to plain text when the system clipboard changed; Paste without Formatting (⇧⌘V); Change Case (UPPERCASE, lowercase, Title Case, Sentence case — formatting kept); Type › Insert Special Character / White Space / Break Character submenus. - Anchored objects: items flow in the text inline (on the baseline, with Y offset; lines grow to fit) or above the line (left/center/right, space before/after); paste copied items into text to anchor them; Anchored Object Options and Release (back onto the page where shown); rendered, exported to PDF and to/from IDML (InDesign anchored object settings; verified in InDesign 2026); copy/paste and threading carry them; `anchored.*` commands. -- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. +- Ruler guides: drag out of the rulers (page guides, or spread guides on the pasteboard), drag to move, drop on a ruler to delete; a dragged guide snaps along its axis to other guides, margins, columns, bleed, page and object edges and centers and the document grid (Shift: the ruler's ticks; Cmd suspends snapping); selected like objects (click, Shift-click, a marquee that touches no object, Edit › Select All Guides ⌥⌘G), moved together, nudged, deleted, and placed exactly with the X or Y field; Layout › Create Guides (rows/columns with gutters, fit to margins or page); Delete All Guides on Spread; `guide.*` commands. - Links panel: every placed graphic with status (OK / Modified / Missing / Embedded, checked against the file on disk), page and effective PPI (low resolution flagged); Relink…, Go To, Update, Embed; `links.*` commands. - Missing fonts: highlighted pink on screen (never in output), listed by Preflight; Type › Find/Replace Font (fonts in the document, missing first; replace in text, cells, footnotes and styles); `font.list` / `font.replace`. - Missing glyphs, as in InDesign: a character the applied font lacks is drawn as that font's missing-glyph box (its .notdef; a drawn box when the font's is empty), on screen and in PDF (as a path, so PDF/A and PDF/UA exports take it), and Preflight lists the characters by font. Preferences › Composition › Draw Missing Glyphs from Fallback Fonts (`document.preferences {glyphFallback}`, stored with the document) draws them from other fonts instead; it is off in new documents and IDML imports, on in documents saved before it existed. A missing font's substitute always uses fallback fonts. @@ -348,9 +348,17 @@ list) · [roadmap.md](docs/roadmap.md) (milestones, plan) · [parity-checklist.m Newest first. +- **2026-10-22** · A ruler guide dragged with the Selection tools, out of a ruler, on its own or with other selected guides, snaps along its axis as objects do (#1221): to other ruler guides, margins, columns, bleed, page and object edges and centers (with the smart-guide mark) and the document grid, within the snap zone and following Snap to Guides and Smart Guides. Shift snaps to the ruler's ticks and Cmd suspends snapping. The preview line, the position readout and the drop use the same snapped position. No readiness percentage change. - **2026-10-22** · macOS: the app menu has a Language submenu after Settings… with each interface language in its own name and the current one checked, as in the suite's other apps; Preferences › General › Interface Language stays. No readiness percentage change. - **2026-10-22** · macOS menu bar: the menus keep working after the interface language or Show CJK Features changes (a rebuilt menu bar lost every click, Quit included), and its key equivalents are the shortcuts in effect, so a shortcut moved in the Keyboard Shortcuts editor leaves its old menu item. No readiness percentage change. - **2026-10-22** · Paragraph rules: Rule Above's offset is measured from the first line's baseline to the rule's bottom edge, as in InDesign. Rules sat one ascent too high and crossed the previous paragraph's text (reported from an imported INDD document). No readiness percentage change. +- **2026-10-21** · Ruler guides are selected like objects (#1282): a click with the Selection tools selects a guide (also where it crosses an object), Shift-click adds or removes one, a new guide from the rulers is selected, a marquee that touches no object selects the guides it crosses, and Edit › Select All Guides (⌥⌘G, `guide.selectAll`) selects every unlocked guide on the spread. Selected guides draw thicker in their layer's color, drag together (all of them shown moving), nudge with the arrow keys and go with Delete, each as one undo step (`guide.select`, `guide.deselect`, `guide.moveBy`, `guide.delete {}`). With one guide selected the Control bar's X (vertical guide) or Y (horizontal guide) field and Properties › Transform show where it is, as the rulers measure, and typing a value moves it there. No readiness percentage change. +- **2026-10-16** · Selection: drag a column guide to move its gutter, as in InDesign (#1096). Both guides of the gutter move together and keep its width, stop at the neighbouring guides and the margins, and snap to the document grid, guides and objects, or with Shift to the ruler's tick marks; Escape cancels and the drag is one undo step. Locked column guides (the default) are not picked. A document page takes the result as its own columns; on a parent page it passes to the pages that follow it. `layout.moveColumnGuide` does the same over the control channel and MCP. No readiness percentage change. +- **2026-10-16** · Layout: autoflow, inserted pages and new parents follow dragged column guides (#1096). Autoflow sets one frame per dragged column and counts the pages it adds from them; Insert Pages and New Parent keep the custom positions with the column count and gutter; a page that moves to the other side of the spine (inserted, deleted or moved pages, Duplicate Spread, a new parent's other page) has them mirrored across its margin box, as InDesign does; `document.inspect` reports each page's gutter, column rects and whether its columns are custom. No readiness percentage change. +- **2026-10-16** · Layout: Margins and Columns keeps dragged column guides unless the column count or gutter changes (#1096). The dialog sends every field on OK, so a margin-only change re-divided the columns evenly. Margin changes keep the positions; the columns follow them only while they fit the margin box. No readiness percentage change. +- **2026-10-16** · Lock Guides and Lock Column Guides are commands in View ▸ Grids & Guides (`view.lockGuides`, ⌥⌘;, and `view.lockColumnGuides`), so the control channel and MCP reach them. Lock Column Guides is an undo step, is saved with the document, on in new ones, and read from and written to IDML's `ColumnGuideLocked`, which export always wrote as locked (#1096). No readiness percentage change. +- **2026-10-16** · IDML: a page keeps column guides that were dragged off centre (InDesign's Custom columns). They were divided evenly on import, so the gutter sprang back to the middle; the imported positions now place the columns, with the canvas guides, snapping and text placement following them, and they are written back on export. Layout › Margins and Columns re-divides them evenly when the count or gutter changes, as InDesign's does. No readiness percentage change. +- **2026-10-16** · IDML: custom column positions are checked before they are used (#1096). A set must give two edges per column, ascending from the left margin to the right one; any other (a wrong count, unsorted or non-numeric values, a list longer than 216 columns allow, or positions left behind by a margin or page-size change) is the even split, in the canvas, on import and on export, instead of columns drawn outside the margins. No readiness percentage change. - **2026-10-16** · Type › Reload Fonts (`font.reload`): fonts installed, edited or removed while DesignCraft runs are read again without a restart, and the open documents recompose with them (#328, #547). An edited font file is noticed by its length and modification time; bundled, added and document fonts stay as they are. InDesign does this by itself, so noticing changes automatically is still open (gap 6). No readiness percentage change. - **2026-10-16** · Parents can be deleted from their Pages-panel context menu or `layout.parents.delete {id}`. Applied-page and based-on references are cleared, local override copies survive, and Undo restores the parent. Addresses the deletion portion of #956; no readiness percentage change. - **2026-10-17** · File › Package writes the document's and the IDML's links relative to the package (`Links/logo.png`, `file:Links/logo.png`), so a package holds no path from the machine that made it; opening a document resolves them to the files beside it. Links › Copy Link(s) To still links by full path. (#340). No readiness percentage change.