diff --git a/crates/engine/src/cmd/fileio/mod.rs b/crates/engine/src/cmd/fileio/mod.rs index b84bc0dae..28e0a2de7 100644 --- a/crates/engine/src/cmd/fileio/mod.rs +++ b/crates/engine/src/cmd/fileio/mod.rs @@ -142,7 +142,7 @@ pub fn specs() -> Vec { "Batch", [], None, - "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align or select.allOnArtboard step that names none gets it, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", + "{label?, commands: [{command, params}], artboard?: index (the active artboard; the app passes it: each edit.pasteInPlace, edit.pasteInFront, edit.pasteInBack, object.align, object.distribute, object.distributeSpacing or select.allOnArtboard step that names none gets it, or the last artboard of a document with fewer)} run several commands as ONE undo step (one in each document they edit: steps may open, switch, close or revert documents); stops at the first error and rolls everything back, documents included", has_doc, batch::batch ), diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 99132c318..4f94dcac8 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -184,7 +184,7 @@ pub fn specs() -> Vec { "Distribute", ["Window", "Align"], None, - "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", + "{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected), artboard?: index (0-based; with to: \"artboard\", the artboard to distribute across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} space the objects' left edges, centers or right edges (top edges, centers or bottom edges) evenly, in their order on that line: the first and last objects stay put, and fewer than three objects don't move; with to: \"artboard\", the first one's left (top) edge goes to the artboard's left (top) edge, the last one's right (bottom) edge goes to the artboard's right (bottom) edge, and two objects are enough. With anchor points selected (Direct Selection), the points are spaced evenly between the first and the last along the axis instead (#1636)", can_distribute, distribute ), @@ -193,7 +193,7 @@ pub fn specs() -> Vec { "Distribute Spacing", ["Window", "Align"], None, - "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", + "{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, stays put and the others are spaced from it), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected; default: the selection; \"key\" spaces as \"selection\" does), artboard?: index (0-based; with to: \"artboard\", the artboard to space across; the app passes the active one; default: as object.align's), bounds?: \"preview\"|\"geometric\" (default: the Use Preview Bounds preference; preview bounds take in strokes), patterns?: bool (Transform Patterns: pattern fills and strokes transform with the art; default: prefs transformPatternTiles)} without spacing, make the gaps between the objects equal, in the order of their left (top) edges: the first object's left (top) edge and the last one's right (bottom) edge stay put, or with to: \"artboard\" go to the artboard's left (top) and right (bottom) edges. With anchor points selected (Direct Selection), the points are spaced evenly along the axis instead, or `spacing` apart from the first (#1636)", can_distribute, distribute_spacing ), @@ -1174,6 +1174,17 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu } } +/// Returns the artboard for `to: "artboard"` of `object.distribute` and `object.distributeSpacing` +/// (command `cmd`), the same artboard as `object.align` uses ([`reference_rect`]). It returns +/// `None` for the other choices. A `to` outside the choices is an error. Both commands call it +/// before they space selected anchor points, and they space those points without the artboard. +fn distribute_to(s: &Session, p: &Value, ids: &[NodeId], preview: bool, cmd: &str) -> Result> { + match choice(p, cmd, "to", &["selection", "artboard", "key"])? { + Some("artboard") => reference_rect(s, p, ids, preview).map(Some), + _ => Ok(None), + } +} + /// Returns the selected root (one of `ids`) that is the key object or contains it, such as the /// compound path or selected group the key is in. Align to the key object and Distribute Spacing /// with a spacing keep it in place. @@ -1319,6 +1330,7 @@ fn distribute_anchors(s: &mut Session, label: &str, horiz: bool, gap: Option Result { let ids = selected_roots(s)?; let preview = preview_bounds(s, p, "object.distribute")?; + let board = distribute_to(s, p, &ids, preview, "object.distribute")?; // Anchor points selected with Direct Selection are distributed themselves, as Align aligns them. if !s.doc()?.selection.anchors.is_empty() { return match direction(p, "object.distribute")? { @@ -1348,11 +1360,20 @@ fn distribute(s: &mut Session, p: &Value) -> Result { }; items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz))); let n = items.len(); - if n < 3 { + // Two objects are enough on an artboard: they go to its edges. + if n < 2 || (n < 3 && board.is_none()) { return ok(); } - let first = key(&items[0].1, horiz); - let last = key(&items[n - 1].1, horiz); + let (first_box, last_box) = (&items[0].1, &items[n - 1].1); + // `first` and `last` are the lines that the first and last objects end on. Without an artboard + // the first and last objects stay where they are. On an artboard the first object moves until + // its left (top) edge is on the artboard's left (top) edge, and the last object moves until its + // right (bottom) edge is on the artboard's right (bottom) edge. + let (first, last) = match board { + Some(r) if horiz => (key(first_box, horiz) + r.x0 - first_box.x0, key(last_box, horiz) + r.x1 - last_box.x1), + Some(r) => (key(first_box, horiz) + r.y0 - first_box.y0, key(last_box, horiz) + r.y1 - last_box.y1), + None => (key(first_box, horiz), key(last_box, horiz)), + }; let moves: Vec<(NodeId, Vec2)> = items .iter() .enumerate() @@ -1369,6 +1390,7 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { let ids = selected_roots(s)?; let horiz = choice(p, "object.distributeSpacing", "axis", &["horizontal", "vertical"])? != Some("vertical"); let preview = preview_bounds(s, p, "object.distributeSpacing")?; + let board = distribute_to(s, p, &ids, preview, "object.distributeSpacing")?; let spacing = p .get("spacing") .filter(|v| !v.is_null()) @@ -1385,14 +1407,20 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result { }; let size = |r: &Rect| if horiz { r.width() } else { r.height() }; let start = |r: &Rect| if horiz { r.x0 } else { r.y0 }; + // The objects start at `lo`, and without a spacing the last one ends at `hi`. These are the + // first object's left (top) edge and the last one's right (bottom) edge, or the artboard's + // edges with Align to Artboard and no spacing. + let (lo, hi) = match board.filter(|_| spacing.is_none()) { + Some(r) if horiz => (r.x0, r.x1), + Some(r) => (r.y0, r.y1), + None if horiz => (first.x0, last.x1), + None => (first.y0, last.y1), + }; let gap = match spacing { Some(g) => g, - None => { - let span = if horiz { last.x1 - first.x0 } else { last.y1 - first.y0 }; - (span - items.iter().map(|i| size(&i.1)).sum::()) / (n - 1) as f64 - } + None => (hi - lo - items.iter().map(|i| size(&i.1)).sum::()) / (n - 1) as f64, }; - let mut pos = start(&first); + let mut pos = lo; let mut deltas = vec![]; for (id, r) in &items { deltas.push((*id, pos - start(r))); diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 6953f6029..a131da7de 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -547,16 +547,25 @@ impl DocState { pub const MAX_COORD: f64 = 4.0e6; /// The commands that act on the active artboard (#693), which the app passes as their `artboard`: -/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, and All on +/// Paste in Place, in Front and in Back paste onto it, Align to Artboard aligns to it, Distribute +/// Objects and Distribute Spacing with Align to Artboard distribute objects across it, and All on /// Active Artboard selects on it. A `command.batch` given `artboard` passes it on to each of their /// steps that names none. -pub const ON_ACTIVE_ARTBOARD: [&str; 5] = ["edit.pasteInPlace", "edit.pasteInFront", "edit.pasteInBack", "object.align", "select.allOnArtboard"]; +pub const ON_ACTIVE_ARTBOARD: [&str; 7] = [ + "edit.pasteInPlace", + "edit.pasteInFront", + "edit.pasteInBack", + "object.align", + "object.distribute", + "object.distributeSpacing", + "select.allOnArtboard", +]; /// `(command, param)` pairs the journal records only so a replay reproduces the original run /// (dates from the clock: `cmd::clock_date`; the active artboard the app passes to the /// [`ON_ACTIVE_ARTBOARD`] commands and to a batch); an action leaves them out /// ([`Session::journal_for_action`]). -const REPLAY_ONLY: [(&str, &str); 9] = [ +const REPLAY_ONLY: [(&str, &str); 11] = [ ("file.new", "created"), ("document.save", "modified"), ("file.saveAs", "modified"), @@ -564,6 +573,8 @@ const REPLAY_ONLY: [(&str, &str); 9] = [ ("edit.pasteInFront", "artboard"), ("edit.pasteInBack", "artboard"), ("object.align", "artboard"), + ("object.distribute", "artboard"), + ("object.distributeSpacing", "artboard"), ("select.allOnArtboard", "artboard"), ("command.batch", "artboard"), ]; diff --git a/crates/engine/src/tests.rs b/crates/engine/src/tests.rs index 19f525857..545800a3e 100644 --- a/crates/engine/src/tests.rs +++ b/crates/engine/src/tests.rs @@ -451,8 +451,10 @@ fn align_and_distribute_reject_bad_params_without_changing_state() { ("object.distribute", json!({"horizontal": "middle"}), h), ("object.distribute", json!({"vertical": "centre"}), v), ("object.distribute", json!({"horizontal": "left", "vertical": "middle"}), v), + ("object.distribute", json!({"horizontal": "left", "to": "page"}), to), ("object.distributeSpacing", json!({"axis": "diagonal"}), "`axis` must be one of horizontal, vertical"), ("object.distributeSpacing", json!({"axis": "vertical", "spacing": "5"}), "`spacing` must be a number"), + ("object.distributeSpacing", json!({"axis": "vertical", "to": 0}), to), ] { let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")); assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {msg}")); @@ -506,6 +508,97 @@ fn align_to_artboard_uses_the_artboard_named() { assert_eq!(x0(&s, c), boards[0].x0, "off every artboard: the first"); } +/// Rectangles `(x, width)`, 20 pt tall at y 10, selected → their ids. `vertical`: the same +/// numbers on the other axis, `(y, height)` of rectangles 20 pt wide at x 10. +fn selected_row(s: &mut Session, row: &[(f64, f64)], vertical: bool) -> Vec { + let ids: Vec = + row.iter().map(|&(at, size)| if vertical { rect(s, 10.0, at, 20.0, size) } else { rect(s, at, 10.0, size, 20.0) }).collect(); + s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + ids +} + +/// The left edges of `ids`, or with `vertical` their top edges. +fn starts(s: &Session, ids: &[NodeId], vertical: bool) -> Vec { + let b = |id| s.doc().unwrap().doc.node(id).unwrap().geometric_bounds().unwrap(); + ids.iter().map(|id| if vertical { b(*id).y0 } else { b(*id).x0 }).collect() +} + +/// With Align to Artboard, Distribute moves the first object until its left edge is on the +/// artboard's left edge, and it moves the last one until its right edge is on the artboard's right +/// edge. It spaces the left edges evenly between them, and two objects are enough. With Align to +/// Selection or to the key object, the first and last objects stay put, and two objects don't move. +/// `artboard` names the artboard. +#[test] +fn distribute_to_artboard_puts_the_outermost_objects_on_its_edges() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "artboards": 2})).unwrap(); + let boards: Vec<_> = s.doc().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + let ids = selected_row(&mut s, &[(100.0, 20.0), (150.0, 20.0), (260.0, 20.0)], false); + s.execute("object.distribute", &json!({"horizontal": "left", "to": "artboard"})).unwrap(); + assert_eq!(starts(&s, &ids, false), [0.0, 190.0, 380.0], "on artboard 0's edges"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.distribute", &json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap(); + let x0 = boards[1].x0; + assert_eq!(starts(&s, &ids, false), [x0, x0 + 190.0, x0 + 380.0], "on artboard 1's edges"); + for p in [json!({"horizontal": "left", "to": "selection"}), json!({"horizontal": "left", "to": "key"}), json!({"horizontal": "left"})] { + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.distribute", &p).unwrap(); + assert_eq!(starts(&s, &ids, false), [100.0, 180.0, 260.0], "{p}"); + } + let pair = selected_row(&mut s, &[(100.0, 20.0), (260.0, 40.0)], false); + s.execute("object.distribute", &json!({"horizontal": "left", "to": "selection"})).unwrap(); + assert_eq!(starts(&s, &pair, false), [100.0, 260.0], "two objects stay put"); + s.execute("object.distribute", &json!({"horizontal": "left", "to": "artboard"})).unwrap(); + assert_eq!(starts(&s, &pair, false), [0.0, 360.0], "two objects on the artboard's edges"); +} + +/// With Align to Artboard, each Distribute button spaces its own line between the first and last +/// objects (20, 60 and 40 pt wide or tall), which go to the artboard's edges: left edges, centers +/// or right edges, and top edges, centers or bottom edges. +#[test] +fn distribute_to_artboard_spaces_the_line_of_each_button() { + for vertical in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let ids = selected_row(&mut s, &[(100.0, 20.0), (150.0, 60.0), (300.0, 40.0)], vertical); + let (axis, lines) = if vertical { ("vertical", ["top", "center", "bottom"]) } else { ("horizontal", ["left", "center", "right"]) }; + for (line, middle) in lines.into_iter().zip([180.0, 165.0, 150.0]) { + s.execute("object.distribute", &json!({axis: line, "to": "artboard"})).unwrap(); + assert_eq!(starts(&s, &ids, vertical), [0.0, middle, 360.0], "{axis} {line}"); + s.execute("edit.undo", &json!({})).unwrap(); + } + } +} + +/// With Align to Artboard and no spacing, Distribute Spacing puts the first object's left (top) +/// edge on the artboard's left (top) edge, and it puts the last one's right (bottom) edge on the +/// artboard's right (bottom) edge, with equal gaps between the objects; two objects are enough. +/// Without Align to Artboard the outermost edges stay put, and with a spacing the key object stays +/// put, with Align to Artboard too. +#[test] +fn distribute_spacing_to_artboard_spans_it_with_equal_gaps() { + for vertical in [false, true] { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400})).unwrap(); + let ids = selected_row(&mut s, &[(100.0, 20.0), (150.0, 60.0), (300.0, 40.0)], vertical); + let axis = if vertical { "vertical" } else { "horizontal" }; + // The objects are 120 pt wide (tall) together, which leaves 280 pt for the gaps on the + // artboard and 120 pt between the outermost edges. + s.execute("object.distributeSpacing", &json!({"axis": axis, "to": "artboard"})).unwrap(); + assert_eq!(starts(&s, &ids, vertical), [0.0, 160.0, 360.0], "{axis}: 140 pt gaps"); + s.execute("edit.undo", &json!({})).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": axis, "to": "selection"})).unwrap(); + assert_eq!(starts(&s, &ids, vertical), [100.0, 180.0, 300.0], "{axis}: 60 pt gaps"); + s.execute("edit.undo", &json!({})).unwrap(); + s.select(|_, sel| sel.key = Some(ids[1])).unwrap(); + s.execute("object.distributeSpacing", &json!({"axis": axis, "spacing": 10, "to": "artboard"})).unwrap(); + assert_eq!(starts(&s, &ids, vertical), [120.0, 150.0, 220.0], "{axis}: 10 pt from the key"); + let pair = selected_row(&mut s, &[(100.0, 20.0), (260.0, 40.0)], vertical); + s.execute("object.distributeSpacing", &json!({"axis": axis, "to": "artboard"})).unwrap(); + assert_eq!(starts(&s, &pair, vertical), [0.0, 360.0], "{axis}: two objects"); + } +} + #[test] fn align_both_centers() { let mut s = session(); @@ -2383,6 +2476,30 @@ fn distribute_spaces_selected_anchor_points() { assert_eq!((pts(&s, a)[0].0, pts(&s, b), pts(&s, a)[2].0), (10.0, vec![(55.0, 200.0), (400.0, 300.0)], 100.0)); } +/// With anchor points selected, the Distribute commands space the points among themselves and +/// don't use `to: "artboard"` or `artboard`. A `to` outside the three values is an error for them +/// too, and nothing moves. +#[test] +fn distribute_spaces_anchor_points_among_themselves_with_to_artboard() { + let mut s = Session::new(); + s.execute("file.new", &json!({"width": 400, "height": 400, "artboards": 2})).unwrap(); + let id = NodeId(s.execute("path.create", &json!({"d": "M10 10 L20 50 L100 20 L60 90"})).unwrap()["id"].as_u64().unwrap()); + let pts = |s: &Session| -> Vec<(f64, f64)> { + s.doc().unwrap().doc.node(id).unwrap().path_data().unwrap().anchors().map(|(_, _, a)| (a.p.x, a.p.y)).collect() + }; + s.execute("select.anchors", &json!({"id": id.0, "anchors": [[0, 0], [0, 1], [0, 2]], "mode": "set"})).unwrap(); + let to = "`to` must be one of selection, artboard, key"; + for (cmd, p) in [("object.distribute", json!({"horizontal": "left", "to": "page"})), ("object.distributeSpacing", json!({"to": 0}))] { + let e = s.execute(cmd, &p).expect_err(&format!("{cmd} {p}")); + assert_eq!(e.to_string(), format!("invalid parameters for `{cmd}`: {to}")); + } + assert_eq!(pts(&s), [(10.0, 10.0), (20.0, 50.0), (100.0, 20.0), (60.0, 90.0)], "nothing moved"); + s.execute("object.distribute", &json!({"horizontal": "left", "to": "artboard", "artboard": 1})).unwrap(); + assert_eq!(pts(&s), [(10.0, 10.0), (55.0, 50.0), (100.0, 20.0), (60.0, 90.0)], "between the first and the last point"); + s.execute("object.distributeSpacing", &json!({"axis": "vertical", "to": "artboard"})).unwrap(); + assert_eq!(pts(&s), [(10.0, 10.0), (55.0, 50.0), (100.0, 30.0), (60.0, 90.0)], "equal gaps between 10 and 50"); +} + /// Repeating anchor Align must leave one Undo for the move that actually changed the path. #[test] fn repeating_anchor_alignment_does_not_consume_undo() { diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index 60db68949..ee311b662 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -67,8 +67,9 @@ fn recorded_actions_leave_out_replay_only_values() { } /// The artboard the app passes to Paste in Place, in Front and in Back, to Align to Artboard, to -/// All on Active Artboard and to a batch (its active one) is journaled and left out of recorded -/// actions, so a played action uses the artboard active when it plays. +/// Distribute Objects and Distribute Spacing, to All on Active Artboard and to a batch (its active +/// one) is journaled and left out of recorded actions, so a played action uses the artboard active +/// when it plays. #[test] fn recorded_actions_leave_out_the_artboard_the_app_passes() { let mut s = Session::new(); @@ -82,6 +83,8 @@ fn recorded_actions_leave_out_the_artboard_the_app_passes() { ("edit.pasteInBack", json!({"artboard": 1})), ("select.allOnArtboard", json!({"artboard": 1})), ("object.align", json!({"horizontal": "left", "to": "artboard", "artboard": 1})), + ("object.distribute", json!({"horizontal": "left", "to": "artboard", "artboard": 1})), + ("object.distributeSpacing", json!({"axis": "horizontal", "to": "artboard", "artboard": 1})), ("command.batch", json!({"artboard": 1, "commands": [{"command": "select.allOnArtboard", "params": {}}]})), ]; for (id, p) in &steps { diff --git a/crates/engine/tests/command_sweep.rs b/crates/engine/tests/command_sweep.rs index 3d635e0de..86ed41ec9 100644 --- a/crates/engine/tests/command_sweep.rs +++ b/crates/engine/tests/command_sweep.rs @@ -346,6 +346,8 @@ fn structured_junk() { ("object.distributeSpacing", json!({"axis": "vertical", "spacing": 0})), ("object.align", json!({"horizontal": "center", "vertical": "center", "to": "artboard", "bounds": "preview"})), ("object.distribute", json!({"vertical": "center", "bounds": "geometric"})), + ("object.distribute", json!({"horizontal": "right", "to": "artboard", "artboard": u64::MAX, "bounds": "preview"})), + ("object.distributeSpacing", json!({"axis": "vertical", "to": "artboard", "artboard": -1, "spacing": null})), ("artboard.setProps", json!({"index": 0, "width": -1, "height": 0})), ("artboard.setProps", json!({"index": 0, "width": 1e300, "height": 1e-300, "scaleArt": true, "strokes": true, "corners": true})), ("artboard.setProps", json!({"index": 0, "x": -1e308, "width": 0, "height": 1, "scaleArt": true, "patterns": true})), diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 57fe1cba0..2ad6c9656 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -665,8 +665,9 @@ impl VectorcraftApp { return r; } let mut params = params; - // Paste in place, in front, in back (#693), Select › All on Active Artboard (#1006) and Align to - // Artboard: onto, on or to the active artboard, the view's. A batch passes it on to those steps. + // Paste in place, in front and in back (#693), Select › All on Active Artboard (#1006), and Align, + // Distribute Objects and Distribute Spacing with Align to Artboard act on the active artboard, the + // view's, which the app passes as `artboard`. A batch passes it on to those steps. if (vectorcraft_engine::ON_ACTIVE_ARTBOARD.contains(&id) || id == "command.batch") && params.get("artboard").is_none() && self.view().is_some() diff --git a/crates/ui-egui/src/panels/actions.rs b/crates/ui-egui/src/panels/actions.rs index 11aa09967..d8446570a 100644 --- a/crates/ui-egui/src/panels/actions.rs +++ b/crates/ui-egui/src/panels/actions.rs @@ -234,4 +234,43 @@ mod tests { play(&mut app, 0, center).unwrap(); assert_eq!(bounds(&app, &[rect]).center(), boards[1].center(), "centered on artboard 1, the active one"); } + + /// Actions that record Distribute Objects and Distribute Spacing with Align to Artboard play on + /// the artboard active when they play, not on the one active when they were recorded. + #[test] + fn distribute_actions_play_on_the_artboard_active_when_they_play() { + let mut app = VectorcraftApp::new(Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 100, "artboards": 3})).unwrap(); + let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + // The rectangles are 20, 40 and 20 pt wide, and they are on artboard 1. + let ids: Vec = [(10.0, 20.0), (50.0, 40.0), (120.0, 20.0)] + .into_iter() + .map(|(x, w)| { + let r = app.run("shape.rectangle", json!({"x": boards[1].x0 + x, "y": boards[1].y0 + 15.0, "width": w, "height": 20})).unwrap(); + vectorcraft_doc::NodeId(r["id"].as_u64().unwrap()) + }) + .collect(); + app.run("select.set", json!({"ids": ids.iter().map(|id| id.0).collect::>()})).unwrap(); + let lefts = + |app: &VectorcraftApp| ids.iter().map(|id| app.session.active().unwrap().doc.bounds_of(&[*id], false).unwrap().x0).collect::>(); + // Each step is recorded as the panel records an action, while artboard 0 is active. + let record = |app: &mut VectorcraftApp, name: &str, id: &str, params: Value| { + let start = app.session.journal.len(); + app.run(id, params).unwrap(); + let steps = app.session.journal_for_action(start); + app.ui.action_sets[0].actions.push(Action { name: name.into(), steps }); + app.ui.action_sets[0].actions.len() - 1 + }; + let (x0, x1) = (boards[0].x0, boards[0].x1); + let distribute = record(&mut app, "Distribute Left", "object.distribute", json!({"horizontal": "left", "to": "artboard"})); + assert_eq!(lefts(&app), [x0, x0 + 90.0, x1 - 20.0], "distributed across artboard 0 while recording"); + let spacing = record(&mut app, "Distribute Spacing", "object.distributeSpacing", json!({"axis": "horizontal", "to": "artboard"})); + assert_eq!(lefts(&app), [x0, x0 + 80.0, x1 - 20.0], "spaced across artboard 0 while recording"); + app.view_mut().unwrap().artboard = 2; + let (x0, x1) = (boards[2].x0, boards[2].x1); + play(&mut app, 0, distribute).unwrap(); + assert_eq!(lefts(&app), [x0, x0 + 90.0, x1 - 20.0], "distributed across artboard 2, active when the action plays"); + play(&mut app, 0, spacing).unwrap(); + assert_eq!(lefts(&app), [x0, x0 + 80.0, x1 - 20.0], "spaced across artboard 2, active when the action plays"); + } } diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 8f310bf50..d1d92256f 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -124,7 +124,8 @@ fn topmost(app: &VectorcraftApp) -> Option { /// What Align aligns to: the key object while there is one (a click on an object of the /// selection makes it the key, and Align to Key Object makes the topmost one the key), else the -/// stored Align To choice, Selection or Artboard. +/// stored Align To choice, Selection or Artboard. The Distribute Objects and Distribute Spacing +/// buttons send it as their `to`. fn align_to(app: &VectorcraftApp) -> AlignTo { if has_key(app) { AlignTo::Key } else { app.ui.align_to } } @@ -159,7 +160,7 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { ui.add_space(6.0); } if widgets::icon_button_enabled(ui, icon, tl!(tip), false, distributes, 32.0).clicked() { - app.run("object.distribute", json!({*axis: v})).ok(); + app.run("object.distribute", json!({*axis: v, "to": to.param()})).ok(); } } }); @@ -176,10 +177,10 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) { let on = distributes; let sp = if key { Some(spacing) } else { None }; if widgets::icon_button_enabled(ui, "dc-dist-vspace", tl!("Vertical Distribute Space"), false, on, 28.0).clicked() { - app.run("object.distributeSpacing", json!({"axis": "vertical", "spacing": sp})).ok(); + app.run("object.distributeSpacing", json!({"axis": "vertical", "spacing": sp, "to": to.param()})).ok(); } if widgets::icon_button_enabled(ui, "dc-dist-hspace", tl!("Horizontal Distribute Space"), false, on, 28.0).clicked() { - app.run("object.distributeSpacing", json!({"axis": "horizontal", "spacing": sp})).ok(); + app.run("object.distributeSpacing", json!({"axis": "horizontal", "spacing": sp, "to": to.param()})).ok(); } ui.add_enabled_ui(key, |ui| { if let Some(v) = widgets::spin_field(ui, "align-spacing", Some(spacing), app.session.general_unit(), 70.0, 1.0, 0.0, &[]) { @@ -490,6 +491,54 @@ mod tests { assert_eq!((x1(&a), x1(&b)), (200.0, 200.0), "both to the artboard's right edge"); } + /// One frame of the Align panel → its Horizontal Distribute Left and Horizontal Distribute + /// Space buttons. + fn distribute_buttons(app: &mut VectorcraftApp, ctx: &egui::Context) -> (egui::Rect, egui::Rect) { + frame_events(ctx, app, vec![], show); + // The lower row of 32-point buttons is Distribute Objects': Vertical Distribute Top, Center + // and Bottom, then Horizontal Distribute Left, Center and Right. + let objects = widget_rects(ctx, egui::vec2(32.0, 32.0)); + let bottom = objects.last().unwrap().top(); + let mut row: Vec = objects.into_iter().filter(|r| r.top() == bottom).collect(); + row.sort_by(|a, b| a.left().total_cmp(&b.left())); + // The 28-point buttons are Distribute Spacing's two (Vertical, then Horizontal) and then + // Align To's three. + let mut small = widget_rects(ctx, egui::vec2(28.0, 28.0)); + small.sort_by(|a, b| a.left().total_cmp(&b.left())); + (row[3], small[1]) + } + + /// With Align to Artboard chosen, the Align panel's Horizontal Distribute Left puts the + /// outermost of three objects (20, 40 and 20 pt wide) on the active artboard's left and right + /// edges and spaces their left edges evenly, and Horizontal Distribute Space makes the gaps + /// between them equal. With Align to Selection chosen, the outermost objects stay put. + #[test] + fn the_distribute_buttons_follow_align_to_artboard() { + let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default()); + app.run("file.new", json!({"width": 200, "height": 200, "artboards": 2})).unwrap(); + let boards: Vec<_> = app.session.active().unwrap().doc.artboards.iter().map(|a| a.rect).collect(); + let ids: Vec = [(10, 20), (50, 40), (120, 20)] + .into_iter() + .map(|(x, w)| app.run("shape.rectangle", json!({"x": x, "y": 10, "width": w, "height": 10})).unwrap()["id"].clone()) + .collect(); + app.run("select.set", json!({"ids": ids})).unwrap(); + let lefts = |app: &VectorcraftApp| ids.iter().map(|id| left_edge(app, id)).collect::>(); + let ctx = egui::Context::default(); + let (left, space) = distribute_buttons(&mut app, &ctx); + click(&ctx, &mut app, left.center(), show); + assert_eq!(lefts(&app), [10.0, 65.0, 120.0], "Align to Selection: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + // The objects are on artboard 0, and artboard 1 is the active one. + app.ui.align_to = AlignTo::Artboard; + app.view_mut().unwrap().artboard = 1; + let (x0, x1) = (boards[1].x0, boards[1].x1); + click(&ctx, &mut app, left.center(), show); + assert_eq!(lefts(&app), [x0, x0 + 90.0, x1 - 20.0], "the active artboard's edges: {}", app.ui.status); + app.run("edit.undo", json!({})).unwrap(); + click(&ctx, &mut app, space.center(), show); + assert_eq!(lefts(&app), [x0, x0 + 80.0, x1 - 20.0], "60 pt gaps: {}", app.ui.status); + } + /// Runs the Object → Align item `label` as a click on it does. fn object_align(app: &mut VectorcraftApp, label: &str) { let entry = crate::menus::menu_entries(app).into_iter().find(|e| e.path == ["Object", "Align"] && e.label == label).expect("menu item"); diff --git a/docs/mcp.md b/docs/mcp.md index 102d8d2e1..6b596c2c7 100644 --- a/docs/mcp.md +++ b/docs/mcp.md @@ -1719,9 +1719,23 @@ selection, or to the first artboard when no artboard is under the center. With ` the objects on artboard `artboard`, or on the first artboard when the call gives none; an index the document doesn't have is an error. -The desktop app adds its active artboard, the one the Artboards panel highlights, to the `object.align` and -`select.allOnArtboard` calls it runs without `artboard`, as it does for `edit.pasteInPlace`, `edit.pasteInFront` and -`edit.pasteInBack`, and to each `command.batch`. A batch passes its `artboard` on to each of these steps that names +`object.distribute {to: "artboard", artboard?}` and `object.distributeSpacing {to: "artboard", artboard?}` (the +Distribute Objects and Distribute Spacing buttons with Align to Artboard) use the same artboard as `object.align`. +`object.distribute` moves the first object, in the order of the line it spaces (left edges, centers or right edges; top +edges, centers or bottom edges), until its left or top edge is on the artboard's left or top edge, and it moves the +last object until its right or bottom edge is on the artboard's right or bottom edge. It spaces the other objects +evenly by that line between them, and two objects are enough. `object.distributeSpacing` without `spacing` puts the +left or top edge of the first object, in the order of those edges, on the artboard's left or top edge, and it puts the +right or bottom edge of the last object on the artboard's right or bottom edge, with equal gaps between the objects. +With `to: "selection"` or `"key"`, or without `to`, `object.distribute` and `object.distributeSpacing` without `spacing` +keep the first and last objects where they are, and `object.distribute` moves nothing with fewer than three objects. +With `spacing`, `object.distributeSpacing` does not use `to`. With anchor points selected (Direct Selection), both +commands space the points among themselves and do not use `to` or `artboard`. + +The desktop app adds its active artboard, the one the Artboards panel highlights, to the `object.align`, +`object.distribute`, `object.distributeSpacing` and `select.allOnArtboard` calls it runs without `artboard`, as it does +for `edit.pasteInPlace`, `edit.pasteInFront` and `edit.pasteInBack`, and to each `command.batch`. A batch passes its +`artboard` on to each of these steps that names none, when the step runs. When the document a step runs in has no artboard at that index (for example, after an earlier step deleted the active artboard when it was the last one), the step gets that document's last artboard. In remote mode, MCP's `run_command` calls and each step of the `command_batch` tool get the active artboard too. The @@ -1733,6 +1747,7 @@ tool (`tool.select {tool: "artboard"}`) starts it on the active artboard: its `a ```json {"name":"run_command","arguments":{"command":"object.align","params":{"horizontal":"left","to":"artboard","artboard":1}}} +{"name":"run_command","arguments":{"command":"object.distribute","params":{"horizontal":"left","to":"artboard","artboard":1}}} {"name":"run_command","arguments":{"command":"select.allOnArtboard","params":{"artboard":1}}} ``` diff --git a/docs/target-app-parity.md b/docs/target-app-parity.md index a8dec10bd..0877a964f 100644 --- a/docs/target-app-parity.md +++ b/docs/target-app-parity.md @@ -475,6 +475,7 @@ Moved from the ROADMAP's "Shipped so far" on 2026-10-10. Update it when a featur - **Artboard click-to-fit (#1227):** a plain click fits the topmost editable object or group to a new artboard, including its stroke like Fit to Selected Art, and makes the new artboard active. Art and its selection stay unchanged; creation is one undo step. Handles, modified clicks, drags, Escape and tool changes retain their meaning. Tested inside an artboard and on the pasteboard (`crates/engine/tests/artboard_click.rs`); the public [CS6 Printing Guide](https://helpx.adobe.com/content/dam/help/en/indesign/pdf/CS6_PrintGuide.pdf) documents the object-click gesture. This legacy gesture adds evidence without regrading the 2026 canvas controls. - **Views:** the status bar's artboard navigator (first, previous, next, last; `view.goToArtboard`) goes to an artboard, fits it and synchronizes the Artboard tool selection when that tool is active; ordinary object selection is preserved. Actual Next-button regressions cover keyboard/Properties deletion and Undo in `chrome.rs`. Fit Artboard in Window and Actual Size show the navigator's artboard. The Artboards panel highlights that active artboard: clicking a row makes it active (the Artboard tool's too, while it is chosen) as does clicking an artboard with the Artboard tool, double-clicking its number also fits it in the window and double-clicking its name renames it. Duplicate Artboards, or a row dragged onto New Artboard, copies the artboard with its art (#446). Escape leaves Presentation Mode, F from it goes back to Normal Screen Mode, and the screen mode isn't saved with the preferences: the app always starts in Normal Screen Mode with its menus and panels (#472). View › Pixel Preview shows the art as the document pixels it rasterizes to, one per point and hard-edged when magnified, turning with a rotated view (it used to draw the smooth vectors, with only the pixel grid over them). In Outline view a template layer keeps drawing in Preview, dimmed, so a picture placed to be traced stays visible under the outlines (#1505, fixes #1486). Align to Artboard uses the active artboard, the one the Artboards panel highlights (`object.align {artboard}`). A press with a selection tool inside an artboard makes that artboard active; with the Selection tool, a Shift-click on an empty spot inside an artboard makes it active and keeps the selection. One selected object is aligned to the active artboard too, whatever Align To says (a lone object is never the key object). When an action plays, its Align to Artboard, All on Active Artboard, Paste in Place, Paste in Front and Paste in Back steps that name no artboard use the artboard that is active then. + With Align to Artboard chosen, the Align panel's Distribute Objects and Distribute Spacing buttons use the active artboard (`object.distribute` and `object.distributeSpacing` with `to: "artboard"` and `artboard`). The first object's left or top edge goes to the artboard's left or top edge, and the last object's right or bottom edge goes to the artboard's right or bottom edge. Distribute Objects spaces the other objects evenly by its button's line, and two objects are enough. Distribute Spacing makes the gaps between the objects equal. With anchor points selected, the buttons space the points among themselves and do not use the artboard. When an action plays, its Distribute Objects and Distribute Spacing steps with Align to Artboard use the artboard that is active then. Choosing the Artboard tool (Shift+O, its toolbar button, Edit Artboards in Properties or Document Setup, or the Artboards panel menu's Artboard Options…) starts it on the active artboard: the canvas outlines that artboard, and Delete, Cut, Copy, the Transform panel and Artboard Options act on it. When the view angle changes (a Rotate View drag, the status bar's angle menu, Reset Rotate View or a saved view) and the desktop app renders the frame at the new angle on the render worker, the canvas draws the last frame turned to the new angle until that frame is ready. It used to draw no art until then. - **Geometry and effects:**