From a22bced150146d42d94438d9abaceb788d033c4d Mon Sep 17 00:00:00 2001 From: redrun6767 Date: Sat, 10 Oct 2026 17:35:54 +0000 Subject: [PATCH] Guides: Shift selects ruler guides and art together A Shift-click on a guide adds it to the selected art (guide.select toggle keeps the art), and Shift-adding or -removing art keeps the selected guides; a new selection still clears them. With both selected, Delete deletes the art and the guides in one undo step (not when ids are given). Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1 --- crates/doc/src/selection.rs | 24 ++++++++++++++++---- crates/engine/src/cmd/docmenu.rs | 15 +++++-------- crates/engine/src/cmd/edit.rs | 6 +++++ crates/engine/src/tests_menucmds.rs | 34 +++++++++++++++++++++++++++++ 4 files changed, 66 insertions(+), 13 deletions(-) diff --git a/crates/doc/src/selection.rs b/crates/doc/src/selection.rs index a64b17aa..e1e98de1 100644 --- a/crates/doc/src/selection.rs +++ b/crates/doc/src/selection.rs @@ -29,8 +29,9 @@ pub struct Selection { /// clears them. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub slices: Vec, - /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. They are - /// selected on their own: any change to the object selection clears them. + /// Ruler guides selected with a selection tool, as indexes into [`Document::guides`]. A new + /// selection ([`Self::set`]) clears them; Shift-adding or -removing objects keeps them, so + /// guides and objects can be selected together, in either order. #[serde(default, skip_serializing_if = "Vec::is_empty")] pub guides: Vec, } @@ -66,7 +67,6 @@ impl Selection { pub fn add(&mut self, id: NodeId) { self.target = None; self.slices.clear(); - self.guides.clear(); if !self.objects.contains(&id) { self.objects.push(id); } @@ -74,7 +74,6 @@ impl Selection { pub fn remove(&mut self, id: NodeId) { self.target = None; self.slices.clear(); - self.guides.clear(); self.objects.retain(|x| *x != id); self.anchors.remove(&id); if self.key == Some(id) { @@ -99,6 +98,15 @@ impl Selection { } } } + /// Add ruler guide `i` to the selection, or take it out, keeping the selected objects (a + /// Shift-click on a guide). + pub fn toggle_guide(&mut self, i: usize) { + if let Some(k) = self.guides.iter().position(|g| *g == i) { + self.guides.remove(k); + } else { + self.guides.push(i); + } + } pub fn toggle(&mut self, id: NodeId) { if self.contains(id) { self.remove(id) } else { self.add(id) } } @@ -180,8 +188,16 @@ mod tests { s.set_guides([2, 0, 2]); assert_eq!((s.objects.clone(), s.guides.clone()), (vec![], vec![2, 0])); assert!(s.has_objects_or_guides() && s.is_empty()); + // Shift-adding an object keeps them; a new selection doesn't. s.add(NodeId(1)); + assert_eq!(s.guides, vec![2, 0]); + s.set([NodeId(1)]); assert!(s.guides.is_empty()); + // Toggled, a guide joins (or leaves) the selected objects. + s.toggle_guide(0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![0])); + s.toggle_guide(0); + assert_eq!((s.objects.clone(), s.guides.clone()), (vec![NodeId(1)], vec![])); // Pruning drops guides the document no longer has. let mut d = Document::new(100.0, 100.0); d.guides.push(crate::Guide::new(true, 10.0)); diff --git a/crates/engine/src/cmd/docmenu.rs b/crates/engine/src/cmd/docmenu.rs index bf38e445..973d501d 100644 --- a/crates/engine/src/cmd/docmenu.rs +++ b/crates/engine/src/cmd/docmenu.rs @@ -178,7 +178,7 @@ pub fn specs() -> Vec { "Select Guides", [], None, - "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead → {selected: [index…]}", + "{indexes: [index…], toggle?: bool} select ruler guides on their own (deselecting the art); toggle adds or removes them instead, keeping the selected art (a Shift-click on a guide) → {selected: [index…]}", guides_unlocked, guide_select ), @@ -539,15 +539,12 @@ fn guide_select(s: &mut Session, p: &Value) -> Result { let list = p.get("indexes").and_then(Value::as_array).ok_or_else(|| bad("guide.select", "missing indexes"))?; let picked = list.iter().map(|v| guide_index(s, Some(v), "guide.select")).collect::>>()?; if bool_or(p, "toggle", false) { - let mut now = s.doc()?.selection.guides.clone(); - for i in picked { - if let Some(k) = now.iter().position(|g| *g == i) { - now.remove(k); - } else { - now.push(i); + // Added to (or taken from) what is selected, the art included. + s.select(|_, sel| { + for i in picked { + sel.toggle_guide(i); } - } - s.select(|_, sel| sel.set_guides(now))?; + })?; } else { s.select(|_, sel| sel.set_guides(picked))?; } diff --git a/crates/engine/src/cmd/edit.rs b/crates/engine/src/cmd/edit.rs index d5e1b18a..7c22774b 100644 --- a/crates/engine/src/cmd/edit.rs +++ b/crates/engine/src/cmd/edit.rs @@ -268,6 +268,8 @@ fn clear(s: &mut Session, p: &Value) -> Result { return super::docmenu::guide_remove(s, &json!({})); } let st = s.doc()?; + // Guides selected with the art go with it (not when ids are given). + let with_guides = explicit.is_none(); let targets = explicit.unwrap_or_else(|| st.selection.in_paint_order(&st.doc)); // Keep the selected ids themselves, without promoting compound members. Delete a selected // ancestor just once; its selected descendants leave with it. Layers themselves are kept. @@ -284,6 +286,10 @@ fn clear(s: &mut Session, p: &Value) -> Result { for id in &ids { d.remove(*id)?; } + if with_guides { + let gone = sel.guides.clone(); + d.retain_guides(sel, |i, _| !gone.contains(&i)); + } sel.clear(); Ok(()) })?; diff --git a/crates/engine/src/tests_menucmds.rs b/crates/engine/src/tests_menucmds.rs index 2d92bb2b..d852915e 100644 --- a/crates/engine/src/tests_menucmds.rs +++ b/crates/engine/src/tests_menucmds.rs @@ -823,6 +823,40 @@ fn ruler_guides_select_move_and_delete() { assert!(matches!(s.execute("edit.clear", &json!({})), Err(EngineError::Disabled(..)))); } +/// Shift selects ruler guides and art together, in either order; Delete takes both in one step. +#[test] +fn guides_and_art_are_shift_selected_together() { + let mut s = session(); + let a = rect(&mut s, 10.0, 10.0, 40.0, 20.0); + let b = rect(&mut s, 70.0, 50.0, 60.0, 30.0); + s.execute("guide.add", &json!({"vertical": true, "pos": 200})).unwrap(); + s.execute("guide.add", &json!({"vertical": false, "pos": 300})).unwrap(); + // Art first, then a Shift-click on a guide: the art stays selected. + sel(&mut s, &[a]); + s.execute("guide.select", &json!({"indexes": [0], "toggle": true})).unwrap(); + assert_eq!((selected(&s), selected_guides(&s)), (vec![a], vec![0])); + // A guide first, then Shift-added art: the guide stays selected. + s.execute("guide.select", &json!({"indexes": [1]})).unwrap(); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + assert_eq!((selected(&s), selected_guides(&s)), (vec![b], vec![1])); + // A new selection drops them. + sel(&mut s, &[a]); + assert!(selected_guides(&s).is_empty()); + // Delete takes the art and the guides, in one step; undo brings both back. + s.execute("guide.select", &json!({"indexes": [1], "toggle": true})).unwrap(); + let n = undo_len(&s); + s.execute("edit.clear", &json!({})).unwrap(); + assert_eq!((guides(&s), undo_len(&s)), (vec![(true, 200.0)], n + 1)); + assert!(s.doc().unwrap().doc.node(a).is_none() && s.doc().unwrap().doc.node(b).is_some()); + s.execute("edit.undo", &json!({})).unwrap(); + assert_eq!(guides(&s).len(), 2); + // Ids given: only those go, the guides stay. + s.execute("guide.select", &json!({"indexes": [0]})).unwrap(); + s.execute("select.add", &json!({"ids": [b.0]})).unwrap(); + s.execute("edit.clear", &json!({"ids": [b.0]})).unwrap(); + assert_eq!(guides(&s).len(), 2); +} + /// #451: an artboard guide (`guide.add {artboard}`) runs across its artboard only, and is moved, /// copied and deleted with it; canvas guides stay put. #[test]