From a285fd293b8a31b6583228f5a4654841d8158723 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 00:11:49 -0500 Subject: [PATCH 1/3] Selection: Escape clears the selection instead of stepping out to the parent, as in Figma Measured in Figma: with a layer inside a group inside a frame selected (from the canvas, from the Layers panel, or after Tab), one Escape selects nothing; FigCraft selected the parent first. Escape still leaves text and vector editing, ends gestures and the pen first. Three tests updated to the measured rule (selection_behaviour, devfocus, proto_editor); the rest of the suites pass. Co-Authored-By: Claude Sonnet 5.5 Model-Effort: Opus 5.5 drove; Opus 5.5 briefed; Sonnet 5.5 wrote at low --- crates/engine/src/canvas/tools/keys.rs | 20 ++++--------------- crates/engine/src/devfocus_tests.rs | 6 +++--- .../engine/src/selection_behaviour_tests.rs | 8 +++----- crates/ui-egui/src/proto_editor_tests.rs | 12 ++++------- docs/ui-parity.md | 2 ++ 5 files changed, 16 insertions(+), 32 deletions(-) diff --git a/crates/engine/src/canvas/tools/keys.rs b/crates/engine/src/canvas/tools/keys.rs index 2b56aecd3..870565207 100644 --- a/crates/engine/src/canvas/tools/keys.rs +++ b/crates/engine/src/canvas/tools/keys.rs @@ -1,4 +1,4 @@ -use super::{Document, Gesture, Node, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit}; +use super::{Document, Gesture, NodeId, Overlay, Point, PointerEvent, PointerKind, Result, Session, ShapeHandle, ToolId, json, textedit}; impl Session { /// Keys the current mode handles before menu shortcuts (vector edit mode's tools and point @@ -146,21 +146,9 @@ impl Session { self.ds_mut()?.focus = None; return Ok(()); } - // Escape selects the parent, then nothing. - let doc = self.doc()?; - let parents: Vec = - sel.iter().filter_map(|i| doc.node(*i).and_then(|n| n.parent)).filter(|p| !doc.node(*p).is_some_and(Node::is_page)).collect(); - if !parents.is_empty() { - let mut uniq = Vec::new(); - for p in parents { - if !uniq.contains(&p) { - uniq.push(p); - } - } - self.set_selection(uniq); - } else { - self.set_selection(vec![]); - } + // Escape clears the selection whatever its nesting (measured in Figma: it never selects the + // parent). + self.set_selection(vec![]); Ok(()) } diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs index 0801746de..f36af7ee6 100644 --- a/crates/engine/src/devfocus_tests.rs +++ b/crates/engine/src/devfocus_tests.rs @@ -84,12 +84,12 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() { click(&mut s, 75.0, 75.0); assert_eq!(s.selection(), vec![in_a], "the layer on top of it isn't hit either"); assert_ne!(s.selection(), vec![over]); - // Escape: selection up to the frame, then out of the focus view. + // Escape: the selection goes, then out of the focus view. s.escape().unwrap(); - assert_eq!(s.selection(), vec![a]); + assert!(s.selection().is_empty(), "Escape selects nothing, not the parent frame"); assert_eq!(focused(&s), Some(a)); s.escape().unwrap(); - assert_eq!(focused(&s), None, "Escape with the frame selected leaves"); + assert_eq!(focused(&s), None, "Escape with nothing selected leaves"); click(&mut s, 350.0, 100.0); assert_eq!(s.selection(), vec![b], "everything is hit again"); } diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index c58527286..b3289c6c3 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -271,14 +271,12 @@ fn keyboard_selection() { ex(&mut s, cmd, json!({})); assert_eq!(sorted(s.selection()), sorted(after), "{why}"); } - // Esc steps out to the parent, then deselects. + // Esc clears the selection at once, whatever the nesting (measured in Figma). ex(&mut s, "select.set", json!({"ids": [k.a]})); s.escape().unwrap(); - assert_eq!(s.selection(), vec![k.g], "Esc selects the parent"); + assert_eq!(s.selection(), Vec::::new(), "Esc from a nested layer selects nothing, not the parent"); s.escape().unwrap(); - assert_eq!(s.selection(), vec![k.f]); - s.escape().unwrap(); - assert_eq!(s.selection(), Vec::::new(), "Esc on a top-level layer deselects"); + assert_eq!(s.selection(), Vec::::new()); } #[test] diff --git a/crates/ui-egui/src/proto_editor_tests.rs b/crates/ui-egui/src/proto_editor_tests.rs index b16894425..45fbe4d65 100644 --- a/crates/ui-egui/src/proto_editor_tests.rs +++ b/crates/ui-egui/src/proto_editor_tests.rs @@ -328,8 +328,8 @@ fn edit_delete_from_the_menu_removes_the_open_interaction_not_the_layer() { } #[test] -fn escape_closes_prototype_editor_without_deselecting_layer() { - let (s, a, _b, btn) = setup(); +fn escape_closes_prototype_editor_and_the_next_deselects() { + let (s, _a, _b, btn) = setup(); let mut u = Ui::new(s); u.app.ui.right_tab = crate::RightTab::Prototype; u.run("select.set", json!({"ids": [btn]})); @@ -344,13 +344,9 @@ fn escape_closes_prototype_editor_without_deselecting_layer() { assert_eq!(crate::proto_panel::editor(&u.ctx), None); assert_eq!(u.app.session.selection(), vec![btn], "layer remains selected"); - // Second Escape steps out to parent frame. + // Second Escape clears the selection, not stepping out to the parent frame. u.key(pos2(100.0, 100.0), egui::Key::Escape); - assert_eq!(u.app.session.selection(), vec![a], "steps out to parent frame"); - - // Third Escape deselects all. - u.key(pos2(100.0, 100.0), egui::Key::Escape); - assert!(u.app.session.selection().is_empty(), "selection cleared on third escape"); + assert!(u.app.session.selection().is_empty(), "the second Escape deselects"); } /// After a click in the Pages list, Delete that removes an interaction ends the list's hold on the diff --git a/docs/ui-parity.md b/docs/ui-parity.md index aa0ef25bd..e48e8b0ef 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -59,6 +59,7 @@ interaction feel not yet compared. | Click, Shift-click, marquee, Shift-marquee toggle | done | Shift-drag on a selected layer moves the selection, kept to one axis (#498); a marquee selects what it touches; from a top-level frame's background, the layers inside it; locked and hidden layers are skipped (`selection_behaviour_tests`) | | Deep select (Cmd/Ctrl-click, Cmd/Ctrl-marquee) | done | | | Select layer menu (every layer under the click) | done | | +| Escape with layers selected | done | clears the selection at once, whatever the nesting; it never selects the parent. Measured in the Figma web app (2026-11-08) with a rectangle in a group in a frame, selected from the canvas (a double-click into the group), from the Layers panel, and after Tab moved the selection to its sibling: one Escape selects nothing, a second leaves nothing selected. Escape still leaves text and vector editing, ends gestures and the pen first, and ends the focus view once nothing or the focused frame is selected (`selection_behaviour_tests`, `devfocus_tests`) | | Enter / Shift+Enter (children / parent), Tab / Shift+Tab (siblings) | done | Tab renames the next layer while renaming | | Resize handles: Shift keeps proportions, Alt from centre, constraints-aware | done | Alt shrinks too; past an edge flips (several layers mirror); groups and mixed rotations scale each layer along its own axes, keeping rotation and flip (no skew: children turned off a right angle are approximate); Cmd/Ctrl ignores a frame's constraints (`transform_behaviour_tests`) | | Rotate just outside a corner; Shift in 15° steps | done | rotated handle box when layers share a rotation | @@ -186,6 +187,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look ## Revision history | Date | Change | Summary | +| 2026-11-08 | patch | Selection: Escape with layers selected clears the selection instead of stepping out to the parent first. Measured in the Figma web app with a rectangle in a group in a frame, selected from the canvas, from the Layers panel and after Tab: one Escape selects nothing. Editing, gestures and the pen still end first | |---|---|---| | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | From bceb5476e0d9d170155dfff32107e6ec366a698e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 00:31:58 -0500 Subject: [PATCH 2/3] Docs: the new history row below the table's separator (#2107 review) Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- docs/ui-parity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/ui-parity.md b/docs/ui-parity.md index e48e8b0ef..70d495bd0 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -187,8 +187,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look ## Revision history | Date | Change | Summary | -| 2026-11-08 | patch | Selection: Escape with layers selected clears the selection instead of stepping out to the parent first. Measured in the Figma web app with a rectangle in a group in a frame, selected from the canvas, from the Layers panel and after Tab: one Escape selects nothing. Editing, gestures and the pen still end first | |---|---|---| +| 2026-11-08 | patch | Selection: Escape with layers selected clears the selection instead of stepping out to the parent first. Measured in the Figma web app with a rectangle in a group in a frame, selected from the canvas, from the Layers panel and after Tab: one Escape selects nothing. Editing, gestures and the pen still end first | | 2026-11-02 | patch | Shortcuts: a Cmd/Ctrl-click on a padding field (the horizontal or vertical pair, or a side with Individual padding open) makes the value typed go to all four sides, in one undo step (Guide to auto layout, Keyboard shortcut guide); a plain click still edits that axis or side alone. A documented gesture row: 134 documented, 133 bound. Test: `padding_individual_tests` | | 2026-11-01 | patch | Number fields: dragging the field itself with Alt held scrubs it ("You can also hover the input field itself and hold down the modifier key: Mac: ⌥ Option Windows: Alt", Adjust alignment, rotation, position, and dimensions), with the resize cursor and the label's rates (Alt is the enabler there, so it isn't the fine rate); a click with Alt doesn't open the editor. Found by a documentation audit of the ui-parity rows. Test: `field_tests::alt_held_over_a_field_scrubs_it`. Counts unchanged | | 2026-11-02 | patch | M6, #1839: with a shape or frame tool chosen and nothing selected, the arrows show a placement crosshair in the middle of the view and move it by the nudge amounts (`tool.crosshair`), Enter places the object there (`tool.place`), Escape or a tool change takes it away; the canvas draws the crosshair and `ui.inspect` reports it (`tool.crosshair`), the Keyboard shortcuts panel lists the gesture ("Use the arrow keys to activate the crosshair cursor...", Accessibility at Figma). The board Shapes tool places on it too (its 160 px shape centred, as the tool centres a press), and the view pans by the overshoot when the crosshair is nudged past its edge. Tests: `place_crosshair_tests`, `shortcut_keys_tests::arrows_show_and_move_the_placement_crosshair_with_a_shape_tool`. | From 92cf9849027aff7dc4a2aee82ecdc1bd1d7a0cd1 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 17:38:13 -0500 Subject: [PATCH 3/3] Selection: Escape tests for a top-level layer, several layers and the focused frame (#2107 review) One Escape selects nothing for a top-level frame, a top-level layer, two layers in one group, layers in different parents, and a nested layer with a top-level one. With the focused frame itself selected, one Escape leaves the focus view. Co-Authored-By: Claude Opus 5.5 Model-Effort: Opus 5.5 drove and wrote --- crates/engine/src/devfocus_tests.rs | 6 ++++++ crates/engine/src/selection_behaviour_tests.rs | 14 ++++++++++++++ 2 files changed, 20 insertions(+) diff --git a/crates/engine/src/devfocus_tests.rs b/crates/engine/src/devfocus_tests.rs index f36af7ee6..b7a4f9341 100644 --- a/crates/engine/src/devfocus_tests.rs +++ b/crates/engine/src/devfocus_tests.rs @@ -92,6 +92,12 @@ fn only_the_focused_frame_is_hit_and_escape_leaves() { assert_eq!(focused(&s), None, "Escape with nothing selected leaves"); click(&mut s, 350.0, 100.0); assert_eq!(s.selection(), vec![b], "everything is hit again"); + // With the focused frame itself selected, one Escape leaves the focus view. + ex(&mut s, "dev.focus", json!({"id": a})); + ex(&mut s, "select.set", json!({"ids": [a]})); + assert_eq!(focused(&s), Some(a)); + s.escape().unwrap(); + assert_eq!(focused(&s), None, "Escape with the focused frame selected leaves at once"); } #[test] diff --git a/crates/engine/src/selection_behaviour_tests.rs b/crates/engine/src/selection_behaviour_tests.rs index b3289c6c3..6498b8cb5 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -277,6 +277,20 @@ fn keyboard_selection() { assert_eq!(s.selection(), Vec::::new(), "Esc from a nested layer selects nothing, not the parent"); s.escape().unwrap(); assert_eq!(s.selection(), Vec::::new()); + // A top-level layer, and several layers, nested or not: one Esc selects nothing. + let cases: Vec<(Vec, &str)> = vec![ + (vec![k.f], "a top-level frame"), + (vec![k.r], "a top-level layer"), + (vec![k.a, k.b], "two layers in one group (not their group)"), + (vec![k.a, k.d], "layers in different parents (not the parents)"), + (vec![k.a, k.r], "a nested layer and a top-level one"), + ]; + for (ids, why) in cases { + ex(&mut s, "select.set", json!({"ids": ids})); + assert_eq!(sorted(s.selection()), sorted(ids.clone()), "{why}: selected"); + s.escape().unwrap(); + assert_eq!(s.selection(), Vec::::new(), "Esc on {why} selects nothing"); + } } #[test]