diff --git a/crates/engine/src/lib.rs b/crates/engine/src/lib.rs index 1cc1203e..d10bdd3d 100644 --- a/crates/engine/src/lib.rs +++ b/crates/engine/src/lib.rs @@ -1366,10 +1366,17 @@ impl Session { let Some(mut it) = st.interaction.take() else { return Ok(()) }; let Some(preview) = it.preview.take() else { return Ok(()) }; let perspective_again = it.perspective_again.take(); + let copy = preview.0 == "object.transform" && preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); + // Alt pressed or released during a move drag turns it into a copy or back: name it for + // what it did in the end. + match (it.label.as_str(), copy) { + ("Move", true) => it.label = "Copy".into(), + ("Copy", false) if preview.0 == "object.transform" => it.label = "Move".into(), + _ => {} + } st.keep_interaction(it); if preview.0 == "object.transform" { let m = cmd::matrix_param(&preview.1, "matrix"); - let copy = preview.1.get("copy").and_then(Value::as_bool).unwrap_or(false); if let Some(m) = m { st.last_transform = Some((m, copy)); st.last_perspective = None; diff --git a/crates/engine/src/tests_journal.rs b/crates/engine/src/tests_journal.rs index 826d9613..832e1722 100644 --- a/crates/engine/src/tests_journal.rs +++ b/crates/engine/src/tests_journal.rs @@ -355,3 +355,19 @@ fn an_undo_group_is_one_undo_step() { assert!((width(&s) - 120.0).abs() < 1e-9); assert_eq!(undo_len(&s), undo + 2); } + +/// A move drag is named for what its last preview did: Alt pressed or released during the drag +/// turns it into a copy or back (the Selection and Direct Selection tools). +#[test] +fn a_move_drag_is_named_for_whether_it_copied() { + let mut s = Session::new(); + s.execute("file.new", &json!({})).unwrap(); + s.execute("shape.rectangle", &json!({"x": 10, "y": 10, "width": 20, "height": 20})).unwrap(); + let last = |s: &Session| s.doc().unwrap().history.undo.last().map(|e| e.label.clone()); + for (begin, copy, label) in [("Move", true, "Copy"), ("Copy", false, "Move"), ("Move", false, "Move")] { + s.begin_interaction(begin).unwrap(); + s.preview("object.transform", &json!({"matrix": [1, 0, 0, 1, 5, 0], "copy": copy})).unwrap(); + s.commit_interaction().unwrap(); + assert_eq!(last(&s).as_deref(), Some(label), "begun as {begin}, copy: {copy}"); + } +} diff --git a/crates/tools/src/direct.rs b/crates/tools/src/direct.rs index a8fcfd2a..1e4b1c56 100644 --- a/crates/tools/src/direct.rs +++ b/crates/tools/src/direct.rs @@ -115,8 +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. + /// 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). anchor_snap: Option, + /// Whether [`Self::anchor_snap`] was gathered for a copy. + snap_for_copy: bool, /// What the dragged handle snaps to. handle_snap: HandleSnap, /// What the objects being moved snap to, gathered when the move begins. @@ -135,6 +138,7 @@ impl DirectSelectionTool { mesh: MeshEdit::default(), guides: vec![], anchor_snap: None, + snap_for_copy: false, handle_snap: HandleSnap::default(), move_snap: None, hover: None, @@ -380,7 +384,13 @@ impl Tool for DirectSelectionTool { } // A path with all its anchors selected is stored whole (no partial entry). let already = cx.selection.contains(id) && cx.selection.partial(id).is_none_or(|s| s.contains(&(si, ai))); - self.state = State::MoveAnchors { start: p, grab, began: false }; + // Paths all selected whole move as objects, held by this anchor, so Alt can turn + // the copy on and off during the drag as with the Selection tool. + self.state = if already && cx.selection.anchors.is_empty() { + State::MoveObject { start: p, grab: Some(grab), began: false } + } else { + State::MoveAnchors { start: p, grab, began: false } + }; if ev.mods.shift { return vec![Action::Exec("select.anchors".into(), json!({"id": id.0, "anchors": [[si, ai]], "mode": "toggle"}))]; } @@ -478,14 +488,17 @@ 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 }; - if grab.is_some() { - // 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))); - } else { + self.anchor_snap = None; + if grab.is_none() { 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) { @@ -872,7 +885,7 @@ mod tests { let mut t = DirectSelectionTool::new(false); assert!(t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0)).is_empty()); let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Drag, 120.0, 100.0)); - assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "path.moveAnchors"), "{a:?}"); + assert!(matches!(a.as_slice(), [Action::Begin(_), Action::Preview(c, _)] if c == "object.transform"), "{a:?}"); t.pointer(&cx, &PointerEvent::new(PointerKind::Up, 120.0, 100.0)); let shift = Mods { shift: true, ..Default::default() }; let a = t.pointer(&cx, &PointerEvent::new(PointerKind::Down, 100.0, 100.0).with_mods(shift)); @@ -1123,6 +1136,42 @@ mod tests { assert_eq!(out.first(), Some(&Action::Begin("Copy".into()))); } + /// Dragging a fully selected path by an anchor moves it whole, so Alt pressed or released + /// during the drag turns the copy on and off, as with the Selection tool; the anchor pressed + /// snaps all along, onto the original only while it is left behind. + #[test] + fn alt_during_a_drag_by_an_anchor_toggles_the_copy() { + let (d, _, b) = two_squares(); + let p = paint(); + let (none, alt) = (Mods::default(), Mods { alt: true, ..Default::default() }); + let mut whole = Selection::default(); + whole.set([b]); + let c = cx(&d, &whole, &p); + let mut t = DirectSelectionTool::new(false); + let preview = |dx: f64, dy: f64, copy: bool| { + Action::Preview( + "object.transform".into(), + json!({"matrix": matrix_json(vectorcraft_geom::Affine::translate(vectorcraft_geom::Vec2::new(dx, dy))), "copy": copy}), + ) + }; + assert!(t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)).is_empty()); + assert_eq!( + t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 250.0)), + vec![Action::Begin("Move".into()), preview(-50.0, -50.0, false)] + ); + // 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. + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 402.0, 301.0).with_mods(none)), vec![preview(102.0, 1.0, false)]); + assert_eq!(t.pointer(&c, &PointerEvent::new(PointerKind::Up, 402.0, 301.0)), vec![Action::Commit]); + // With only some anchors selected, the drag still moves just those. + let one = anchors_of(b, &[(0, 0)]); + let c = cx(&d, &one, &p); + t.pointer(&c, &PointerEvent::new(PointerKind::Down, 300.0, 300.0)); + let a = t.pointer(&c, &PointerEvent::new(PointerKind::Drag, 250.0, 260.0).with_mods(alt)); + assert!(matches!(a.last(), Some(Action::Preview(c, _)) if c == "path.moveAnchors"), "{a:?}"); + } + /// A dragged anchor lands on another path's anchor or segment, lines up with the other anchors /// of its own path, and with several selected the one pressed on snaps, the others following. #[test]