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 <[email protected]>
Claude-Session: https://claude.ai/code/session_01CC5DiGJEZ5ADaQMBEQk4W1
This commit is contained in:
redrun6767andClaude Opus 5.5 authored and bflatastic committed 2026-10-11 04:35:15 +07:00
1 parent a9724275cc
commit a22bced150
4 files changed
+66 -13

No files matched your search

+20 -4
View File
@@ -29,8 +29,9 @@ pub struct Selection {
/// clears them.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub slices: Vec<NodeId>,
/// 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<usize>,
}
@@ -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));
+6 -9
View File
@@ -178,7 +178,7 @@ pub fn specs() -> Vec<CommandSpec> {
"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<Value> {
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::<Result<Vec<_>>>()?;
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))?;
}
+6
View File
@@ -268,6 +268,8 @@ fn clear(s: &mut Session, p: &Value) -> Result<Value> {
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<Value> {
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(())
})?;
+34
View File
@@ -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]