From a02c4abefc9fffc39fffb1aa548dc02bbc4da1bf Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Tue, 27 Oct 2026 12:21:44 +0100 Subject: [PATCH] M1: Alt pressed during a drag turns the move into a copy (#1385) Co-authored-by: Cursor --- crates/engine/src/canvas/snap.rs | 18 ++++++++ crates/engine/src/canvas/tools/drag.rs | 15 ++++++- crates/engine/src/duplicate_spacing_tests.rs | 45 ++++++++++++++++++++ docs/roadmap.md | 3 +- 4 files changed, 79 insertions(+), 2 deletions(-) diff --git a/crates/engine/src/canvas/snap.rs b/crates/engine/src/canvas/snap.rs index b68f1fe95..de1d2ff08 100644 --- a/crates/engine/src/canvas/snap.rs +++ b/crates/engine/src/canvas/snap.rs @@ -138,6 +138,24 @@ impl Targets { self.boxes.push(r); } + /// Take back what [`Targets::add_box`] added for `r`. + pub fn remove_box(&mut self, r: Rect) { + let take = |v: &mut Vec, s: Span| { + if let Some(i) = v.iter().rposition(|x| *x == s) { + v.remove(i); + } + }; + for x in [r.x0, r.center().x, r.x1] { + take(&mut self.xs, (x, r.y0, r.y1)); + } + for y in [r.y0, r.center().y, r.y1] { + take(&mut self.ys, (y, r.x0, r.x1)); + } + if let Some(i) = self.boxes.iter().rposition(|b| *b == r) { + self.boxes.remove(i); + } + } + /// A point to snap onto (a vector point, a segment midpoint): lines through it on both axes, /// and a marker when a moving point lands on it. pub fn add_point(&mut self, p: Point) { diff --git a/crates/engine/src/canvas/tools/drag.rs b/crates/engine/src/canvas/tools/drag.rs index 0d4066732..e9d35fef1 100644 --- a/crates/engine/src/canvas/tools/drag.rs +++ b/crates/engine/src/canvas/tools/drag.rs @@ -159,7 +159,20 @@ impl Session { } pub(super) fn drag(&mut self, ev: &PointerEvent) -> Result<()> { - let g = std::mem::take(&mut self.tool.gesture); + let mut g = std::mem::take(&mut self.tool.gesture); + // Alt makes a move a copy whenever it goes down, before the drag or during it, and letting + // go of it moves the original again; a copy snaps to the layers it came from too. + if let Gesture::Move { targets, duplicate, .. } = &mut g + && *duplicate != ev.alt + { + *duplicate = ev.alt; + if let Some(b) = targets.moving { + if ev.alt { targets.add_box(b) } else { targets.remove_box(b) } + } + if let Some(it) = self.ds_mut().ok().and_then(|ds| ds.interaction.as_mut()) { + it.label = if ev.alt { "Duplicate" } else { "Move" }.into(); + } + } let res = match &g { Gesture::None => return self.hover(ev), Gesture::Marquee { start, additive, base, within } => { diff --git a/crates/engine/src/duplicate_spacing_tests.rs b/crates/engine/src/duplicate_spacing_tests.rs index 4c62179a7..817681b9a 100644 --- a/crates/engine/src/duplicate_spacing_tests.rs +++ b/crates/engine/src/duplicate_spacing_tests.rs @@ -169,6 +169,51 @@ fn drag_move(s: &mut Session, from: (f64, f64), by: (f64, f64)) { s.pointer(&ev(PointerKind::Up, (from.0 + by.0, from.1 + by.1))).unwrap(); } +/// Drag from `from` to `to` in four steps, Alt held at step `k` when `alt(k)` (0: the press, 1–4: +/// the moves, 5: the release). +fn drag_alt_when(s: &mut Session, from: (f64, f64), to: (f64, f64), alt: impl Fn(u8) -> bool) { + let ev = |k: u8, kind, (x, y): (f64, f64)| PointerEvent { alt: alt(k), ..PointerEvent::at(kind, x, y, 1.0) }; + s.pointer(&ev(0, PointerKind::Down, from)).unwrap(); + for k in 1..=4 { + let t = f64::from(k) / 4.0; + s.pointer(&ev(k, PointerKind::Move, (from.0 + (to.0 - from.0) * t, from.1 + (to.1 - from.1) * t))).unwrap(); + } + s.pointer(&ev(5, PointerKind::Up, to)).unwrap(); +} + +fn layers(s: &Session) -> usize { + s.doc().unwrap().node(s.page().unwrap()).unwrap().children.len() +} + +fn last_step(s: &Session) -> String { + s.ds().unwrap().history.undo.last().map(|e| e.label.clone()).unwrap_or_default() +} + +#[test] +fn alt_pressed_during_a_drag_turns_the_move_into_a_copy() { + // #1385: as in other design apps, Alt counts whenever it goes down, not only at the press. + let mut s = Session::new(); + let a = rect(&mut s, 0.0, 0.0); + // Alt from the third step: the original goes back, a copy lands where the drag ends and snaps + // to the original (2 px below its top edge). + drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k >= 3); + let copy = s.selection()[0]; + assert_ne!(copy, a); + assert_eq!((at(&s, a), at(&s, copy)), ((0.0, 0.0), (200.0, 0.0))); + assert_eq!((layers(&s), last_step(&s).as_str()), (2, "Duplicate")); + assert!(s.undo().unwrap()); + assert_eq!((layers(&s), at(&s, a)), (1, (0.0, 0.0)), "one undo step"); + // Let go of Alt during the drag: no copy, the original moves (and no longer snaps to where it + // started, as only a copy does). + drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k < 3); + assert_eq!((layers(&s), s.selection(), at(&s, a)), (1, vec![a], (200.0, 2.0))); + assert_eq!(last_step(&s), "Move"); + // Down and up again during one drag: a copy, as Alt is held at the end. + s.undo().unwrap(); + drag_alt_when(&mut s, (50.0, 50.0), (250.0, 52.0), |k| k == 1 || k >= 4); + assert_eq!((layers(&s), at(&s, a)), (2, (0.0, 0.0))); +} + /// The reported path (#567): Cmd+D, drag the copy with the mouse, Cmd+D again. The other tests /// here set the copy's position with `node.set` or Alt-drag it; dragging is how it is usually done, /// and the step is measured from where the copy ended up, whichever way it got there. diff --git a/docs/roadmap.md b/docs/roadmap.md index 14d734ba5..24ffc3a04 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -1,6 +1,6 @@ # FigCraft roadmap: milestones and what's next -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-26 · **Change:** minor (Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-27 · **Change:** minor (Alt pressed during a drag duplicates (#1385); Android Vector Drawable export; a selected unfilled shape drags over a filled layer below it (#1370); text truncation fills the last line, survives resizing, and auto height takes Max lines (#1375, #1376, #1377); M11 widget fields and property menus; M12 Boards: timer and voting; Installed weights of an embedded font family (#1246); `.sketch` import, best effort; Adobe Fonts found on macOS (#298); M12 Boards; community fixes in #507, #510, #529 and #530: auto-save, a text box by dragging, batch step references, removing someone from a live session, Russian and Turkish catalogs reviewed, Czech and Indonesian drafts, the Japanese draft completed, zoom previews, the web clipboard, Alt-drag snapping and Cmd+D repeat, recovery copies, crash and rollback fixes, colour emoji, `.fig` import fixes; earlier changes are in the revision history below) · **Target:** Figma Design (desktop app 126.10.7) Forward-looking plan. Where FigCraft stands today: [`ROADMAP.md`](../ROADMAP.md) and [`target-app-parity.md`](target-app-parity.md); the full ranked list: [`gaps.md`](gaps.md). Hours @@ -75,6 +75,7 @@ blockers. | Date | Change | Summary | |---|---|---| +| 2026-10-27 | minor | Alt during a drag (M1, #1385): Alt (Option) turns a move into a copy whenever it goes down, before the drag or during it, and letting go of it during the drag moves the original again; the undo step is named for what the drag did, and only a copy snaps to the layers it came from. Regression test in the duplicate spacing tests. Numbers unchanged | | 2026-10-26 | minor | Dragging a selected unfilled shape (M1, #1370): a press inside a selected stroke-only shape over a filled layer stacked below it moved that layer; the drag-from-inside fallback of #910 now treats any layer painted below the selection like the frame or canvas behind it, so the selection drags, while a layer painted above it still takes the press. Regression test in the selection behaviour suite. Numbers unchanged | | 2026-10-26 | minor | Text truncation (M4, #1375, #1376, #1377): the last line shown takes as much of the next word as fits before the ellipsis ("Lorem consec…", not "Lorem…"); an explicit height, `transform.resize`, a resize handle or fixed sizing on a truncated text keeps it truncating instead of turning it fixed size; Max lines on auto-height text hugs up to that many lines and ends in an ellipsis (`text.maxLines`, `node.set {maxLines}`, Type settings, `.fig` export and import), and `node.get` / `get_node` report `maxLines`. Regression tests in the text and engine behaviour suites. Numbers unchanged | | 2026-10-24 | minor | Installed weights of an embedded font family (M7, #1246): release builds embed some families with only a few styles (Poppins Regular, Italic and Bold), and the first font scan dropped every installed face of such a family, so an installed Poppins SemiBold or Medium was never offered (a rescan did offer them). The first scan now keeps the embedded faces, which still win for the styles they cover, and offers the installed styles they lack beside them. Regression test in the font database tests. Numbers unchanged |