From a285fd293b8a31b6583228f5a4654841d8158723 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Sun, 8 Nov 2026 00:11:49 -0500 Subject: [PATCH] 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 |