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..b7a4f9341 100644 --- a/crates/engine/src/devfocus_tests.rs +++ b/crates/engine/src/devfocus_tests.rs @@ -84,14 +84,20 @@ 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"); + // 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 c58527286..6498b8cb5 100644 --- a/crates/engine/src/selection_behaviour_tests.rs +++ b/crates/engine/src/selection_behaviour_tests.rs @@ -271,14 +271,26 @@ 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()); + // 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] 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 87c59be9f..6c85eb6d0 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`) (a locked layer selected from the panel ignores the arrow keys and the nudge commands; its properties still edit; measured in Figma's web app, 2026-11-10) | | 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 | @@ -187,6 +188,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 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-10 | patch | M5, #1560: locked layers follow Figma's rules (Lock and unlock layers; measured in Figma's web app): a locked layer selected in the Layers panel ignores the arrow keys and the nudge commands while its properties still edit, and a child of a locked layer keeps its padlock inert (the Layers panel padlock, Lock/Unlock and `object.toggleLock` without a value no longer lock the child on its own) | | 2026-11-10 | patch | #2217: the main menu's submenus (Object, say) ran past the window's bottom, behind the taskbar, with the lowest commands out of reach, because submenus were not constrained to the window (to keep them beside their row). Every menu panel now scrolls when taller than the window less 8 pt margins, and a submenu is held within the window's height (moved up, never above the top) while keeping its place beside its row, so menu-aim is unchanged. Tests: `menu_overflow_tests.rs`. Numbers unchanged. | | 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` |