From a75b465419c80792fcc79b2e12eeaeeb82429591 Mon Sep 17 00:00:00 2001 From: Michael Gieson Date: Sat, 10 Oct 2026 22:46:53 -0400 Subject: [PATCH] Selection tools: Alt-drag copies snap to the original they leave behind The snap targets of a copy were gathered when Alt went down, from the document as previewed, where the original had already moved with the drag, so the original was never a target where it stays. Now both target sets (for a move, and for a copy, which includes the original) are gathered when the drag begins, and Alt picks between them each frame: for Direct Selection drags by an anchor and for whole-object moves (the Selection tool's, and Direct Selection's from a segment or fill). Co-Authored-By: Claude Opus 5.5 (1M context) --- crates/tools/src/direct.rs | 37 +++++++++++++----------- crates/tools/src/guides.rs | 15 +++++----- crates/tools/src/select.rs | 59 ++++++++++++++++++++++++++++++++------ 3 files changed, 80 insertions(+), 31 deletions(-) diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index 1e4b1c56..5b72592d 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -115,11 +115,11 @@ pub struct DirectSelectionTool { mesh: MeshEdit, /// Smart guides of the handle, anchors or objects being dragged. guides: Vec, - /// What the dragged anchors snap to, gathered when the drag begins (for a path moved whole by - /// an anchor, again when Alt turns the copy on or off: whether the original is a target). + /// What the dragged anchors snap to, gathered when the drag begins. anchor_snap: Option, - /// Whether [`Self::anchor_snap`] was gathered for a copy. - snap_for_copy: bool, + /// What the anchor holding paths moved whole snaps to while Alt makes a copy: the original left + /// behind too. Gathered with [`Self::anchor_snap`], before the previews move the original. + copy_snap: Option, /// What the dragged handle snaps to. handle_snap: HandleSnap, /// What the objects being moved snap to, gathered when the move begins. @@ -138,7 +138,7 @@ impl DirectSelectionTool { mesh: MeshEdit::default(), guides: vec![], anchor_snap: None, - snap_for_copy: false, + copy_snap: None, handle_snap: HandleSnap::default(), move_snap: None, hover: None, @@ -488,25 +488,23 @@ impl Tool for DirectSelectionTool { } out.push(Action::Begin(if ev.mods.alt { "Copy".into() } else { "Move".into() })); self.state = State::MoveObject { start, grab, began: true }; - self.anchor_snap = None; - if grab.is_none() { + if grab.is_some() { + // Alt can go down or up at any time: gather both while nothing has moved. + self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, cx.selection))); + self.copy_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &Selection::default()))); + } else { self.move_snap = Some(MoveSnap::new(cx)); } } - if grab.is_some() && (self.anchor_snap.is_none() || self.snap_for_copy != ev.mods.alt) { - // A copy leaves the original behind, so its anchors are targets too. - let stay = if ev.mods.alt { Selection::default() } else { cx.selection.clone() }; - self.anchor_snap = Some(PointSnap::new(cx, || Targets::for_anchor_drag(cx.doc, &stay))); - self.snap_for_copy = ev.mods.alt; - } let mut d = move_delta(start, p, ev.mods.shift); self.guides.clear(); - if let (Some(grab), Some(snap)) = (grab, &self.anchor_snap) { + let snap = if ev.mods.alt { &self.copy_snap } else { &self.anchor_snap }; + if let (Some(grab), Some(snap)) = (grab, snap) { let shift = ev.mods.shift.then(|| Leave::segment(cx, grab, true)); let (q, guides) = snap.snap_from(cx, grab + (p - start), shift.as_ref()); (d, self.guides) = (q - grab, guides); } else if let Some(snap) = &self.move_snap { - (d, self.guides) = snap.snap(cx, start, d); + (d, self.guides) = snap.snap(cx, start, d, ev.mods.alt); } out.push(Action::Preview( "object.transform".into(), @@ -589,7 +587,7 @@ impl Tool for DirectSelectionTool { ) => { self.state = State::Idle; self.guides.clear(); - (self.anchor_snap, self.move_snap) = (None, None); + (self.anchor_snap, self.copy_snap, self.move_snap) = (None, None, None); if began { vec![Action::Commit] } else { vec![] } } (PointerKind::Up, State::Handle { .. } | State::SpineHandle { .. }) => { @@ -1159,6 +1157,13 @@ mod tests { t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)), vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)] ); + // The tool sees the document as previewed: B moved along, not where the copy leaves it. + let (mut moved, _, b2) = two_squares(); + let l = moved.layers[0].id; + let sq = vectorcraft_geom::shapes::rectangle(Rect::new(250.0, 250.0, 350.0, 350.0)); + moved.remove(b2).unwrap(); + moved.insert(Some(l), 1, vectorcraft_doc::Node::path(b, sq, vectorcraft_doc::Appearance::default_art())).unwrap(); + let c = cx(&moved, &whole, &p); // Alt down mid-drag: a copy, whose pressed corner can land on the original's top-right one. assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(alt)), vec![preview(100.0, 0.0, true)]); // Alt up: a move again, which leaves nothing behind to snap to. diff --git a/crates/tools/src/guides.rs b/crates/tools/src/guides.rs index 065fa41b..95197153 100644 --- a/crates/tools/src/guides.rs +++ b/crates/tools/src/guides.rs @@ -179,10 +179,11 @@ impl Targets { Self::collect_inner(doc, exclude, None, visible, Gather::Bounds) } - /// Targets for moving the selection of `cx`: [`Self::collect`]'s, styled as `cx` asks, and with - /// Smart Guides › Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). - pub fn for_move(cx: &ToolContext) -> Self { - let mut t = Self::collect(cx.doc, &cx.selection.objects, None).styled(cx); + /// Targets for moving the selection of `cx`: [`Self::collect`]'s but `exclude` (the selection, + /// or nothing for a copy, which leaves it behind), styled as `cx` asks, and with Smart Guides › + /// Spacing Guides the objects it spaces itself evenly with ([`Self::snap_rect`]). + pub fn for_move(cx: &ToolContext, exclude: &[NodeId]) -> Self { + let mut t = Self::collect(cx.doc, exclude, None).styled(cx); if cx.spacing_guides { t.boxes = object_boxes(cx.doc, &cx.selection.objects); } @@ -1229,7 +1230,7 @@ mod tests { } let (s, p) = (Selection::default(), paint()); let lines = |ov: &[Overlay]| ov.iter().filter(|o| matches!(o, Overlay::Line { color, .. } if *color == MAGENTA)).count(); - let t = Targets::for_move(&cx(&d, &s, &p)); + let t = Targets::for_move(&cx(&d, &s, &p), &s.objects); // 21 after B: 20, as A and B, with both gaps marked (a line and two ticks each). let (dv, ov) = t.snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); assert_eq!(dv, Vec2::new(-1.0, 0.0)); @@ -1241,7 +1242,7 @@ mod tests { let id = wide.alloc_id(); wide.insert(Some(l), 0, Node::path(id, vectorcraft_geom::shapes::rectangle(Rect::new(220.0, 300.0, 260.0, 340.0)), Default::default())) .unwrap(); - let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p)).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0); + let (dv, ov) = Targets::for_move(&cx(&wide, &s, &p), &s.objects).snap_rect(Rect::new(148.0, 305.0, 188.0, 345.0), 4.0); assert_eq!(dv, Vec2::new(2.0, 0.0)); assert!(lines(&ov) >= 6, "{ov:?}"); // A column: 28 under D, as C and D are 30 apart. @@ -1253,7 +1254,7 @@ mod tests { assert_eq!(dv, Vec2::new(0.0, 2.0)); // Off: nowhere to go, and no marks. let off = ToolContext { spacing_guides: false, ..cx(&d, &s, &p) }; - let (dv, ov) = Targets::for_move(&off).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); + let (dv, ov) = Targets::for_move(&off, &s.objects).snap_rect(Rect::new(141.0, 305.0, 181.0, 345.0), 4.0); assert_eq!((dv, lines(&ov)), (Vec2::ZERO, 0)); } diff --git a/crates/tools/src/select.rs b/crates/tools/src/select.rs index 21934d78..511a6725 100644 --- a/crates/tools/src/select.rs +++ b/crates/tools/src/select.rs @@ -79,36 +79,47 @@ pub struct SelectionTool { /// Snap to Grid lands its bounds on the grid (#740), taking over from the rest; otherwise Smart /// Guides line its bounds up with the other art, and with them off, View → Snap to Point lands the /// point it was grabbed by on an anchor or a ruler guide. Snap to Pixel puts its top-left on whole -/// pixels. +/// pixels. A copy (Alt, which can go down or up at any time during the drag) leaves the original +/// behind, so it snaps to the original too. pub(crate) struct MoveSnap { bounds: Option, targets: Option, points: Option, + /// The targets and points for a copy, gathered with the others before the previews move the + /// original. + copy_targets: Option, + copy_points: Option, } impl MoveSnap { pub(crate) fn new(cx: &ToolContext) -> Self { + let guides = cx.smart_guides && !cx.snap_to_grid; + let points = |exclude: &[NodeId]| if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, exclude) }; Self { bounds: selection_bounds(cx), - targets: (cx.smart_guides && !cx.snap_to_grid).then(|| Targets::for_move(cx)), - points: if cx.snap_to_grid { None } else { Targets::snap_to_point(cx, &cx.selection.objects) }, + targets: guides.then(|| Targets::for_move(cx, &cx.selection.objects)), + points: points(&cx.selection.objects), + copy_targets: guides.then(|| Targets::for_move(cx, &[])), + copy_points: points(&[]), } } - /// The move by `d` of the selection grabbed at `start`, snapped, and its guides. - pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2) -> (Vec2, Vec) { + /// The move (`copy`: the copy) by `d` of the selection grabbed at `start`, snapped, and its + /// guides. + pub(crate) fn snap(&self, cx: &ToolContext, start: Point, mut d: Vec2, copy: bool) -> (Vec2, Vec) { + let (targets, points) = if copy { (&self.copy_targets, &self.copy_points) } else { (&self.targets, &self.points) }; let mut guides = vec![]; if cx.snap_to_grid && let Some(b) = self.bounds { d += grid_pull(b + d, cx.grid_step()); } - if let Some(t) = &self.points { + if let Some(t) = points { let (q, ov) = t.snap_point(start + d, cx.tol(cx.snap_tolerance)); d = q - start; guides = ov; } - if let (Some(t), Some(b)) = (&self.targets, self.bounds) { + if let (Some(t), Some(b)) = (targets, self.bounds) { let (adj, ov) = t.snap_rect(b + d, cx.snap_tol()); d += adj; guides = ov; @@ -331,7 +342,7 @@ impl Tool for SelectionTool { } let mut d = move_delta(start, p, m.shift); if let Some(snap) = &self.moving { - (d, self.guides) = snap.snap(cx, start, d); + (d, self.guides) = snap.snap(cx, start, d, m.alt); } self.state = State::Moving { start, began: true, deselect: None, key: None }; self.measure = cx.measurement_labels.then(|| (p, cx.offset_label(d.x, d.y))); @@ -622,6 +633,38 @@ mod tests { assert_eq!(a, vec![Action::Commit]); } + /// Alt pressed during a move makes a copy, which snaps to the original it leaves behind (as + /// gathered before the previews moved it); released, the move doesn't. + #[test] + fn an_alt_copy_snaps_to_the_original_left_behind() { + use vectorcraft_doc::{Appearance, Node}; + let (mut d, id) = doc_with_rect(); + // An artboard whose edges and centre are out of reach. + d.artboards[0].rect = Rect::new(0.0, 0.0, 1000.0, 1000.0); + let mut s = Selection::default(); + s.add(id); + let p = paint(); + let mut t = SelectionTool::default(); + let dx = |a: &[Action]| { + let Some(Action::Preview(_, v)) = a.last() else { panic!("{a:?}") }; + v["matrix"][4].as_f64().unwrap() + }; + t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Down, 150.0, 150.0)); + assert_eq!(dx(&t.pointer(&cx(&d, &s, &p), &ev(PointerKind::Drag, 200.0, 150.0))), 50.0); + // The previews moved the rectangle: the tool sees it 50 pt along. + let mut moved = d.clone(); + let l = moved.layers[0].id; + moved.remove(id).unwrap(); + let r = vectorcraft_geom::shapes::rectangle(Rect::new(150.0, 100.0, 250.0, 200.0)); + moved.insert(Some(l), 0, Node::path(id, r, Appearance::default_art())).unwrap(); + let c = cx(&moved, &s, &p); + let alt = Mods { alt: true, ..Mods::default() }; + // The copy's left edge, 3 pt past the original's right edge (x = 200), lands on it. + assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0).with_mods(alt))), 100.0); + // Without Alt nothing stays behind there. + assert_eq!(dx(&t.pointer(&c, &ev(PointerKind::Drag, 253.0, 150.0))), 103.0); + } + /// #740: with Snap to Grid on, a moved object lands on the grid (every 9 pt by default) by /// whichever of its edges or its centre is nearest a gridline, on each axis. #[test]