From c8082facb1c20a2a89ffebf3ae4cfa20e0b86401 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Wed, 21 Oct 2026 21:56:30 +0000 Subject: [PATCH] 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 --- crates/engine/src/cmd/object.rs | 47 ++++++++++++++++++++++------- crates/engine/src/tests_keyguide.rs | 16 ++++++++-- crates/ui-egui/src/panels/align.rs | 29 ++++++++++++++++-- 3 files changed, 77 insertions(+), 15 deletions(-) diff --git a/crates/engine/src/cmd/object.rs b/crates/engine/src/cmd/object.rs index 4817d38a0..4b5ccc8e3 100644 --- a/crates/engine/src/cmd/object.rs +++ b/crates/engine/src/cmd/object.rs @@ -252,8 +252,8 @@ pub fn specs() -> Vec { "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 { + st.selection.guides.iter().filter_map(|&i| st.doc.guides.get(i)).fold(None, |acc: Option, 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 { 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 { 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> { - 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()) } diff --git a/crates/engine/src/tests_keyguide.rs b/crates/engine/src/tests_keyguide.rs index 2c3dcfcdf..9e45ae0f5 100644 --- a/crates/engine/src/tests_keyguide.rs +++ b/crates/engine/src/tests_keyguide.rs @@ -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 { 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]); diff --git a/crates/ui-egui/src/panels/align.rs b/crates/ui-egui/src/panels/align.rs index 36cafab8f..1f79030d2 100644 --- a/crates/ui-egui/src/panels/align.rs +++ b/crates/ui-egui/src/panels/align.rs @@ -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 = 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::>(); + 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