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>
This commit is contained in:
Cursor Agent
2026-10-22 05:20:10 +07:00
committed by bflatastic
co-authored by nrq6993-a11y
parent 430741f074
commit c8082facb1
3 changed files with 77 additions and 15 deletions
+36 -11
View File
@@ -252,8 +252,8 @@ pub fn specs() -> Vec<CommandSpec> {
"Align",
["Window", "Align"],
None,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), 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 align instead: to their own bounds, or to the artboard (#1180)",
has_selection,
"{horizontal?: \"left\"|\"center\"|\"right\", vertical?: \"top\"|\"center\"|\"bottom\", to?: \"selection\"|\"artboard\"|\"key\" (default: the key object when the selection has one, select.key, else the selection; a key guide, select.key {guide}, stays put and the objects line up on its line: a vertical guide sets where left, center and right go and vertical alignment leaves them, a horizontal one the other way round; aligning to the key, the other selected ruler guides move too: a vertical guide to the key's left edge, center or right edge, a horizontal one to its top, center or bottom; aligning to the artboard moves selected guides to its edges too (#3463), and with only guides selected aligning to the selection moves them to their collective bounds), artboard?: index (0-based; with to: \"artboard\", the artboard to align to; the app passes the active one; default: the artboard under the center of the selection, else the first), 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 align instead: to their own bounds, or to the artboard (#1180)",
has_selection_or_guides,
align
),
cmd!(
@@ -1361,11 +1361,22 @@ fn align_reference(s: &Session, p: &Value, ids: &[NodeId], preview: bool, own: O
Ok((r, true, true))
}
/// Bounds of the selected ruler guides (each guide is a zero-thickness line), for Align to
/// Selection when no objects are selected.
fn selected_guides_bounds(st: &crate::DocState) -> Option<Rect> {
st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option<Rect>, g| {
let r = if g.vertical { Rect::new(g.pos, 0.0, g.pos, 0.0) } else { Rect::new(0.0, g.pos, 0.0, g.pos) };
Some(acc.map(|a| a.union(r)).unwrap_or(r))
})
}
fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Result<Rect> {
let st = s.doc()?;
match align_to(s, p) {
Some("artboard") => {
let b = effects::preview::bounds_of(&st.doc, ids, preview).unwrap_or_default();
let b = effects::preview::bounds_of(&st.doc, ids, preview)
.or_else(|| ids.is_empty().then(|| selected_guides_bounds(st)).flatten())
.unwrap_or_default();
// Align to Artboard uses artboard `artboard` (the app passes the active one). Without
// it, or with an index the document doesn't have, it uses the artboard under the center
// of the selection, or the first one when no artboard is there.
@@ -1377,7 +1388,13 @@ fn reference_rect(s: &Session, p: &Value, ids: &[NodeId], preview: bool) -> Resu
let k = st.selection.key.ok_or_else(|| EngineError::Other("no key object".into()))?;
effects::preview::bounds_of(&st.doc, &[k], preview).ok_or(EngineError::NoNode(k))
}
_ => effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into())),
_ => {
if ids.is_empty() {
selected_guides_bounds(st).ok_or_else(|| EngineError::Other("nothing to align".into()))
} else {
effects::preview::bounds_of(&st.doc, ids, preview).ok_or_else(|| EngineError::Other("nothing to align".into()))
}
}
}
}
@@ -1443,15 +1460,22 @@ fn align(s: &mut Session, p: &Value) -> Result<Value> {
apply_moves(s, "Align", p, &ids, &moves, &guides)
}
/// The selected ruler guides that Align to the key object (or key guide) moves, and where: a
/// vertical guide goes to the left edge, centre or right edge of `r` that `h` names, a horizontal
/// one to the top, centre or bottom that `v` names. The key guide stays put, and so do guides
/// when aligning to the selection or the artboard.
/// The selected ruler guides that Align moves, and where: a vertical guide goes to the left edge,
/// centre or right edge of `r` that `h` names, a horizontal one to the top, centre or bottom that
/// `v` names. Align to the key object (or key guide) moves them too, and the key guide stays put.
/// Align to the artboard moves them (#3463). Align to the selection moves them when only guides are
/// selected; with objects in the selection they stay put.
fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>) -> Result<Vec<(usize, f64)>> {
if align_to(s, p) != Some("key") {
let st = s.doc()?;
let to = align_to(s, p);
let move_guides = match to {
Some("key" | "artboard") => true,
Some("selection") => st.selection.is_empty(),
_ => false,
};
if !move_guides {
return Ok(vec![]);
}
let st = s.doc()?;
let at = |vertical: bool| match (vertical, h, v) {
(true, Some("left"), _) => Some(r.x0),
(true, Some("center"), _) => Some(r.center().x),
@@ -1461,11 +1485,12 @@ fn guide_moves(s: &Session, p: &Value, r: Rect, h: Option<&str>, v: Option<&str>
(false, _, Some("bottom")) => Some(r.y1),
_ => None,
};
let skip_key = to == Some("key");
Ok(st
.selection
.guides
.iter()
.filter(|&&i| Some(i) != st.selection.key_guide)
.filter(|&&i| !skip_key || Some(i) != st.selection.key_guide)
.filter_map(|&i| Some((i, at(st.doc.guides.get(i)?.vertical)?)))
.collect())
}
+14 -2
View File
@@ -153,9 +153,9 @@ fn selected_guides_align_to_a_clicked_key_object() {
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 leaves the guides alone.
// 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), [120.0, 110.0]);
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]);
@@ -310,6 +310,18 @@ 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]);
+27 -2
View File
@@ -223,6 +223,8 @@ fn align_to(app: &VectorcraftApp) -> AlignTo {
pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
let n = selection_len(app);
let guides = selected_guides(app);
let can_align = n + guides >= 1;
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 or a key guide (#2143).
@@ -236,12 +238,12 @@ pub fn show(app: &mut VectorcraftApp, ui: &mut Ui) {
for (i, (icon, tip, axis, v)) in ALIGN.iter().enumerate() {
if i == 3 {
ui.add_space(6.0);
if widgets::icon_button_enabled(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, n >= 1, 32.0).clicked() {
if widgets::icon_button_enabled(ui, "dc-al-center", tl!("Horizontal & Vertical Align Center"), false, can_align, 32.0).clicked() {
run_button(app, Button::AlignCenters).ok();
}
ui.add_space(6.0);
}
if widgets::icon_button_enabled(ui, icon, tl!(tip), false, n >= 1, 32.0).clicked() {
if widgets::icon_button_enabled(ui, icon, tl!(tip), false, can_align, 32.0).clicked() {
run_button(app, Button::Align(axis, v)).ok();
}
}
@@ -883,6 +885,29 @@ mod tests {
assert_eq!(app.session.active().unwrap().doc.guides[0].pos, 100.0, "the guide stays put");
}
/// Three vertical ruler guides selected without objects: Align Objects buttons are on, and
/// Horizontal Align Left puts every guide on the artboard's left edge (#3463).
#[test]
fn the_align_buttons_align_guides_selected_alone_to_artboard() {
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();
app.ui.align_to = AlignTo::Artboard;
let ctx = egui::Context::default();
frame_events(&ctx, &mut app, vec![], show);
let row = widget_rects(&ctx, egui::vec2(32.0, 32.0));
let top = row.iter().map(|r| r.top()).fold(f32::MAX, f32::min);
let mut row: Vec<egui::Rect> = row.into_iter().filter(|r| r.top() == top).collect();
row.sort_by(|a, b| a.left().total_cmp(&b.left()));
assert!(row[0].width() > 0.0, "Horizontal Align Left is enabled");
click(&ctx, &mut app, row[0].center(), show);
let pos = |app: &VectorcraftApp| app.session.active().unwrap().doc.guides.iter().map(|g| g.pos).collect::<Vec<_>>();
assert_eq!(pos(&app), [0.0, 0.0, 0.0], "{}", app.ui.status);
}
/// 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