From 795567f01697460c91dc7bfd1fb53a9d9193da78 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Wed, 21 Oct 2026 23:37:09 +0200 Subject: [PATCH 1/3] M1: a selected unfilled shape drags from inside its box (#910) Co-authored-by: Cursor --- .../engine/src/selection_behaviour_tests.rs | 30 +++++++++++++++++++ crates/engine/src/tools.rs | 12 +++++++- 2 files changed, 41 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index bc961a4a..3131e9d0 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -324,6 +324,36 @@ fn shift_drag_moves_the_selection() { assert_eq!(s.selection(), vec![k.empty], "Shift let go before the button still takes the layer out"); } +#[test] +fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() { + let mut s = Session::new(); + let k = scene(&mut s); + for i in [k.r, k.c] { + ex(&mut s, "node.set", json!({"ids": [i], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + } + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + // (selection before, drag from, selection after, the layers that move by (10, 10), derivation) + let rows: Vec<(Vec, (f64, f64), Vec, Vec, &str)> = vec![ + (vec![k.r], (520.0, 20.0), vec![k.r], vec![k.r], "a selected unfilled shape drags from its inside, not only its stroke"), + (vec![k.c], (40.0, 140.0), vec![k.c], vec![k.c], "the same in a top-level frame, whose background is under the inside"), + (vec![k.r, k.empty], (570.0, 20.0), vec![k.r, k.empty], vec![k.r, k.empty], "a gap inside a multiple selection's box drags them all"), + (vec![], (520.0, 20.0), vec![k.r], vec![], "an unselected unfilled shape is hit on its stroke only: the drag is a marquee, which touches it"), + ]; + for (before, from, after, moved, why) in rows { + let home: Vec = [k.r, k.c, k.empty].iter().map(|i| at(&s, *i)).collect(); + ex(&mut s, "select.set", json!({"ids": before})); + drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE); + assert_eq!(sorted(s.selection()), sorted(after), "{why}"); + for (i, h) in [k.r, k.c, k.empty].iter().zip(&home) { + let d = if moved.contains(i) { 10.0 } else { 0.0 }; + assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why}"); + } + if !moved.is_empty() { + assert!(s.undo().unwrap(), "{why}: one undo step"); + } + } +} + #[test] fn alt_shift_drag_on_a_selected_layer_duplicates_it() { let mut s = Session::new(); diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index f0115e1f..8d296a8b 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1191,11 +1191,21 @@ impl Session { .node(leaf) .is_some_and(|n| n.parent == Some(page) && n.kind.is_frame_like() && !n.children.is_empty() && !crate::widgets::is_widget(n)) }; - let target = label_at(&doc, page, &self.tool.labels, ev.p()).or_else(|| { + let label = label_at(&doc, page, &self.tool.labels, ev.p()); + let target = label.or_else(|| { self.hit(&doc, page, ev.p(), ev.tol() * 2.0) .filter(|leaf| ev.cmd || !board_background(*leaf)) .map(|leaf| select_target(&doc, leaf, &sel, ev.cmd)) }); + // A press inside the selection's box that reaches nothing above it (an unfilled shape's + // inside, or the frame or canvas behind it) drags the selection. + let behind_sel = |t: Option| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s))); + let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) => { + Some(first) + } + _ => target, + }; match target { Some(target) => { // Clicking inside the current selection's box keeps it (drag them all). From 84b48bc797557624e670d9b39ea0b85995df1b00 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Thu, 22 Oct 2026 00:19:13 +0200 Subject: [PATCH 2/3] M1: the drag-from-inside fallback tests the selection's turned box (#910) Co-authored-by: Cursor --- crates/engine/src/selection_behaviour_tests.rs | 13 +++++++++++++ crates/engine/src/tools.rs | 2 +- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index 3131e9d0..ef5869b2 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -352,6 +352,19 @@ fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() { assert!(s.undo().unwrap(), "{why}: one undo step"); } } + // A turned layer's box is turned with it: the corners of its page-aligned bounds are outside. + ex(&mut s, "node.set", json!({"ids": [k.r], "props": {"rotation": 45}})); + let b = s.doc().unwrap().world_bounds(k.r).unwrap(); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + drag(&mut s, (b.x0 + 2.0, b.y0 + 2.0), (b.x0 + 12.0, b.y0 + 12.0), NONE); + assert_eq!(at(&s, k.r), b.origin(), "a press outside a turned layer's box is a marquee: it doesn't move the layer"); + ex(&mut s, "select.set", json!({"ids": [k.r]})); + let c = b.center(); + drag(&mut s, (c.x, c.y), (c.x + 10.0, c.y + 10.0), NONE); + assert_eq!(s.selection(), vec![k.r], "inside the turned box it drags"); + // Pixel snapping rounds the turned box's origin, so the move is about the pointer's. + let d = at(&s, k.r) - b.origin(); + assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}"); } #[test] diff --git a/crates/engine/src/tools.rs b/crates/engine/src/tools.rs index 8d296a8b..4e411d28 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -1201,7 +1201,7 @@ impl Session { // inside, or the frame or canvas behind it) drags the selection. let behind_sel = |t: Option| t.is_none_or(|t| sel.iter().any(|s| *s != t && doc.is_ancestor(t, *s))); let target = match sel.iter().copied().find(|s| !locked_in(&doc, *s)) { - Some(first) if label.is_none() && !ev.shift && behind_sel(target) && doc.bounds_of(&sel).is_some_and(|b| b.contains(ev.p())) => { + Some(first) if label.is_none() && !ev.shift && behind_sel(target) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { Some(first) } _ => target, From aa5cc840e5b4b9a346e5721b90a280e4b17ff26c Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Thu, 22 Oct 2026 07:46:23 +0200 Subject: [PATCH 3/3] M1: re-run CI (#910)