From 2136ec20b1ba343b11b7a479eebb9fb6c1f78145 Mon Sep 17 00:00:00 2001 From: Muamer Mustafic Date: Mon, 26 Oct 2026 14:02:23 +0100 Subject: [PATCH] M1: a selected unfilled shape drags over a filled layer stacked below it (#1370) Co-authored-by: Cursor --- .../engine/src/selection_behaviour_tests.rs | 40 +++++++++++++++++++ crates/engine/src/tools.rs | 24 ++++++++++- docs/roadmap.md | 3 +- 3 files changed, 64 insertions(+), 3 deletions(-) diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index 8e479d22..7a72c642 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -391,6 +391,46 @@ fn a_drag_inside_the_selection_box_moves_it_where_nothing_is_hit() { assert!((d.x - 10.0).abs() < 2.0 && (d.y - 10.0).abs() < 2.0, "it moves with the pointer: {d:?}"); } +#[test] +fn a_layer_stacked_below_the_selection_doesnt_take_a_drag_from_inside_it() { + // #1370: inside a selected unfilled shape, a filled layer behind it doesn't take the press (the + // selection drags); a filled layer above it still does. + let mut s = Session::new(); + let at = |s: &Session, i: NodeId| s.doc().unwrap().world_bounds(i).unwrap().origin(); + let frame = id(&ex(&mut s, "frame.create", json!({"x": 300, "y": 0, "width": 200, "height": 200}))); + for (parent, dx, where_) in [(None, 0.0, "on the canvas"), (Some(frame), 300.0, "in a top-level frame")] { + // `back` (0,0) filled, `front` (20,20) stroke only, `top` (60,60) filled; each 100×100, so + // the presses are well clear of the stroke. + let square = |s: &mut Session, x: f64| { + let mut p = json!({"x": x, "y": x, "width": 100, "height": 100}); + if let Some(parent) = parent { + p["parent"] = json!(parent); + } + id(&ex(s, "shape.rectangle", p)) + }; + let back = square(&mut s, 0.0); + let front = square(&mut s, 20.0); + ex(&mut s, "node.set", json!({"ids": [front], "props": {"fills": [], "strokes": ["#000000"], "strokeWeight": 1}})); + let top = square(&mut s, 60.0); + // (drag from, selection after (the layer that moves by (10, 10)), derivation) + let rows = [ + ((dx + 40.0, 40.0), front, "a filled layer behind the selected shape doesn't take the press: the selection drags"), + ((dx + 90.0, 90.0), top, "a filled layer above the selected shape still takes the press"), + ]; + for (from, moved, why) in rows { + let home: Vec = [back, front, top].iter().map(|i| at(&s, *i)).collect(); + ex(&mut s, "select.set", json!({"ids": [front]})); + drag(&mut s, from, (from.0 + 10.0, from.1 + 10.0), NONE); + assert_eq!(s.selection(), vec![moved], "{why} ({where_})"); + for (i, h) in [back, front, top].iter().zip(&home) { + let d = if *i == moved { 10.0 } else { 0.0 }; + assert_eq!(at(&s, *i), Point::new(h.x + d, h.y + d), "{why} ({where_})"); + } + 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 629c9a06..0c1b03aa 100644 --- a/crates/engine/src/tools.rs +++ b/crates/engine/src/tools.rs @@ -747,6 +747,26 @@ pub fn locked_in(doc: &Document, id: NodeId) -> bool { std::iter::once(id).chain(doc.ancestors(id)).any(|a| doc.node(a).is_some_and(|n| n.locked)) } +/// Whether layer `t` paints below layer `s`: it holds `s`, or it is behind `s` in the stack of the +/// nearest layer holding both. +fn paints_below(doc: &Document, t: NodeId, s: NodeId) -> bool { + let chain = |id: NodeId| { + let mut c: Vec = std::iter::once(id).chain(doc.ancestors(id)).collect(); + c.reverse(); + c + }; + let (ct, cs) = (chain(t), chain(s)); + let shared = ct.iter().zip(&cs).take_while(|(a, b)| a == b).count(); + match (ct.get(shared), cs.get(shared)) { + (None, Some(_)) => true, + (Some(a), Some(b)) => shared.checked_sub(1).and_then(|i| ct.get(i)).and_then(|p| doc.node(*p)).is_some_and(|p| { + let order: Vec = p.paint_order().collect(); + order.iter().position(|c| c == a) < order.iter().position(|c| c == b) + }), + _ => false, + } +} + /// Which handle (if any) is under `p`. A selection with a locked layer has none. pub fn handle_at(doc: &Document, sel: &[NodeId], p: Point, zoom: f64) -> Option { if sel.is_empty() || sel.iter().any(|i| locked_in(doc, *i)) { @@ -1363,8 +1383,8 @@ impl Session { .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))); + // inside, the frame or canvas behind it, or a layer stacked below it) drags the selection. + let behind_sel = |t: Option| t.is_none_or(|t| sel.iter().any(|s| *s != t && paints_below(&doc, 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) && selection_box(&doc, &sel).is_some_and(|q| in_quad(&q, ev.p())) => { Some(first) diff --git a/docs/roadmap.md b/docs/roadmap.md index c22f88f8..dadaa97b 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-24 · **Change:** minor (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-26 · **Change:** minor (a selected unfilled shape drags over a filled layer below it (#1370); 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-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-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 | | 2026-10-24 | minor | M12: a shared board timer and voting sessions; 30–60 h → 26–54 h | | 2026-10-24 | minor | M11: plugin widgets take text input (editable fields, `widget.setText`) and a property menu (`widget.menu`), plugin API 4; 8–26 h → 6–22 h |