From 80eb5844c7f7979d8c7a025547858ea2c7c04c6f Mon Sep 17 00:00:00 2001 From: theironchef <6278036+theironchef@users.noreply.github.com> Date: Tue, 6 Oct 2026 12:16:02 -0700 Subject: [PATCH] Selection tool: Shift+drag on a selected object moves it, constrained Pressing with Shift on an object that was already selected toggled it out of the selection at once and went idle, so the drag that followed moved nothing. The toggle now waits for the release: a Shift-click still takes the object out of the selection, while a Shift-drag moves the selection constrained, as it already did for unselected objects and when Shift was pressed mid-drag. Fixes #224. Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_01U9TrG4w49oSRYbP3cqxA3D --- crates/tools/src/select.rs | 66 ++++++++++++++++++++++++++++++++++---- 1 file changed, 59 insertions(+), 7 deletions(-) diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 872f0313..2cc3d09e 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -21,6 +21,9 @@ enum State { Moving { start: Point, began: bool, + /// Shift-pressed on this selected object: released without a drag, it leaves the + /// selection. + deselect: Option, }, Scaling { handle: Handle, @@ -141,16 +144,19 @@ impl Tool for SelectionTool { Some(h) => { let top = h.top_object(cx.isolation); let mut out = vec![]; + let mut deselect = None; if m.shift { - out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 }))); + // A Shift-click takes a selected object out of the selection when it + // is released; a Shift-drag moves the selection, constrained. if cx.selection.contains(top) { - self.state = State::Idle; - return out; + deselect = Some(top); + } else { + out.push(Action::Exec("select.toggle".into(), json!({ "id": top.0 }))); } } else if !cx.selection.contains(top) { out.push(Action::Exec("select.set".into(), json!({ "ids": [top.0] }))); } - self.state = State::Moving { start: p, began: false }; + self.state = State::Moving { start: p, began: false, deselect }; out } None => { @@ -185,7 +191,7 @@ impl Tool for SelectionTool { d = vectorcraft_geom::Vec2::new((b.x0 + d.x).round() - b.x0, (b.y0 + d.y).round() - b.y0); self.guides.clear(); } - self.state = State::Moving { start, began: true }; + self.state = State::Moving { start, began: true, deselect: None }; self.measure = Some((p, cx.offset_label(d.x, d.y))); out.push(Action::Preview("object.transform".into(), json!({ "matrix": matrix_json(Affine::translate(d)), "copy": m.alt }))); out @@ -215,12 +221,16 @@ impl Tool for SelectionTool { self.state = State::Idle; c.finish() } - (PointerKind::Up, State::Moving { began, .. }) => { + (PointerKind::Up, State::Moving { began, deselect, .. }) => { self.state = State::Idle; self.measure = None; self.guides.clear(); self.targets = None; - if began { vec![Action::Commit] } else { vec![] } + match (began, deselect) { + (true, _) => vec![Action::Commit], + (false, Some(id)) => vec![Action::Exec("select.toggle".into(), json!({ "id": id.0 }))], + (false, None) => vec![], + } } (PointerKind::Up, State::Scaling { .. } | State::Rotating { .. }) => { self.state = State::Idle; @@ -339,6 +349,48 @@ mod tests { assert_eq!(a, vec![Action::Commit]); } + #[test] + fn shift_drag_on_a_selected_object_moves_it_constrained() { + let (d, id) = doc_with_rect(); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let cx = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); + // Pressing keeps the selection: nothing is toggled yet. + assert!(t.pointer(&cx, &shift(PointerKind::Down, 150.0, 150.0)).is_empty()); + let a = t.pointer(&cx, &shift(PointerKind::Drag, 190.0, 160.0)); + assert_eq!(a[0], Action::Begin("Move".into())); + assert!( + matches!(&a[1], Action::Preview(c, v) if c == "object.transform" + && v["matrix"][4].as_f64().is_some_and(|x| x > 39.0) && v["matrix"][5] == 0.0), + "moved right, constrained to horizontal: {a:?}" + ); + assert_eq!(t.pointer(&cx, &shift(PointerKind::Up, 190.0, 160.0)), vec![Action::Commit]); + } + + #[test] + fn shift_click_toggles_an_object_on_release() { + let (d, id) = doc_with_rect(); + let p = paint(); + let shift = |kind, x, y| ev(kind, x, y).with_mods(Mods { shift: true, ..Mods::default() }); + let toggle = vec![Action::Exec("select.toggle".into(), json!({"id": id.0}))]; + // Selected: it leaves the selection when the click is released. + let mut s = Selection::default(); + s.add(id); + let cx1 = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert!(t.pointer(&cx1, &shift(PointerKind::Down, 150.0, 150.0)).is_empty()); + assert_eq!(t.pointer(&cx1, &shift(PointerKind::Up, 150.0, 150.0)), toggle); + // Not selected: it joins the selection at once, ready to be dragged. + let s = Selection::default(); + let cx2 = cx(&d, &s, &p); + let mut t = SelectionTool::default(); + assert_eq!(t.pointer(&cx2, &shift(PointerKind::Down, 150.0, 150.0)), toggle); + assert!(t.pointer(&cx2, &shift(PointerKind::Up, 150.0, 150.0)).is_empty()); + } + #[test] fn marquee_selects() { let (d, id) = doc_with_rect();