M1: Alt pressed during a drag turns the move into a copy (#1385)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Muamer MustaficandCursor committed 2026-10-27 12:21:44 +01:00
1 parent 43af145a12
commit a02c4abefc
4 files changed
+79 -2

No files matched your search

+18
View File
@@ -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<Span>, 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) {
+14 -1
View File
@@ -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 } => {
@@ -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.
+2 -1
View File
@@ -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 |