Merge pull request #3181 from danngreen/feat/distribute-with-guides

Distribute: selected ruler guides are spaced with the objects
This commit is contained in:
BFlat
2026-10-21 16:19:52 +07:00
committed by GitHub
6 changed files with 440 additions and 49 deletions
+99 -34
View File
@@ -252,7 +252,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key object, select.key: the key object stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; not used without a key object or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the selection; \"key\" distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object), 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) in their order on that line: without spacing and a key object they are spaced evenly, the first and last objects stay put, and fewer than three objects don't move; with spacing and a key object, see spacing (two objects are enough); 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)",
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", spacing?: pt (used with a key, select.key: the key object, or the key guide, select.key {guide}, stays put and the lines are placed this far apart, outward from its line, in their order on that line; 0 puts them all on its line; a key guide along the axis leaves everything where it is; not used without a key or with anchor points selected), to?: \"selection\"|\"artboard\"|\"key\" (default: the key while there is one, else the selection; \"key\" without spacing distributes as \"selection\" does; not used with anchor points selected, or with spacing and a key object; with a key guide, \"selection\" or \"artboard\" distributes without it as the key), 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 selected objects' left edges, centers or right edges (top edges, centers or bottom edges) in their order on that line, with the selected ruler guides across the axis (vertical ones for horizontal; #1597): a guide has no size, so its edges and center are its line, and guides along the axis stay. Without spacing and a key they are spaced evenly, the first and last stay put, and fewer than three don't move; with spacing and a key, see spacing (two are enough); 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 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
),
@@ -261,7 +261,7 @@ pub fn specs() -> Vec<CommandSpec> {
"Distribute Spacing",
["Window", "Align"],
None,
"{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)",
"{axis: \"horizontal\"|\"vertical\", spacing?: pt (the key object, select.key, or the key guide, select.key {guide}, stays put and the others are spaced from it; a key guide along the axis leaves everything where it is), to?: \"selection\"|\"artboard\"|\"key\" (used without spacing and without anchor points selected, and with a key guide; default: the key while there is one, else the selection; \"key\" without spacing 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 selected objects equal, in the order of their left (top) edges, with the selected ruler guides across the axis as items with no size (#1597; guides along the axis stay): the first item'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
),
@@ -1473,10 +1473,68 @@ fn align_anchors(s: &mut Session, p: &Value, ids: &[NodeId], (h, v): (Option<&st
ok()
}
/// Distribute needs two or more objects, or anchor points selected with Direct Selection (they
/// may all be on one path).
/// Distribute needs two or more objects and ruler guides, or anchor points selected with Direct
/// Selection (they may all be on one path).
fn can_distribute(s: &Session) -> std::result::Result<(), String> {
if s.active().is_some_and(|st| !st.selection.anchors.is_empty()) { Ok(()) } else { has_multi(s) }
if s.active().is_some_and(|st| !st.selection.anchors.is_empty() || st.selection.len() + st.selection.guides.len() >= 2) {
Ok(())
} else {
has_multi(s)
}
}
/// What Distribute and Distribute Spacing space: a selected object, or a selected ruler guide
/// across the axis (#1597), which has no size.
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum Spaced {
Object(NodeId),
Guide(usize),
}
/// The selected objects `ids` with their bounds (`preview`: visual bounds), and the selected
/// ruler guides across the axis (vertical ones when `horiz`) with their line as their bounds.
/// Guides along the axis are left out, so they stay where they are.
fn spaced_items(s: &Session, ids: &[NodeId], preview: bool, horiz: bool) -> Result<Vec<(Spaced, Rect)>> {
let st = s.doc()?;
let objects = items_bounds(&st.doc, ids, preview).into_iter().map(|(id, r)| (Spaced::Object(id), r));
let guides = st.selection.guides.iter().filter_map(|&i| {
let g = st.doc.guides.get(i).filter(|g| g.vertical == horiz)?;
Some((Spaced::Guide(i), Rect::new(g.pos, g.pos, g.pos, g.pos)))
});
Ok(objects.chain(guides).collect())
}
/// The index in `items` of what Distribute and Distribute Spacing keep in place with a spacing:
/// the selected root that is the key object or contains it ([`key_root`]), or the key guide when
/// aligning to the key (#2143).
fn spaced_key(s: &Session, p: &Value, ids: &[NodeId], items: &[(Spaced, Rect)]) -> Option<usize> {
let key = match key_root(s, ids) {
Some(id) => Spaced::Object(id),
None => key_guide(s, p).and(s.doc().ok()?.selection.key_guide).map(Spaced::Guide)?,
};
items.iter().position(|(it, _)| *it == key)
}
/// Whether the key is a guide along the axis (a horizontal guide for a horizontal distribution),
/// aligning to the key. It has no line there, so a spacing from it leaves everything where it
/// is, as Align does.
fn key_guide_along(s: &Session, p: &Value, horiz: bool) -> bool {
key_guide(s, p).is_some_and(|(_, vertical)| vertical != horiz)
}
/// Moves each of `deltas` that far along the axis (x when `horiz`) as undo step `label`, as
/// [`apply_moves`] does: an object by that offset, a ruler guide to its new position.
fn apply_spaced(s: &mut Session, label: &str, p: &Value, ids: &[NodeId], horiz: bool, deltas: &[(Spaced, f64)]) -> Result<Value> {
let along = |d: f64| if horiz { Vec2::new(d, 0.0) } else { Vec2::new(0.0, d) };
let (mut moves, mut guides) = (vec![], vec![]);
let d = &s.doc()?.doc;
for &(it, delta) in deltas {
match it {
Spaced::Object(id) => moves.push((id, along(delta))),
Spaced::Guide(i) => guides.extend(d.guides.get(i).map(|g| (i, g.pos + delta))),
}
}
apply_moves(s, label, p, ids, &moves, &guides)
}
/// [`distribute`] and [`distribute_spacing`] of the selected anchor points (whole paths among them
@@ -1530,7 +1588,6 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
(h, _) => distribute_anchors(s, "Distribute", h.is_some(), None),
};
}
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
let (horiz, key): (bool, fn(&Rect, bool) -> f64) = match direction(p, "object.distribute")? {
(Some(h), _) => (
true,
@@ -1550,41 +1607,46 @@ fn distribute(s: &mut Session, p: &Value) -> Result<Value> {
),
_ => return Err(bad("object.distribute", "give horizontal or vertical")),
};
// The selected guides across the axis are spaced with the objects (#1597). A guide has no
// size, so its left edge, center and right edge are all its line.
let mut items = spaced_items(s, &ids, preview, horiz)?;
items.sort_by(|a, b| key(&a.1, horiz).total_cmp(&key(&b.1, horiz)));
let n = items.len();
// With a spacing, the key object (or the compound path or selected group it is in) stays where
// it is, and the lines are placed `spacing` apart, outward from its line, in their order.
// Without a key object the spacing is not used. `from_key` is the key's index in `items` and
// the spacing.
let from_key = spacing.zip(key_root(s, &ids)).and_then(|(g, k)| Some((items.iter().position(|(id, _)| *id == k)?, g)));
// Two objects are enough on an artboard, where they go to its edges, and when they are spaced
// from a key object.
if spacing.is_some() && key_guide_along(s, p, horiz) {
return ok();
}
// With a spacing, the key object (or the compound path or selected group it is in) or the key
// guide stays where it is, and the lines are placed `spacing` apart, outward from its line, in
// their order. Without a key the spacing is not used. `from_key` is the key's index in
// `items` and the spacing.
let from_key = spacing.zip(spaced_key(s, p, &ids, &items)).map(|(g, k)| (k, g));
// Two items are enough on an artboard, where they go to its edges, and when they are spaced
// from a key.
if n < 2 || (n < 3 && board.is_none() && from_key.is_none()) {
return ok();
}
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
// `first` and `last` are the lines that the first and last items end on. Without an artboard
// the first and last items stay where they are. On an artboard the first item moves until
// its left (top) edge is on the artboard's left (top) edge, and the last item 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
let deltas: Vec<(Spaced, f64)> = items
.iter()
.enumerate()
.map(|(i, (id, r))| {
.map(|(i, (it, r))| {
let target = match from_key {
Some((k, g)) => key(&items[k].1, horiz) + g * (i as f64 - k as f64),
None => first + (last - first) * i as f64 / (n - 1) as f64,
};
let delta = target - key(r, horiz);
(*id, if horiz { Vec2::new(delta, 0.0) } else { Vec2::new(0.0, delta) })
(*it, target - key(r, horiz))
})
.collect();
apply_moves(s, "Distribute", p, &ids, &moves, &[])
apply_spaced(s, "Distribute", p, &ids, horiz, &deltas)
}
fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
@@ -1596,16 +1658,21 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
if !s.doc()?.selection.anchors.is_empty() {
return distribute_anchors(s, "Distribute Spacing", horiz, spacing);
}
let mut items = items_bounds(&s.doc()?.doc, &ids, preview);
// The selected guides across the axis are spaced with the objects, as items with no size (#1597).
let mut items = spaced_items(s, &ids, preview, horiz)?;
items.sort_by(|a, b| if horiz { a.1.x0.total_cmp(&b.1.x0) } else { a.1.y0.total_cmp(&b.1.y0) });
if spacing.is_some() && key_guide_along(s, p, horiz) {
return ok();
}
let n = items.len();
let &[(_, first), .., (_, last)] = items.as_slice() else {
return Err(EngineError::Other("select two or more objects to distribute".into()));
// Selected guides along the axis have nothing to space.
return if s.doc()?.selection.guides.is_empty() { Err(EngineError::Other("select two or more objects to distribute".into())) } else { ok() };
};
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
// The items start at `lo`, and without a spacing the last one ends at `hi`. These are the
// first item'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),
@@ -1619,17 +1686,15 @@ fn distribute_spacing(s: &mut Session, p: &Value) -> Result<Value> {
};
let mut pos = lo;
let mut deltas = vec![];
for (id, r) in &items {
deltas.push((*id, pos - start(r)));
for (it, r) in &items {
deltas.push((*it, pos - start(r)));
pos += size(r) + gap;
}
// With a spacing (aligning to a key object) the key object stays where it is, with the
// compound path or selected group it is in: the others are spaced from it.
let key = key_root(s, &ids);
let fixed = spacing.and_then(|_| deltas.iter().find(|(id, _)| Some(*id) == key)).map_or(0.0, |k| k.1);
let moves: Vec<(NodeId, Vec2)> =
deltas.into_iter().map(|(id, d)| (id, if horiz { Vec2::new(d - fixed, 0.0) } else { Vec2::new(0.0, d - fixed) })).collect();
apply_moves(s, "Distribute Spacing", p, &ids, &moves, &[])
// With a spacing the key stays where it is: the key object, with the compound path or
// selected group it is in, or the key guide (#2143). The others are spaced from it.
let fixed = spacing.and_then(|_| spaced_key(s, p, &ids, &items)).and_then(|k| deltas.get(k)).map_or(0.0, |k| k.1);
let deltas: Vec<(Spaced, f64)> = deltas.into_iter().map(|(it, d)| (it, d - fixed)).collect();
apply_spaced(s, "Distribute Spacing", p, &ids, horiz, &deltas)
}
fn set_bounds(s: &mut Session, p: &Value) -> Result<Value> {
+207 -1
View File
@@ -1,4 +1,5 @@
//! #2143: a ruler guide selected with objects can be the key object Align lines them up on.
//! #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};
@@ -183,3 +184,208 @@ fn guides_align_to_a_key_object_among_others_and_parallel_ones_to_a_key_guide()
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 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]));
}
+35
View File
@@ -138,6 +138,41 @@ fn distribute_with_a_huge_spacing_from_the_key_object() {
}
}
#[test]
fn distribute_with_a_huge_spacing_from_a_key_guide() {
for (vertical, pos) in [(true, 50.0), (true, 10000.0), (false, -10000.0)] {
for p in [json!({"horizontal": "left", "spacing": 1e308}), json!({"vertical": "bottom", "spacing": -1e308})] {
let mut s = Fixture::Multi.session();
s.execute("guide.add", &json!({"vertical": vertical, "pos": pos})).unwrap();
s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap();
s.execute("select.key", &json!({"guide": 0})).unwrap();
if no_panic(s.execute("object.distribute", &p)).is_ok() {
check_session(&s).unwrap_or_else(|e| panic!("guide at {pos}, {p}: {e}"));
vectorcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("guide at {pos}, {p}: {e}"));
}
}
}
}
#[test]
fn distribute_guides_selected_alone_with_a_huge_spacing() {
for (cmd, p) in [
("object.distributeSpacing", json!({"axis": "horizontal", "spacing": 1e308})),
("object.distributeSpacing", json!({"axis": "vertical", "spacing": -1e308})),
("object.distribute", json!({"horizontal": "center", "to": "artboard", "artboard": 99})),
] {
let mut s = Fixture::Empty.session();
for (v, pos) in [(true, 10.0), (true, 20.0), (true, 9000.0), (false, -9000.0), (false, 0.0)] {
s.execute("guide.add", &json!({"vertical": v, "pos": pos})).unwrap();
}
s.execute("guide.select", &json!({"indexes": [0, 1, 2, 3, 4]})).unwrap();
if no_panic(s.execute(cmd, &p)).is_ok() {
check_session(&s).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
vectorcraft_testkit::invariants::check_native_roundtrip(&s.doc().unwrap().doc).unwrap_or_else(|e| panic!("{cmd} {p}: {e}"));
}
}
}
#[test]
fn bug_artboard_huge_props_break_svg() {
for key in ["x", "y", "width", "height"] {
+75 -7
View File
@@ -123,9 +123,9 @@ fn guide_for_key(app: &VectorcraftApp) -> Option<usize> {
app.session.active().filter(|st| !st.selection.is_empty()).and_then(|st| st.selection.guides.first().copied())
}
/// Whether the key is an object, which Distribute Spacing spaces from (not a guide).
fn has_key_object(app: &VectorcraftApp) -> bool {
app.session.active().is_some_and(|st| st.selection.key.is_some())
/// The number of selected ruler guides, which Distribute spaces with the objects (#1597).
fn selected_guides(app: &VectorcraftApp) -> usize {
app.session.active().map_or(0, |st| st.selection.guides.len())
}
/// The topmost selected object, the last in paint order.
@@ -145,12 +145,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let n = selection_len(app);
let to = align_to(app);
// `spacing` is the Distribute Spacing value. The Distribute Objects and Distribute Spacing
// buttons send it while there is a key object.
// buttons send it while there is a key object or a key guide (#2143).
let spacing: f64 = pstate::<Option<f64>>(ui.ctx(), "align-spacing").unwrap_or(0.0);
let key = to == AlignTo::Key && has_key_object(app);
let key = to == AlignTo::Key;
let sp = if key { Some(spacing) } else { None };
// Two or more objects, or anchor points, which may all be on one path.
let distributes = n >= 2 || has_anchors(app);
// Two or more objects and ruler guides (#1597), or anchor points, which may all be on one path.
let distributes = n + selected_guides(app) >= 2 || has_anchors(app);
widgets::subheader(ui, tl!("Align Objects:"));
ui.horizontal(|ui| {
ui.spacing_mut().item_spacing.x = 5.0;
@@ -633,6 +633,74 @@ mod tests {
assert_eq!(lefts(&app), [35.0, 50.0, 65.0], "15 pt apart: {}", app.ui.status);
}
/// #2143: with a key guide, the Distribute Objects and Distribute Spacing buttons send the
/// Distribute Spacing value too, and the objects are spaced from the guide, which stays put.
#[test]
fn the_distribute_buttons_send_the_spacing_with_a_key_guide() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
let ids: Vec<Value> = [(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("guide.add", json!({"vertical": true, "pos": 100})).unwrap();
app.run("select.set", json!({"ids": ids})).unwrap();
app.run("guide.select", json!({"indexes": [0], "toggle": true})).unwrap();
app.run("select.key", json!({"guide": 0})).unwrap();
let lefts = |app: &VectorcraftApp| ids.iter().map(|id| left_edge(app, id)).collect::<Vec<_>>();
let sent = |app: &VectorcraftApp| {
let (id, p) = app.session.journal.last().unwrap();
(id.clone(), p["to"].clone(), p.get("spacing").cloned())
};
let ctx = egui::Context::default();
let (left, space) = distribute_buttons(&mut app, &ctx);
click(&ctx, &mut app, left.center(), show);
assert_eq!(sent(&app), ("object.distribute".to_string(), json!("key"), Some(json!(0.0))));
assert_eq!(lefts(&app), [100.0; 3], "on the guide: {}", app.ui.status);
app.run("edit.undo", json!({})).unwrap();
set_pstate(&ctx, "align-spacing", Some(15.0));
click(&ctx, &mut app, left.center(), show);
assert_eq!(lefts(&app), [70.0, 85.0, 115.0], "15 pt apart, outward from the guide: {}", app.ui.status);
assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put");
// Horizontal Distribute Space with 15: the object before the guide ends 15 pt before it,
// the one after it starts 15 pt after it.
click(&ctx, &mut app, space.center(), show);
assert_eq!(sent(&app), ("object.distributeSpacing".to_string(), json!("key"), Some(json!(15.0))));
assert_eq!(lefts(&app), [10.0, 45.0, 115.0], "{}", app.ui.status);
assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put");
}
/// Three vertical ruler guides selected without objects: the Distribute buttons are on, and
/// Horizontal Distribute Center puts the middle guide halfway between the other two, or with
/// Align to Artboard spaces them from its left edge to its right edge, as a public forum answer
/// describes for equal columns (https://community.adobe.com/questions-652/illustrator-equal-guides-822924).
#[test]
fn the_distribute_buttons_space_guides_selected_alone() {
let mut app = VectorcraftApp::new(vectorcraft_engine::Session::new(), Default::default());
app.run("file.new", json!({"width": 200, "height": 200})).unwrap();
for pos in [20, 50, 180] {
app.run("guide.add", json!({"vertical": true, "pos": pos})).unwrap();
}
app.run("guide.select", json!({"indexes": [0, 1, 2]})).unwrap();
let ctx = egui::Context::default();
frame_events(&ctx, &mut app, vec![], show);
let row = widget_rects(&ctx, egui::vec2(32.0, 32.0));
let bottom = row.iter().map(|r| r.top()).fold(f32::MIN, f32::max);
let mut row: Vec<egui::Rect> = row.into_iter().filter(|r| r.top() == bottom).collect();
row.sort_by(|a, b| a.left().total_cmp(&b.left()));
assert_eq!(row.len(), 6, "the Distribute row");
// Its fifth button is Horizontal Distribute Center.
click(&ctx, &mut app, row[4].center(), show);
let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::<Vec<_>>();
assert_eq!(pos(&app), [20.0, 100.0, 180.0], "{}", app.ui.status);
app.run("edit.undo", json!({})).unwrap();
assert_eq!(pos(&app), [20.0, 50.0, 180.0], "one undo step");
// With Align to Artboard, the outermost guides go to its edges: equal columns across it.
app.ui.align_to = AlignTo::Artboard;
click(&ctx, &mut app, row[4].center(), show);
assert_eq!(pos(&app), [0.0, 100.0, 200.0], "{}", 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");
+19 -5
View File
@@ -1109,7 +1109,7 @@ With a key object (or a key guide), `object.align` moves the other selected rule
Aligning to the selection or the artboard leaves guides where they are.
`select.key {guide}` makes a selected ruler guide (its `guide.list` index) the key instead, while one or more objects are selected (#2143).
`object.align` then lines the objects up on the guide and the guide stays put: a vertical guide sets where their left edges, centres or right edges go and vertical alignment leaves them where they are, a horizontal guide the other way round.
Selected anchor points align to it the same way. Distribute Spacing doesn't space from a key guide.
Selected anchor points align to it the same way. `object.distribute {spacing}` and `object.distributeSpacing {spacing}` space the objects from it (see [Align to Artboard](#align-to-artboard)).
A click with the Selection tool on a guide selected with objects runs `select.key {guide}`, and a click on the key guide again runs `select.key {}`.
The key guide goes when it leaves the selection or no object is left selected, and an object key replaces it (and the other way round).
It returns `{key}`, the key object's id or null, with `guide`, the key guide's index, when a guide is the key.
@@ -2252,16 +2252,30 @@ left or top edge of the first object, in the order of those edges, on the artboa
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` without `spacing` moves nothing with fewer than
three objects. With `spacing`, `object.distributeSpacing` does not use `to`, and `object.distribute` does not use it
while the selection has a key object. With anchor points selected (Direct Selection), both commands space the points
three objects. With `spacing` and a key object, `object.distribute` and `object.distributeSpacing` do not use `to`;
with a key guide, `to` decides whether the guide is the key. With anchor points selected (Direct Selection), both commands space the points
among themselves and do not use `to` or `artboard`.
`object.distribute {spacing}` (the Distribute Objects buttons while the selection has a key object, `select.key`) keeps
the key object where it is, with the compound path or selected group that contains it. It places the objects' lines
(left edges, centers or right edges; top edges, centers or bottom edges) `spacing` points apart, outward from the key
object's line, in their order on that line. With `spacing: 0`, every object ends on the key object's line. Two objects
are enough. Without a key object, or with anchor points selected, `object.distribute` does not use `spacing`. A
`spacing` that is not a number is an error.
are enough. Without a key object or key guide, or with anchor points selected, `object.distribute` does not use
`spacing`. A `spacing` that is not a number is an error.
`object.distribute` and `object.distributeSpacing` space the selected ruler guides across the axis (vertical guides
for the horizontal options, horizontal guides for the vertical ones) with the selected objects, as items with no size
(#1597). A guide's left edge, center and right edge are all its line, so the three options of an axis give the same
result for it. Guides along the axis stay where they are. Two or more guides selected without objects are enough, and
the Align panel's buttons are enabled for them; with `to: "artboard"`, the outermost items go to the artboard's edges.
With a key guide (`select.key {guide}`, #2143), `object.distribute {spacing}` and `object.distributeSpacing {spacing}`
(the Align panel's Distribute buttons send the Distribute Spacing value then too) keep the guide where it is and
space the other items from it, as from a key object with no size: `object.distribute` places their lines `spacing`
points apart, outward from the guide's line, and `object.distributeSpacing` leaves gaps of `spacing` points, the guide
included. With `spacing: 0`, `object.distribute` puts every line on the guide. A key guide along the axis leaves
everything where it is, as `object.align` does. `to: "selection"` or `"artboard"` distributes without the key guide
as the key, as with `object.align`, and without `spacing` a key guide is spaced as any selected guide.
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
+5 -2
View File
File diff suppressed because one or more lines are too long