Files
vectorcraft/crates/engine/src/tests_keyguide.rs
T
Cursor Agentandnrq6993-a11y c8082facb1 G11: Align panel moves ruler guides to artboard (#3463)
## Summary
object.align now moves selected ruler guides when aligning to the artboard,
matching Illustrator and the existing Distribute behaviour. Guide-only
selection can run align; the Align panel enables its buttons when only
guides are selected. Guides still stay put when objects are in the selection
and Align To is Selection; key-guide behaviour is unchanged.

## Tests
- engine tests_keyguide: guides_selected_alone_align_to_artboard; updated
  selected_guides_align_to_a_clicked_key_object for artboard align
- ui-egui align panel: the_align_buttons_align_guides_selected_alone_to_artboard

Closes storytold/vectorcraft#3463

Co-authored-by: nrq6993-a11y <nrq6993-a11y@users.noreply.github.com>
2026-10-22 05:20:10 +07:00

404 lines
22 KiB
Rust

//! #2143: a ruler guide selected with objects can be the key object Align lines them up on, and
//! Distribute spaces ruler guides selected without objects.
use serde_json::{Value, json};
use vectorcraft_tools::{Mods, PointerEvent, PointerKind};
use super::*;
fn session() -> Session {
let mut s = Session::new();
s.execute("file.new", &json!({"width": 800, "height": 600})).unwrap();
s
}
fn rect(s: &mut Session, x: f64, y: f64) -> NodeId {
NodeId(s.execute("shape.rectangle", &json!({"x": x, "y": y, "width": 40, "height": 20})).unwrap()["id"].as_u64().unwrap())
}
fn bounds(s: &Session, id: NodeId) -> vectorcraft_geom::Rect {
s.doc().unwrap().doc.bounds_of(&[id], false).unwrap()
}
/// A Selection tool click at (`x`, `y`), with Shift when `shift`.
fn click(s: &mut Session, x: f64, y: f64, shift: bool) {
let (v, mods) = (ViewInfo::default(), Mods { shift, ..Default::default() });
for kind in [PointerKind::Down, PointerKind::Up] {
s.pointer(&PointerEvent::new(kind, x, y).with_mods(mods), v).unwrap();
}
}
#[test]
fn a_clicked_guide_is_the_key_and_align_lines_the_objects_up_on_it() {
let mut s = session();
let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 250.0));
s.execute("guide.add", &json!({"vertical": true, "pos": 500})).unwrap();
s.select_tool("selection", ViewInfo::default()).unwrap();
// Click an object, Shift-click the guide, then click the guide again.
click(&mut s, 110.0, 110.0, false);
click(&mut s, 500.0, 400.0, true);
assert_eq!((s.doc().unwrap().selection.objects.clone(), s.doc().unwrap().selection.guides.clone()), (vec![a], vec![0]));
click(&mut s, 500.0, 400.0, false);
assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "the guide is the key");
assert_eq!(s.execute("document.inspect", &json!({})).unwrap()["keyGuide"], 0);
// Shift-click a third object: the guide stays the key.
click(&mut s, 310.0, 260.0, true);
assert_eq!(s.doc().unwrap().selection.objects, vec![a, b]);
assert_eq!(s.doc().unwrap().selection.key_guide, Some(0));
let (ya, yb) = (bounds(&s, a).y0, bounds(&s, b).y0);
s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap();
// Both centred on the guide's x; a vertical guide fixes no y, so they keep theirs.
for (id, y) in [(a, ya), (b, yb)] {
let r = bounds(&s, id);
assert!((r.center().x - 500.0).abs() < 1e-6 && (r.y0 - y).abs() < 1e-6, "{id:?}: {r:?}");
}
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 500.0, "the guide stays put");
s.execute("object.align", &json!({"horizontal": "left"})).unwrap();
assert!((bounds(&s, a).x0 - 500.0).abs() < 1e-6 && (bounds(&s, b).x0 - 500.0).abs() < 1e-6);
// A click on the key guide again: no key.
click(&mut s, 500.0, 400.0, false);
assert_eq!(s.doc().unwrap().selection.key_guide, None);
}
#[test]
fn one_object_aligns_to_a_horizontal_key_guide() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0);
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
s.execute("select.set", &json!({"ids": [a.0]})).unwrap();
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
assert_eq!(s.execute("select.key", &json!({"guide": 0})).unwrap(), json!({"key": null, "guide": 0}));
let x = bounds(&s, a).x0;
s.execute("object.align", &json!({"vertical": "bottom", "horizontal": "right"})).unwrap();
let r = bounds(&s, a);
assert!((r.y1 - 300.0).abs() < 1e-6 && (r.x0 - x).abs() < 1e-6, "{r:?}");
// `to` still wins.
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
assert!(bounds(&s, a).x0.abs() < 1e-6);
}
#[test]
fn select_key_takes_only_a_selected_guide_with_objects() {
let mut s = session();
let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 100.0));
for pos in [500, 600] {
s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap();
}
let err = |s: &mut Session, p: Value| assert!(s.execute("select.key", &p).is_err(), "{p}");
s.execute("guide.select", &json!({"indexes": [1]})).unwrap();
err(&mut s, json!({"guide": 1}));
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
err(&mut s, json!({"guide": 1}));
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
for bad in [json!({"guide": 7}), json!({"guide": -1}), json!({"guide": "x"}), json!({"guide": 1, "id": a.0})] {
err(&mut s, bad);
}
s.execute("select.key", &json!({"guide": 1})).unwrap();
// An object key replaces the guide key, and the other way round.
s.execute("select.key", &json!({"id": b.0})).unwrap();
assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (Some(b), None));
s.execute("select.key", &json!({"guide": 1})).unwrap();
assert_eq!((s.doc().unwrap().selection.key, s.doc().unwrap().selection.key_guide), (None, Some(1)));
// Deleting a guide before it keeps the key on the same guide.
s.execute("guide.remove", &json!({"index": 0})).unwrap();
assert_eq!(s.doc().unwrap().selection.key_guide, Some(0));
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 600.0);
// Taking the guide out of the selection, or every object, lets the key go.
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
assert_eq!(s.doc().unwrap().selection.key_guide, None);
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
s.execute("select.key", &json!({"guide": 0})).unwrap();
s.execute("select.toggle", &json!({"id": a.0})).unwrap();
assert_eq!(s.doc().unwrap().selection.key_guide, Some(0), "one object is enough");
s.execute("select.toggle", &json!({"id": b.0})).unwrap();
assert_eq!(s.doc().unwrap().selection.key_guide, None, "no object to align");
}
#[test]
fn selected_anchor_points_align_to_a_key_guide() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 400})).unwrap();
s.execute("select.anchors", &json!({"id": a.0, "anchors": [[0, 0]], "mode": "set"})).unwrap();
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
s.execute("select.key", &json!({"guide": 0})).unwrap();
s.execute("object.align", &json!({"horizontal": "center"})).unwrap();
let NodeKind::Path { path, .. } = &s.doc().unwrap().doc.node(a).unwrap().kind else { panic!("path") };
let p = path.subpaths[0].anchors[0].p;
assert_eq!(p.x, 400.0);
assert_eq!(path.subpaths[0].anchors[1].p.x, 140.0, "the other points stay");
}
#[test]
fn selected_guides_align_to_a_clicked_key_object() {
let mut s = session();
let a = rect(&mut s, 100.0, 100.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 500})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 400})).unwrap();
s.select_tool("selection", ViewInfo::default()).unwrap();
// Click the object, Shift-click both guides, then click the object again: one object is
// enough to be the key while guides are selected.
click(&mut s, 110.0, 110.0, false);
click(&mut s, 500.0, 300.0, true);
click(&mut s, 300.0, 400.0, true);
assert_eq!(s.doc().unwrap().selection.guides, vec![0, 1]);
click(&mut s, 110.0, 110.0, false);
assert_eq!(s.doc().unwrap().selection.key, Some(a), "the object is the key");
let before = bounds(&s, a);
s.execute("object.align", &json!({"horizontal": "center", "vertical": "center"})).unwrap();
let guides = |s: &Session| s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::<Vec<_>>();
assert_eq!(guides(&s), [120.0, 110.0], "the vertical guide on the centre's x, the horizontal one on its y");
assert_eq!(bounds(&s, a), before, "the key stays put");
s.execute("object.align", &json!({"horizontal": "right"})).unwrap();
assert_eq!(guides(&s), [140.0, 110.0], "horizontal alignment moves only the vertical guide");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(guides(&s), [120.0, 110.0], "one undo step");
// Aligning to the artboard moves the vertical guide to the artboard's left edge.
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard"})).unwrap();
assert_eq!(guides(&s), [0.0, 110.0]);
// Deselecting the guides lets the lone key go.
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
assert_eq!(s.doc().unwrap().selection.objects, vec![a]);
assert_eq!(s.doc().unwrap().selection.key, None);
}
#[test]
fn guides_align_to_a_key_object_among_others_and_parallel_ones_to_a_key_guide() {
let mut s = session();
let (a, b) = (rect(&mut s, 100.0, 100.0), rect(&mut s, 300.0, 250.0));
for pos in [500, 600] {
s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap();
}
s.execute("select.set", &json!({"ids": [a.0, b.0]})).unwrap();
s.execute("guide.select", &json!({"indexes": [0, 1], "toggle": true})).unwrap();
s.execute("select.key", &json!({"id": b.0})).unwrap();
s.execute("object.align", &json!({"horizontal": "left"})).unwrap();
assert_eq!(bounds(&s, a).x0, 300.0);
assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0));
// Aligning to the selection leaves guides alone.
s.execute("object.align", &json!({"horizontal": "right", "to": "selection"})).unwrap();
assert!(s.doc().unwrap().doc.guides.iter().all(|g| g.pos == 300.0));
// A key guide stays put, and the other vertical guide lines up on it.
s.execute("guide.move", &json!({"index": 1, "pos": 700})).unwrap();
s.execute("select.key", &json!({"guide": 1})).unwrap();
s.execute("object.align", &json!({"horizontal": "center"})).unwrap();
assert_eq!(s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect::<Vec<_>>(), [700.0, 700.0]);
}
/// Objects 40 pt wide at `xs` (y 100) and a vertical guide at `pos` that is their key.
fn key_guide_across(xs: &[f64], pos: f64) -> (Session, Vec<NodeId>) {
let mut s = session();
let ids: Vec<NodeId> = xs.iter().map(|&x| rect(&mut s, x, 100.0)).collect();
s.execute("guide.add", &json!({"vertical": true, "pos": pos})).unwrap();
s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()})).unwrap();
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
s.execute("select.key", &json!({"guide": 0})).unwrap();
(s, ids)
}
fn lefts(s: &Session, ids: &[NodeId]) -> Vec<f64> {
ids.iter().map(|id| bounds(s, *id).x0).collect()
}
#[test]
fn distribute_with_a_spacing_spaces_the_objects_from_a_key_guide() {
let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], 350.0);
// Two objects are before the guide and one after it: the guide is spaced from as a key object
// with no size would be.
for (h, want) in [("left", [330.0, 340.0, 360.0]), ("center", [310.0, 320.0, 340.0]), ("right", [290.0, 300.0, 320.0])] {
s.execute("object.distribute", &json!({"horizontal": h, "spacing": 10})).unwrap();
assert_eq!(lefts(&s, &ids), want, "{h}");
assert_eq!(s.doc().unwrap().doc.guides[0].pos, 350.0, "{h}: the guide stays put");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(lefts(&s, &ids), [100.0, 300.0, 500.0], "{h}: one undo step");
}
// `to: "key"` is what the Align panel sends; 0 puts every line on the guide.
s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 0, "to": "key"})).unwrap();
assert_eq!(lefts(&s, &ids), [350.0; 3]);
// A guide before or after every object.
for (pos, want) in [(50.0, [60.0, 70.0, 80.0]), (600.0, [570.0, 580.0, 590.0])] {
let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], pos);
s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10})).unwrap();
assert_eq!(lefts(&s, &ids), want, "guide at {pos}");
}
// Two objects are enough, and a negative spacing reverses the order.
let (mut s, ids) = key_guide_across(&[100.0, 500.0], 300.0);
s.execute("object.distribute", &json!({"horizontal": "left", "spacing": -20})).unwrap();
assert_eq!(lefts(&s, &ids), [320.0, 280.0]);
}
#[test]
fn distribute_from_a_horizontal_key_guide_and_along_a_guide() {
let mut s = session();
let ids: Vec<NodeId> = [100.0, 200.0, 400.0].iter().map(|&y| rect(&mut s, 50.0, y)).collect();
s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap();
s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()})).unwrap();
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
s.execute("select.key", &json!({"guide": 0})).unwrap();
let tops = |s: &Session| ids.iter().map(|id| bounds(s, *id).y0).collect::<Vec<_>>();
s.execute("object.distribute", &json!({"vertical": "bottom", "spacing": 5})).unwrap();
// Bottoms 120 and 220 are above the guide, 420 below it.
assert_eq!(tops(&s), [270.0, 275.0, 285.0]);
// A horizontal guide gives no line across a horizontal distribution: the objects stay where
// they are, as Align leaves them, and no undo step is added.
let undo = s.doc().unwrap().history.undo.len();
let xs: Vec<f64> = ids.iter().map(|id| bounds(&s, *id).x0).collect();
s.execute("object.distribute", &json!({"horizontal": "center", "spacing": 5})).unwrap();
assert_eq!(ids.iter().map(|id| bounds(&s, *id).x0).collect::<Vec<_>>(), xs);
assert_eq!(s.doc().unwrap().history.undo.len(), undo);
}
#[test]
fn distribute_without_a_spacing_spaces_the_key_guide_too() {
// Without a spacing, the key guide is spaced with the objects as an item with no size: lines
// 100, 150, 350 (the guide) and 400 go to 100, 200, 300 and 400.
let (mut s, ids) = key_guide_across(&[100.0, 150.0, 400.0], 350.0);
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 200.0, 400.0], vec![300.0]));
// `to: "selection"` wins over the key guide, so a spacing isn't used either.
let (mut s, ids) = key_guide_across(&[100.0, 150.0, 400.0], 350.0);
s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10, "to": "selection"})).unwrap();
assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 200.0, 400.0], vec![300.0]));
}
#[test]
fn distribute_spacing_spaces_the_objects_from_a_key_guide() {
// Left edges 100, 300 and 500 of 40 pt objects with a vertical key guide at 350, 10 pt apart:
// the object before the guide ends 10 pt before it, the one after it starts 10 pt after it.
let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], 350.0);
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap();
assert_eq!((lefts(&s, &ids), positions(&s)), (vec![250.0, 300.0, 360.0], vec![350.0]));
// A guide along the axis leaves everything where it is.
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 10})).unwrap();
assert_eq!(lefts(&s, &ids), [250.0, 300.0, 360.0]);
assert_eq!(bounds(&s, ids[0]).y0, 100.0);
}
#[test]
fn other_selected_guides_are_spaced_from_a_key_guide() {
let (mut s, ids) = key_guide_across(&[100.0, 300.0, 500.0], 350.0);
s.execute("guide.add", &json!({"vertical": true, "pos": 600})).unwrap();
s.execute("guide.add", &json!({"vertical": false, "pos": 50})).unwrap();
s.execute("guide.select", &json!({"indexes": [1, 2], "toggle": true})).unwrap();
s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10})).unwrap();
// The vertical guide at 600 is the last line, 10 pt after the last object's; the horizontal
// guide stays.
assert_eq!((lefts(&s, &ids), positions(&s)), (vec![330.0, 340.0, 360.0], vec![350.0, 370.0, 50.0]));
}
/// Guides at `vertical` x positions and `horizontal` y positions, all selected without objects.
fn guides_alone(vertical: &[f64], horizontal: &[f64]) -> Session {
let mut s = session();
for (v, pos) in vertical.iter().map(|p| (true, p)).chain(horizontal.iter().map(|p| (false, p))) {
s.execute("guide.add", &json!({"vertical": v, "pos": pos})).unwrap();
}
let all: Vec<usize> = (0..vertical.len() + horizontal.len()).collect();
s.execute("guide.select", &json!({"indexes": all})).unwrap();
s
}
/// Selects the objects `ids` and the ruler guides `guides`, and nothing else.
fn select_with_guides(s: &mut Session, ids: &[NodeId], guides: &[usize]) {
s.execute("select.set", &json!({"ids": ids.iter().map(|id| id.0).collect::<Vec<_>>()})).unwrap();
let now = s.doc().unwrap().selection.guides.clone();
let flip: Vec<usize> = (0..s.doc().unwrap().doc.guides.len()).filter(|i| now.contains(i) != guides.contains(i)).collect();
s.execute("guide.select", &json!({"indexes": flip, "toggle": true})).unwrap();
assert_eq!((s.doc().unwrap().selection.objects.len(), s.doc().unwrap().selection.guides.len()), (ids.len(), guides.len()));
}
fn positions(s: &Session) -> Vec<f64> {
s.doc().unwrap().doc.guides.iter().map(|g| g.pos).collect()
}
#[test]
fn guides_selected_alone_align_to_artboard() {
let mut s = guides_alone(&[100.0, 400.0], &[50.0, 300.0]);
s.execute("object.align", &json!({"horizontal": "left", "to": "artboard", "artboard": 0})).unwrap();
assert_eq!(positions(&s), [0.0, 0.0, 50.0, 300.0]);
s.execute("edit.undo", &json!({})).unwrap();
s.execute("object.align", &json!({"horizontal": "right", "to": "artboard", "artboard": 0})).unwrap();
assert_eq!(&positions(&s)[..2], [800.0, 800.0]);
s.execute("object.align", &json!({"vertical": "top", "to": "artboard", "artboard": 0})).unwrap();
assert_eq!(positions(&s), [800.0, 800.0, 0.0, 0.0]);
}
#[test]
fn distribute_spaces_guides_selected_without_objects() {
let mut s = guides_alone(&[100.0, 150.0, 500.0], &[50.0, 60.0, 400.0]);
// A guide has no width: the three options of an axis give the same result, and only the
// guides across the axis move.
for h in ["left", "center", "right"] {
s.execute("object.distribute", &json!({"horizontal": h})).unwrap();
assert_eq!(positions(&s), [100.0, 300.0, 500.0, 50.0, 60.0, 400.0], "{h}");
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&s), [100.0, 150.0, 500.0, 50.0, 60.0, 400.0], "{h}: one undo step");
}
s.execute("object.distribute", &json!({"vertical": "center"})).unwrap();
assert_eq!(positions(&s), [100.0, 150.0, 500.0, 50.0, 225.0, 400.0]);
// Align to Artboard: the outermost guides go to its edges (the document is 800 by 600).
s.execute("object.distribute", &json!({"horizontal": "center", "to": "artboard", "artboard": 0})).unwrap();
assert_eq!(&positions(&s)[..3], [0.0, 400.0, 800.0]);
// Distribute Spacing: equal gaps, or `spacing` apart from the first.
let mut s = guides_alone(&[100.0, 150.0, 500.0], &[]);
s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap();
assert_eq!(positions(&s), [100.0, 300.0, 500.0]);
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap();
assert_eq!(positions(&s), [100.0, 110.0, 120.0]);
s.execute("object.distributeSpacing", &json!({"axis": "vertical", "spacing": 10})).unwrap();
assert_eq!(positions(&s), [100.0, 110.0, 120.0], "no horizontal guide to space");
}
#[test]
fn distributing_guides_needs_two_items() {
// Two guides are the ends already; one guide can't be distributed.
let mut s = guides_alone(&[100.0, 500.0], &[]);
let undo = s.doc().unwrap().history.undo.len();
s.execute("object.distribute", &json!({"horizontal": "center"})).unwrap();
assert_eq!((positions(&s), s.doc().unwrap().history.undo.len()), (vec![100.0, 500.0], undo));
s.execute("guide.select", &json!({"indexes": [0]})).unwrap();
assert!(s.execute("object.distribute", &json!({"horizontal": "center"})).is_err());
// One object and one guide are enough for Distribute Spacing.
let a = rect(&mut s, 300.0, 100.0);
select_with_guides(&mut s, &[a], &[0]);
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap();
assert_eq!((positions(&s), bounds(&s, a).x0), (vec![100.0, 500.0], 110.0));
}
#[test]
fn guides_selected_with_objects_are_distributed_with_them() {
// 40 pt objects at 100 and 300, vertical guides at 150 and 400, and a horizontal guide.
let mut s = guides_alone(&[150.0, 400.0], &[50.0]);
let ids: Vec<NodeId> = [100.0, 300.0].iter().map(|&x| rect(&mut s, x, 100.0)).collect();
select_with_guides(&mut s, &ids, &[0, 1, 2]);
// Lines 100, 150 (a guide), 300 and 400 (a guide): the guide between the objects goes to
// 200, and the outermost items stay. The horizontal guide stays too.
s.execute("object.distribute", &json!({"horizontal": "left"})).unwrap();
assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 300.0], vec![200.0, 400.0, 50.0]));
s.execute("edit.undo", &json!({})).unwrap();
assert_eq!(positions(&s), [150.0, 400.0, 50.0], "one undo step");
// Distribute Spacing: a guide has no width. From 100 to 360 there are 260 pt, 80 of them
// objects: three gaps of 60 pt.
s.execute("guide.move", &json!({"index": 1, "pos": 360})).unwrap();
select_with_guides(&mut s, &ids, &[0, 1, 2]);
s.execute("object.distributeSpacing", &json!({"axis": "horizontal"})).unwrap();
assert_eq!((lefts(&s, &ids), positions(&s)), (vec![100.0, 260.0], vec![200.0, 360.0, 50.0]));
// Align to Artboard: the outermost items go to its edges (the document is 800 wide).
s.execute("object.distribute", &json!({"horizontal": "left", "to": "artboard", "artboard": 0})).unwrap();
assert_eq!(lefts(&s, &ids)[0], 0.0);
assert_eq!(positions(&s)[1], 800.0);
}
#[test]
fn selected_guides_are_spaced_from_a_key_object() {
// Lines 100, 150 (a guide) and 300 (the key object), 10 pt apart outward from the key.
let mut s = guides_alone(&[150.0], &[]);
let ids: Vec<NodeId> = [100.0, 300.0].iter().map(|&x| rect(&mut s, x, 100.0)).collect();
select_with_guides(&mut s, &ids, &[0]);
s.execute("select.key", &json!({"id": ids[1].0})).unwrap();
s.execute("object.distribute", &json!({"horizontal": "left", "spacing": 10})).unwrap();
assert_eq!((lefts(&s, &ids), positions(&s)), (vec![280.0, 300.0], vec![290.0]));
// Distribute Spacing: the object ends 10 pt before the guide, the guide 10 pt before the key.
s.execute("object.distributeSpacing", &json!({"axis": "horizontal", "spacing": 10})).unwrap();
assert_eq!((lefts(&s, &ids), positions(&s)), (vec![240.0, 300.0], vec![290.0]));
}