From da4a79832344e4dac0783421a9227d96479e5ef2 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 08:13:54 -0500 Subject: [PATCH 1/6] M1: pick select: Tab while the keyboard selection box shows walks the layer tree with a pink outline, Space selects (#1836) Tab or the chord again switches the box to a pick cursor; Tab/Shift+Tab move between sibling layers, Enter/Shift+Enter into and out of a layer, Space selects, Escape or a canvas press ends it; ui.key takes the same keys. Gesture and parity rows, catalog, test, docs. Co-Authored-By: Claude Fable 5.1 --- crates/engine/src/keymap.rs | 4 ++ crates/engine/src/shortcut_parity_tests.rs | 4 +- crates/ui-egui/locales/en.toml | 11 +++ crates/ui-egui/src/canvas/input.rs | 1 + crates/ui-egui/src/canvas/overlays.rs | 8 +++ crates/ui-egui/src/control.rs | 7 ++ crates/ui-egui/src/layers_keyboard_tests.rs | 58 +++++++++++++++ crates/ui-egui/src/lib.rs | 4 ++ crates/ui-egui/src/shortcuts.rs | 79 ++++++++++++++++++++- docs/ui-parity.md | 9 +-- 10 files changed, 178 insertions(+), 7 deletions(-) diff --git a/crates/engine/src/keymap.rs b/crates/engine/src/keymap.rs index cec22d62f..4c4b94071 100644 --- a/crates/engine/src/keymap.rs +++ b/crates/engine/src/keymap.rs @@ -359,6 +359,10 @@ pub static GESTURES: &[(Group, &str, &str, &str)] = &[ (Zoom, "Zoom around the pointer", "⌘ scroll", "Ctrl+scroll"), (Selection, "Deep select", "⌘ click", "Ctrl+click"), (Selection, "Measure to the selection", "Hold ⌥", "Hold Alt"), + (Selection, "Selection box: switch to pick select", "⇥ or ⌥ Space again", "Tab or Ctrl+Space again"), + (Selection, "Pick select: next or previous layer", "⇥ / ⇧⇥", "Tab / Shift+Tab"), + (Selection, "Pick select: into a layer, out to its parent", "↵ / ⇧↵", "Enter / Shift+Enter"), + (Selection, "Pick select: select the picked layer", "Space", "Space"), (Edit, "Duplicate by dragging", "⌥ drag", "Alt+drag"), (Transform, "Nudge (Shift: big nudge)", "← ↑ → ↓", "← ↑ → ↓"), (Transform, "Resize by the nudge amount", "⌘ arrows", "Ctrl+arrows"), diff --git a/crates/engine/src/shortcut_parity_tests.rs b/crates/engine/src/shortcut_parity_tests.rs index 46eb14447..c3734c2d5 100644 --- a/crates/engine/src/shortcut_parity_tests.rs +++ b/crates/engine/src/shortcut_parity_tests.rs @@ -235,6 +235,8 @@ static ROWS: &[Row] = &[ "then the arrows move it, Cmd/Ctrl+arrows draw it out (Shift: farther), Enter selects, Escape closes; F8 kept") .reserved("Ctrl+Space switches the input method with some Chinese, Japanese and Korean input methods on Windows and Linux"), doc(Selection, "Select nested layers", &["Cmd"], &[], Gesture("Deep select"), "held while clicking or dragging"), + doc(Selection, "Pick select (while the keyboard box selection shows)", &["Tab"], &[], Gesture("Selection box: switch to pick select"), + "or the chord again; then Tab and Shift+Tab move the pick cursor between layers, Enter and Shift+Enter into and out of a layer, Space selects, Escape ends"), pin(Selection, "object.rename", &["F2"], &[]), pin(Selection, "select.area", &["F8"], &[]), // Edit @@ -413,7 +415,7 @@ fn counts_match_the_docs() { let reserved = documented.iter().filter(|r| !r.reserved.is_empty()).count(); let pinned = ROWS.len() - documented.len(); // docs/ui-parity.md quotes these. - assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (134, 133, 1, 0, 2, 57)); + assert_eq!((documented.len(), bound, moved, missing, reserved, pinned), (135, 134, 1, 0, 2, 57)); } #[test] diff --git a/crates/ui-egui/locales/en.toml b/crates/ui-egui/locales/en.toml index a23c281ba..cdb64be47 100644 --- a/crates/ui-egui/locales/en.toml +++ b/crates/ui-egui/locales/en.toml @@ -1977,3 +1977,14 @@ name = "English" "⌘ click" = "⌘ click" "⌘ scroll" = "⌘ scroll" "⌥ drag" = "⌥ drag" +"Selection box: switch to pick select" = "Selection box: switch to pick select" +"Pick select: next or previous layer" = "Pick select: next or previous layer" +"Pick select: into a layer, out to its parent" = "Pick select: into a layer, out to its parent" +"Pick select: select the picked layer" = "Pick select: select the picked layer" +"⇥ or ⌥ Space again" = "⇥ or ⌥ Space again" +"Tab or Ctrl+Space again" = "Tab or Ctrl+Space again" +"↵ / ⇧↵" = "↵ / ⇧↵" +"Enter / Shift+Enter" = "Enter / Shift+Enter" +"Space" = "Space" +"Nothing to pick: the page is empty" = "Nothing to pick: the page is empty" +"Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels" = "Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels" diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index 5aa054163..46b9e497a 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -190,6 +190,7 @@ pub(super) fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) -> boo app.ui.layers_focus = false; app.ui.pages_focus = None; app.ui.key_box = None; + app.ui.pick = None; } // Zoom (pinch, Cmd/Ctrl+scroll; a plain mouse wheel with Scroll wheel zoom) about the pointer; // other scrolls pan. diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index 483a180b9..da5b68f2f 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -55,6 +55,7 @@ pub(super) fn overlays(app: &FigApp, painter: &egui::Painter, t: &Tokens) -> Vec { draw_outline(app, painter, &doc, h, stroke(outline_colour(&doc, h, accent(painter)), 1.5)); } + pick_outline(app, painter, &doc); // Inside a boolean group (an operand selected or in vector edit mode): every operand's outline, // so the shapes making the live result stay visible while one of them changes. let editing = ds.vector_edit.as_ref().map(|ve| ve.id); @@ -759,3 +760,10 @@ fn vector_overlay( pub(super) fn accent(p: &egui::Painter) -> Color32 { crate::theme::canvas_accent(p.ctx()) } + +/// Pick select: the pink outline on the layer the keys walked to (#1836). +fn pick_outline(app: &FigApp, painter: &egui::Painter, doc: &Document) { + if let Some(p) = app.ui.pick.filter(|p| doc.node(*p).is_some()) { + draw_outline(app, painter, doc, p, stroke(crate::handles::PINK, 2.0)); + } +} diff --git a/crates/ui-egui/src/control.rs b/crates/ui-egui/src/control.rs index 322d9032c..2b16de46a 100644 --- a/crates/ui-egui/src/control.rs +++ b/crates/ui-egui/src/control.rs @@ -982,6 +982,13 @@ fn ui_key(app: &mut FigApp, ctx: &Context, p: &Value) -> Result { figcraft_engine::textedit::key(&mut app.session, key, m.shift, word, line).map_err(|e| e.to_string())?; return Ok(json!({"ok": true})); } + // The keyboard selection box and pick select take their keys first, as on the keyboard. + if let Some(k) = normalize(key).and_then(|(name, _)| egui::Key::from_name(name)) { + let mods = egui::Modifiers { alt: m.alt, ctrl: m.ctrl, shift: m.shift, mac_cmd: false, command: m.cmd || m.ctrl }; + if crate::shortcuts::key_pick(app, k, mods) || crate::shortcuts::key_box(app, k, mods) { + return Ok(json!({"ok": true})); + } + } // Vector edit mode and shape builder keys (tools, nudging points, delete, join, Enter). if app.session.tool_key(key, m.shift, m.alt, m.cmd).map_err(|e| e.to_string())? { return Ok(json!({"ok": true})); diff --git a/crates/ui-egui/src/layers_keyboard_tests.rs b/crates/ui-egui/src/layers_keyboard_tests.rs index 92cacd545..adbb9a510 100644 --- a/crates/ui-egui/src/layers_keyboard_tests.rs +++ b/crates/ui-egui/src/layers_keyboard_tests.rs @@ -222,3 +222,61 @@ impl crate::FigApp { self.session.doc().unwrap().node(id).unwrap().transform.translation().x } } + +/// Pick select (#1836): "Press Tab or ⌥Option Space / Control Space again to switch to pick +/// select." "Once activated, a pink outline is placed on a selected objects on the canvas, or any +/// node if nothing is selected." "Use Tab to move between parent objects on the canvas, +/// Return/Enter and ⇧Shift Return / Enter to move between parent and child layers. Press Space to +/// make you selection. Press Esc or click anywhere on the canvas to exit selection mode." +/// (Accessibility at Figma). +#[test] +fn tab_switches_the_selection_box_to_pick_select() { + let (mut u, a, b) = setup(); + // A frame with a child, on top of A and B in the layers list. + let f = NodeId(u.app.run("frame.create", json!({"x": 200, "y": 0, "width": 80, "height": 80})).unwrap()["id"].as_u64().unwrap()); + let c = NodeId(u.app.run("shape.rectangle", json!({"x": 210, "y": 10, "width": 20, "height": 20, "parent": f})).unwrap()["id"].as_u64().unwrap()); + u.app.run("select.set", json!({"ids": [a]})); + press(&mut u, Key::F8, Modifiers::NONE); + press(&mut u, Key::Tab, Modifiers::NONE); + assert!(u.app.ui.key_box.is_none(), "the box becomes the pick cursor"); + assert_eq!(u.app.ui.pick, Some(a), "on the selected layer"); + assert_eq!(u.app.session.selection(), vec![a], "nothing selected yet"); + // Tab walks the siblings as the Layers panel lists them (the frame on top, then B, then A), + // wrapping; Shift+Tab goes the other way. + press(&mut u, Key::Tab, Modifiers::NONE); + assert_eq!(u.app.ui.pick, Some(f), "below A comes the top of the list"); + press(&mut u, Key::Tab, Modifiers::SHIFT); + assert_eq!(u.app.ui.pick, Some(a)); + press(&mut u, Key::Tab, Modifiers::SHIFT); + assert_eq!(u.app.ui.pick, Some(b)); + // Enter goes into a frame's top child, Shift+Enter back out; the page is never picked. + press(&mut u, Key::Tab, Modifiers::SHIFT); + assert_eq!(u.app.ui.pick, Some(f)); + press(&mut u, Key::Enter, Modifiers::NONE); + assert_eq!(u.app.ui.pick, Some(c)); + press(&mut u, Key::Enter, Modifiers::SHIFT); + press(&mut u, Key::Enter, Modifiers::SHIFT); + assert_eq!(u.app.ui.pick, Some(f)); + assert_eq!(u.app.session.selection(), vec![a], "walking selects nothing"); + // Space selects the picked layer and ends pick select. + press(&mut u, Key::Space, Modifiers::NONE); + assert_eq!(u.app.session.selection(), vec![f]); + assert!(u.app.ui.pick.is_none()); + // The chord again while the box shows switches too; Escape ends it, the selection kept. + press(&mut u, Key::F8, Modifiers::NONE); + press(&mut u, Key::F8, Modifiers::NONE); + assert!(u.app.ui.key_box.is_none()); + assert_eq!(u.app.ui.pick, Some(f)); + press(&mut u, Key::Escape, Modifiers::NONE); + assert!(u.app.ui.pick.is_none()); + assert_eq!(u.app.session.selection(), vec![f]); + // Nothing selected: the pick starts on the page's top layer; a press on the canvas ends it. + u.app.run("select.none", json!({})); + press(&mut u, Key::F8, Modifiers::NONE); + press(&mut u, Key::Tab, Modifiers::NONE); + assert_eq!(u.app.ui.pick, Some(f)); + let p = u.app.canvas.rect.center(); + u.button(p, true); + u.button(p, false); + assert!(u.app.ui.pick.is_none()); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index e9969c106..9857a3320 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -481,6 +481,9 @@ pub struct UiState { /// A selection box drawn from the keyboard (`select.area`), page `[x0, y0, x1, y1]` with the /// marquee's start at `x0, y0`. pub key_box: Option<[f64; 4]>, + /// Pick select (#1836): the layer the pink outline is on while the keys walk the layer tree + /// (Tab or the selection-box chord again while the box shows; Space selects it). + pub pick: Option, /// Layers panel search: open, the filter text, the shift-click range anchor and the selection /// the tree last revealed (ancestors expand and the row scrolls into view when it changes). pub layer_search: bool, @@ -656,6 +659,7 @@ impl Default for UiState { context_menu: None, key_menu: false, key_box: None, + pick: None, layer_search: false, layer_filter: String::new(), layer_anchor: None, diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index f44adb68e..dce0861d2 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -428,7 +428,7 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re if key == Key::Escape && !egui::Popup::is_any_open(ctx) && crate::colorpicker::close_if_open(app) { return true; } - if key_box(app, key, m) { + if key_pick(app, key, m) || key_box(app, key, m) { return true; } // Enter or Escape finishes crop mode (the crop is already applied). @@ -517,8 +517,15 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re /// F8): the arrows move it, Cmd/Ctrl+arrows or Shift+arrows move its far corner, sizing it (Shift /// with Cmd/Ctrl: five times as far), by 10 screen pixels; Enter selects what it covers like a /// marquee (Shift toggles, Cmd/Ctrl takes nested layers), Escape drops it. -fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool { +pub(crate) fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool { let Some(mut b) = app.ui.key_box else { return false }; + // "Press Tab ... to switch to pick select" (Accessibility at Figma): the box becomes the + // pick cursor. + if key == Key::Tab && m.is_none() { + app.ui.key_box = None; + app.start_pick_select(); + return true; + } let size = m.shift || m.command; let step = if m.shift && m.command { 50.0 } else { 10.0 } / app.session.ds().map_or(1.0, |d| d.view.zoom.max(1e-6)); let (dx, dy) = match key { @@ -553,9 +560,77 @@ fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool { } } +/// The sibling `dir` rows away from layer `id` in the Layers panel (down for 1, up for -1, +/// wrapping), as `select.next` and `select.previous` walk: the list shows the last child on top. +fn pick_sibling(doc: &figcraft_doc::Document, id: figcraft_doc::NodeId, dir: i64) -> Option { + let siblings = &doc.node(doc.node(id)?.parent?)?.children; + let n = i64::try_from(siblings.len()).ok()?; + let idx = i64::try_from(siblings.iter().position(|c| *c == id)?).ok()?; + siblings.get(usize::try_from((idx - dir).rem_euclid(n)).ok()?).copied() +} + +/// Keys while the pick cursor shows ([`FigApp::start_pick_select`]): Tab and Shift+Tab move it between +/// sibling layers in Layers-panel order, Enter into the top child of a frame or group, Shift+Enter +/// out to the parent (never the page), Space selects the layer under it, Escape drops it ("Use +/// Tab to move between parent objects on the canvas, Return/Enter and ⇧Shift Return / Enter to +/// move between parent and child layers. Press Space to make you selection. Press Esc or click +/// anywhere on the canvas to exit selection mode.", Accessibility at Figma, #1836). +pub(crate) fn key_pick(app: &mut FigApp, key: Key, m: Modifiers) -> bool { + let Some(at) = app.ui.pick else { return false }; + let Ok(doc) = app.session.doc() else { return false }; + let Some(node) = doc.node(at) else { + app.ui.pick = None; + return false; + }; + let plain = !m.command && !m.alt && !m.ctrl; + let next = match key { + Key::Tab if plain => pick_sibling(doc, at, if m.shift { -1 } else { 1 }), + Key::Enter if plain && m.shift => node.parent.filter(|p| doc.node(*p).is_some_and(|n| !n.is_page())), + Key::Enter if plain => node.children.last().copied(), + Key::Space if m.is_none() => { + app.ui.pick = None; + app.run("select.set", json!({"ids": [at]})); + return true; + } + Key::Escape => { + app.ui.pick = None; + return true; + } + _ => return false, + }; + if let Some(n) = next { + app.ui.pick = Some(n); + } + true +} + impl FigApp { + /// Pick select: the pink outline on the first selected layer, or on the page's top layer when + /// nothing is selected ("Once activated, a pink outline is placed on a selected objects on the + /// canvas, or any node if nothing is selected", Accessibility at Figma); the keys walk the + /// layer tree from there ([`key_pick`]). + pub(crate) fn start_pick_select(&mut self) { + let Ok(ds) = self.session.ds() else { return }; + let top = self.session.page().ok().and_then(|p| ds.doc.node(p)).and_then(|n| n.children.last().copied()); + let Some(at) = ds.selection.first().copied().or(top) else { + self.toast(crate::i18n::tr("Nothing to pick: the page is empty")); + return; + }; + self.ui.pick = Some(at); + self.toast(crate::i18n::tr( + "Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels", + )); + } + /// Show a selection box for the keyboard: around the selection, else in the middle of the view. + /// The chord again while the box shows switches to pick select, and while the pick cursor + /// shows brings the box back. pub(crate) fn start_key_box(&mut self) { + if self.ui.key_box.take().is_some() { + self.start_pick_select(); + return; + } + self.ui.pick = None; let Ok(ds) = self.session.ds() else { return }; let zoom = ds.view.zoom.max(1e-6); let around = ds.selection.iter().filter_map(|i| ds.doc.world_bounds(*i)).reduce(|a, b| a.union(b)); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 1d3c01373..a24759f6b 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -1,6 +1,6 @@ # UI and interaction parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** patch (Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) +> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-11-02 · **Change:** minor (pick select: Tab while the keyboard selection box shows (or the chord again) puts a pink outline on a layer and the keys walk the layer tree from it, as Accessibility at Figma says (#1836); Backspace or Delete at the start of a list item keeps the item's indentation level, as the lists article says; max lines on auto-width text keep it auto width, hugging that many lines, as the text properties article says; superscript and subscript are synthesised where the font has no glyphs for them, from the standard glyphs shrunk and shifted by the font's OS/2 metrics, and a run mixing available and unavailable glyphs is synthesised whole, as Explore text properties says (#1828); Alt+Enter while editing text opens the link at the caret, as the accessibility page documents; Minimize, by its shortcut or the button by the file name, collapses the navigation bar and both sidebars as the help center says, with the right sidebar back while something is selected (#1835); the interface size keys of the help center's accessibility page: Cmd/Ctrl+Shift+Alt with =, - and 0 step the interface through the Preferences sizes and back to the system's size; Cmd/Ctrl+Alt+arrows rotate the selection a degree at a time, 15 with Shift, as the help center's accessibility page says; they resized before; the Layers panel answers Ctrl+1 as well as Alt+1 on Windows and Linux, the two keys the help center gives it; the colour picker's Image tab shows a placeholder and Upload from computer, an asset preview, and a file dropped on it replaces the image, keeping the fill mode, crop and position (#1682); the keyboard's Menu key opens the context menu, as Shift+F10 does; the eyedropper with Cmd/Ctrl+Shift makes a colour style or variable of what it picks; sidebar number and text fields keep their text vertically centered when focused, including typography spacing; with the pen out, Enter or a double-click ends the path and edits the vector with the Move tool instead of drawing on (#1430); the Actions palette offers only commands that run from it, and no second row reading like a menu item: Place image opens the file dialog (#1574); Preferences ▸ Scroll wheel zoom: a mouse wheel zooms the canvas about the pointer and Cmd/Ctrl+wheel scrolls (#1390); boolean operations refuse input past their caps, and a boolean group past them draws without its result (#1104); the selection and hover outlines of rounded rectangles and frames follow their corners (#1506); colour picker review fixes for text undo, fitted sidebars, moving controls and gradient variables; Corner smoothing: an original icon and the shared number-field drag gesture, preserving fractional baselines; guides snap to layers when dragged; Crop image: Object ▸ Crop image, the right-click menu and the image fill row enter crop mode (`image.crop`, #1464); the Design panel's sections on one grid — 16 / 8 margins, 88 + 8 + 88 + 8 + 24 columns, 24 pt rows, 4 pt between a row's buttons (#858); the toolbar on one 32 pt grid, without its Resources button (#1210); colour picker LCH and OKLCH models; an XML (Android Vector Drawable) export format; Context menus: the Menu key reaches egui 0.37 but is unbound (row done → partial); the Text on a path tool; board tool keys pinned; community fixes: the text box by dragging, the font picker keys, the pen path on leaving Design, a still canvas in vector edit mode, a scrolling pages list, colour emoji, readable new text in frames, system theme on first run; the Small caps sample in Type settings drawn from capitals; recent files as cards; vector edit mode: hovering, hatching, selecting and dragging filled regions; home screen: Templates tab, first-run templates and Getting started tips; number fields: scrub rates, arrow steps, arithmetic with brackets, relative edits of mixed values; outline mode's hidden layers and object bounds; Hide when publishing; cursors per tool and modifier; panel widths; dark pages for files made in the dark theme; drag threshold and double-clicks into text in groups; rotation origin on Alt+R and placing with Enter: no documented shortcut missing; keyboard shortcut parity suite and bindings; cursor chat and spotlight in live sessions; layers and assets panels behaviour suite and fixes; vector multi-edit and the vector edit tail; canvas navigation, guides and grids behaviour suite and fixes; shapes and vector behaviour suite and fixes; paint and effects behaviour suite and fixes; frames, groups, sections and masks behaviour suite and fixes; layers panel keyboard: F2 rename, Shift+F10 context menu, F8 box selection; selection and transform behaviour suite and fixes; Display P3: gamut warning and soft-proof (true P3 blocked upstream); several windows; boolean hit testing on the result, group drags, frame outlines and slices; outlines for the selection, mask outlines; arc fields, live boolean editing, shape builder on strokes, one-sided widths; fields select their text on focus; Typography spacing icons support scrubbing (#373); CSS colour names in hex fields; a rename selects the whole name, so typing replaces it; Escape or Enter after a pen path stays on the same vector (#760); IME composition kept on through idle frames of a text edit, candidates at the caret (#747); Selection colors for mixed selections and smart-selection spacing (#753); submenus with a hover delay and a safe triangle; the Actions palette's tabs, ranked search, highlight and recents; menu items that ask for a value; editor fixes from the community batches) · **Target:** Figma Design UI3 (desktop app 126.10.7) Fine canvas control is what Figma users feel first: handles, snapping, nudging, modifier keys, drag behaviour, numeric precision. This checklist covers the tools, gestures and panels. Status: @@ -75,7 +75,7 @@ interaction feel not yet compared. | Locked layers | done | selectable from the layers panel; no handles, not dragged with the rest of a selection | | Smart selection spacing | partial | with layers in a row, a column or an aligned grid selected, the Layout section shows horizontal and vertical spacing ("Mixed" until set; typed, scrubbed, fractional and negative) and pink handles in the gaps on the canvas drag them, one undo step, Escape cancels (`arrange.spacing`; sizes, parents and offsets within each track kept; at most 2,000 layers; flow children, locked layers and layers inside instances left out). A ring at each layer's centre drags it to another place in a regular row, column or complete top-left-aligned grid: the others close up and make room around it with the gaps kept, and with Cmd/Ctrl held the two layers swap places instead, previewed live while dragging (`selection_reorder_tests`; no ring on a layer under 16 px on screen). Not covered: reordering irregular grids, and several marked layers moved together. Checked by its author on macOS and in the app on Windows (#402) | | Selection colors | partial | for a mixed selection the Design panel lists their solid and gradient paints (children and text ranges included; hidden paints, masks, images, patterns and boolean operands left out; styles and variables listed apart): editing a colour changes every matching fill and stroke in one undo step, a row selects the layers that use it or applies a color style or color variable of the document, and a row linked to a style or variable detaches it, keeping the color (`selection.colors`, `selection.setPaint`, `selection.applyColorStyle`, `selection.bindColorVariable`, `selection.detachColor`, `selection.selectColor`). No library styles or variables from there, and no new style or variable from a row (#403) | -| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes | +| Keyboard box selection | done | Alt+Space on macOS, Ctrl+Space on Windows and Linux, or F8 (`select.area`): a box around the selection or in the middle of the view; arrows move it, Cmd/Ctrl+arrows or Shift+arrows size it (Shift with Cmd/Ctrl: farther), Enter selects like a marquee (Shift+Enter toggles, Cmd/Ctrl+Enter nested), Escape closes; Tab while the box shows, or the chord again, switches to pick select: a pink outline on the first selected layer (the page's top layer with nothing selected), Tab and Shift+Tab move it between sibling layers in Layers order, Enter into a frame's top child, Shift+Enter out to the parent, Space selects it, Escape or a press on the canvas ends it; `ui.key` takes the same keys (#1836) | ## Tools @@ -129,8 +129,8 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | | Count | Notes | |---|---|---| -| Documented shortcuts (rows) | 134 | each with macOS and Windows/Linux keys | -| Bound as documented | 133 (110 before this suite) | 112 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys | +| Documented shortcuts (rows) | 135 | each with macOS and Windows/Linux keys | +| Bound as documented | 134 (110 before this suite) | 112 keymap bindings, 15 held keys, gestures and presentation keys, 6 vector edit tool keys | | Bound on other keys (documented conflict) | 1 | Run last plugin: the documentation also gives Ctrl+Alt+P to pixel preview on Windows; the plugin takes Ctrl+Alt+Shift+P there | | Missing | 0 | moving the rotation origin (Alt+R, `transform.rotationOrigin`) and placing an object with Enter after choosing its tool (`tool.place`) were the last two | | Documented keys a platform keeps | 2 | Ctrl+Space (Windows/Linux keyboard box selection) switches some CJK input methods; F6 goes to a browser's address bar in the web build | @@ -177,6 +177,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | Date | Change | Summary | |---|---|---| +| 2026-11-02 | minor | M1, #1836: pick select. Tab while the keyboard selection box shows, or Alt/Ctrl+Space again, puts a pink outline on the first selected layer (the page's top layer with nothing selected); Tab and Shift+Tab move it between sibling layers as the Layers panel lists them, Enter into a frame's top child, Shift+Enter out to the parent (never the page), Space selects the layer under it, Escape or a press on the canvas ends it; the chord again while the pick cursor shows brings the box back ("Press Tab or ⌥Option Space / Control Space again to switch to pick select", "a pink outline is placed on a selected objects on the canvas, or any node if nothing is selected", "Use Tab to move between parent objects on the canvas, Return/Enter and ⇧Shift Return / Enter to move between parent and child layers. Press Space to make you selection", Accessibility at Figma). The control channel's `ui.key` sends the box's and the pick cursor's keys to them too. One documented row more (135, 134 bound); four gesture rows. Tests: `layers_keyboard_tests::tab_switches_the_selection_box_to_pick_select`. | | 2026-11-02 | patch | M4: Backspace or Delete at the start of a list item takes its marker and keeps its level ("Use Backspace or Delete at the beginning of a list item to delete the counter, but keep the same level of indentation", Create bulleted and numbered lists): a plain paragraph may now carry a list level, and the text layout starts it where that level's item text starts (the gutters stay even when no list is left). Backspace used to drop the level too, and Delete at an item's start deleted the next character. Tab on such a paragraph types a tab, as outside a list. Tests: `text_tail_tests::list_keys_at_list_items`, text crate `behaviour_tests::a_plain_paragraph_at_a_list_level_starts_where_the_items_text_does` | | 2026-11-02 | patch | M4: max lines on an auto-width text keep it auto width: the box hugs up to that many lines and still grows with its longest line ("Max lines is available only if truncate text is enabled, and: Text resizing is set to auto height or auto width", Explore text properties). `text.maxLines` turned such a text into a fixed truncating box with a stale height, so later typing no longer widened it; fixed-size text still becomes a truncating box, auto-height text hugs as before. Test: `text_behaviour_tests::max_lines_on_auto_width_text_keeps_auto_width_and_hugs_that_many_lines` | | 2026-11-02 | patch | M4, #1828: superscript and subscript synthesised where the font has no `sups` / `subs` glyphs (the standard glyph shrunk and shifted by the OS/2 metrics, the usual 0.65 em and 0.35 / 0.14 em offsets without them), and a run mixing available and unavailable glyphs synthesised whole, as Explore text properties says. Tests: `text::tests::superscript_native_or_synthesised_and_never_mixed`, `text_behaviour_tests::superscript_and_subscript_are_synthesised_without_glyphs_for_them`. | From 81799aca09705b24cca0555a6a4d8172a0ebca3e Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 09:17:50 -0500 Subject: [PATCH 2/6] Pick select: the pick carries its document's uid UiState.pick is (uid, layer); FigApp::picked() answers only while that document is in front and the layer exists, so another document's layer with the same id is never walked to or selected. Test extended with a same-id layer in a new document. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/canvas/overlays.rs | 2 +- crates/ui-egui/src/layers_keyboard_tests.rs | 39 +++++++++++++++------ crates/ui-egui/src/lib.rs | 5 +-- crates/ui-egui/src/shortcuts.rs | 23 +++++++++--- 4 files changed, 50 insertions(+), 19 deletions(-) diff --git a/crates/ui-egui/src/canvas/overlays.rs b/crates/ui-egui/src/canvas/overlays.rs index da5b68f2f..deb284959 100644 --- a/crates/ui-egui/src/canvas/overlays.rs +++ b/crates/ui-egui/src/canvas/overlays.rs @@ -763,7 +763,7 @@ pub(super) fn accent(p: &egui::Painter) -> Color32 { /// Pick select: the pink outline on the layer the keys walked to (#1836). fn pick_outline(app: &FigApp, painter: &egui::Painter, doc: &Document) { - if let Some(p) = app.ui.pick.filter(|p| doc.node(*p).is_some()) { + if let Some(p) = app.picked().filter(|p| doc.node(*p).is_some()) { draw_outline(app, painter, doc, p, stroke(crate::handles::PINK, 2.0)); } } diff --git a/crates/ui-egui/src/layers_keyboard_tests.rs b/crates/ui-egui/src/layers_keyboard_tests.rs index adbb9a510..5a9f78597 100644 --- a/crates/ui-egui/src/layers_keyboard_tests.rs +++ b/crates/ui-egui/src/layers_keyboard_tests.rs @@ -239,44 +239,61 @@ fn tab_switches_the_selection_box_to_pick_select() { press(&mut u, Key::F8, Modifiers::NONE); press(&mut u, Key::Tab, Modifiers::NONE); assert!(u.app.ui.key_box.is_none(), "the box becomes the pick cursor"); - assert_eq!(u.app.ui.pick, Some(a), "on the selected layer"); + assert_eq!(u.app.picked(), Some(a), "on the selected layer"); assert_eq!(u.app.session.selection(), vec![a], "nothing selected yet"); // Tab walks the siblings as the Layers panel lists them (the frame on top, then B, then A), // wrapping; Shift+Tab goes the other way. press(&mut u, Key::Tab, Modifiers::NONE); - assert_eq!(u.app.ui.pick, Some(f), "below A comes the top of the list"); + assert_eq!(u.app.picked(), Some(f), "below A comes the top of the list"); press(&mut u, Key::Tab, Modifiers::SHIFT); - assert_eq!(u.app.ui.pick, Some(a)); + assert_eq!(u.app.picked(), Some(a)); press(&mut u, Key::Tab, Modifiers::SHIFT); - assert_eq!(u.app.ui.pick, Some(b)); + assert_eq!(u.app.picked(), Some(b)); // Enter goes into a frame's top child, Shift+Enter back out; the page is never picked. press(&mut u, Key::Tab, Modifiers::SHIFT); - assert_eq!(u.app.ui.pick, Some(f)); + assert_eq!(u.app.picked(), Some(f)); press(&mut u, Key::Enter, Modifiers::NONE); - assert_eq!(u.app.ui.pick, Some(c)); + assert_eq!(u.app.picked(), Some(c)); press(&mut u, Key::Enter, Modifiers::SHIFT); press(&mut u, Key::Enter, Modifiers::SHIFT); - assert_eq!(u.app.ui.pick, Some(f)); + assert_eq!(u.app.picked(), Some(f)); assert_eq!(u.app.session.selection(), vec![a], "walking selects nothing"); // Space selects the picked layer and ends pick select. press(&mut u, Key::Space, Modifiers::NONE); assert_eq!(u.app.session.selection(), vec![f]); - assert!(u.app.ui.pick.is_none()); + assert!(u.app.picked().is_none()); // The chord again while the box shows switches too; Escape ends it, the selection kept. press(&mut u, Key::F8, Modifiers::NONE); press(&mut u, Key::F8, Modifiers::NONE); assert!(u.app.ui.key_box.is_none()); - assert_eq!(u.app.ui.pick, Some(f)); + assert_eq!(u.app.picked(), Some(f)); press(&mut u, Key::Escape, Modifiers::NONE); - assert!(u.app.ui.pick.is_none()); + assert!(u.app.picked().is_none()); assert_eq!(u.app.session.selection(), vec![f]); // Nothing selected: the pick starts on the page's top layer; a press on the canvas ends it. u.app.run("select.none", json!({})); press(&mut u, Key::F8, Modifiers::NONE); press(&mut u, Key::Tab, Modifiers::NONE); - assert_eq!(u.app.ui.pick, Some(f)); + assert_eq!(u.app.picked(), Some(f)); let p = u.app.canvas.rect.center(); u.button(p, true); u.button(p, false); + assert!(u.app.picked().is_none()); + // Picked in one document, the cursor stays behind when another comes to the front, even where + // that one has a layer with the same id (each document numbers its layers from 1): Space + // there selects nothing, and the stale pick is dropped. + u.app.run("select.set", json!({"ids": [a]})); + press(&mut u, Key::F8, Modifiers::NONE); + press(&mut u, Key::Tab, Modifiers::NONE); + assert_eq!(u.app.picked(), Some(a)); + u.app.run("file.new", json!({})); + for _ in 0..3 { + u.app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); + } + u.app.run("select.none", json!({})); + assert!(u.app.session.doc().unwrap().node(a).is_some(), "the new document has a layer with the picked id"); + assert_eq!(u.app.picked(), None); + press(&mut u, Key::Space, Modifiers::NONE); + assert!(u.app.session.selection().is_empty(), "nothing selected in the new document"); assert!(u.app.ui.pick.is_none()); } diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 9857a3320..6207b59dd 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -482,8 +482,9 @@ pub struct UiState { /// marquee's start at `x0, y0`. pub key_box: Option<[f64; 4]>, /// Pick select (#1836): the layer the pink outline is on while the keys walk the layer tree - /// (Tab or the selection-box chord again while the box shows; Space selects it). - pub pick: Option, + /// (Tab or the selection-box chord again while the box shows; Space selects it), with the uid + /// of its document: layer ids are only unique within a document ([`FigApp::picked`]). + pub pick: Option<(u64, NodeId)>, /// Layers panel search: open, the filter text, the shift-click range anchor and the selection /// the tree last revealed (ancestors expand and the row scrolls into view when it changes). pub layer_search: bool, diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index dce0861d2..2f0826e74 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -576,12 +576,17 @@ fn pick_sibling(doc: &figcraft_doc::Document, id: figcraft_doc::NodeId, dir: i64 /// move between parent and child layers. Press Space to make you selection. Press Esc or click /// anywhere on the canvas to exit selection mode.", Accessibility at Figma, #1836). pub(crate) fn key_pick(app: &mut FigApp, key: Key, m: Modifiers) -> bool { - let Some(at) = app.ui.pick else { return false }; - let Ok(doc) = app.session.doc() else { return false }; - let Some(node) = doc.node(at) else { + if app.ui.pick.is_none() { + return false; + } + // Picked in another document (one switched away from or closed since) or deleted: gone. + let Some(at) = app.picked() else { app.ui.pick = None; return false; }; + let Ok(uid) = app.session.ds().map(|d| d.uid) else { return false }; + let Ok(doc) = app.session.doc() else { return false }; + let Some(node) = doc.node(at) else { return false }; let plain = !m.command && !m.alt && !m.ctrl; let next = match key { Key::Tab if plain => pick_sibling(doc, at, if m.shift { -1 } else { 1 }), @@ -599,12 +604,20 @@ pub(crate) fn key_pick(app: &mut FigApp, key: Key, m: Modifiers) -> bool { _ => return false, }; if let Some(n) = next { - app.ui.pick = Some(n); + app.ui.pick = Some((uid, n)); } true } impl FigApp { + /// The layer the pick cursor is on, while its document is the one in front and the layer is + /// still there (#1836). + pub(crate) fn picked(&self) -> Option { + let (uid, id) = self.ui.pick?; + let ds = self.session.ds().ok()?; + (ds.uid == uid && ds.doc.node(id).is_some()).then_some(id) + } + /// Pick select: the pink outline on the first selected layer, or on the page's top layer when /// nothing is selected ("Once activated, a pink outline is placed on a selected objects on the /// canvas, or any node if nothing is selected", Accessibility at Figma); the keys walk the @@ -616,7 +629,7 @@ impl FigApp { self.toast(crate::i18n::tr("Nothing to pick: the page is empty")); return; }; - self.ui.pick = Some(at); + self.ui.pick = Some((ds.uid, at)); self.toast(crate::i18n::tr( "Pick select: Tab and Shift+Tab move between layers, Enter goes into one, Shift+Enter out, Space selects, Escape cancels", )); From 66ab8566e0f904165165aef80c977fd401f3982b Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Mon, 2 Nov 2026 09:51:24 -0500 Subject: [PATCH 3/6] Pick select: the pick holds only on the page it was made on FigApp::picked() also requires the layer to be on the page shown, so after page.select the cursor isn't drawn and Space selects nothing there. Test extended. Co-Authored-By: Claude Opus 5.5 --- crates/ui-egui/src/layers_keyboard_tests.rs | 14 ++++++++++++++ crates/ui-egui/src/lib.rs | 3 ++- crates/ui-egui/src/shortcuts.rs | 4 ++-- 3 files changed, 18 insertions(+), 3 deletions(-) diff --git a/crates/ui-egui/src/layers_keyboard_tests.rs b/crates/ui-egui/src/layers_keyboard_tests.rs index 5a9f78597..871aeff02 100644 --- a/crates/ui-egui/src/layers_keyboard_tests.rs +++ b/crates/ui-egui/src/layers_keyboard_tests.rs @@ -286,6 +286,20 @@ fn tab_switches_the_selection_box_to_pick_select() { press(&mut u, Key::F8, Modifiers::NONE); press(&mut u, Key::Tab, Modifiers::NONE); assert_eq!(u.app.picked(), Some(a)); + // On another page of the same document the cursor isn't there either: Space selects nothing. + let first_page = u.app.session.page().unwrap(); + u.app.run("page.add", json!({})); + u.app.run("page.select", json!({"index": 1})); + assert_ne!(u.app.session.page().unwrap(), first_page); + assert_eq!(u.app.picked(), None); + press(&mut u, Key::Space, Modifiers::NONE); + assert!(u.app.session.selection().is_empty(), "nothing selected on the other page"); + assert!(u.app.ui.pick.is_none()); + u.app.run("page.select", json!({"index": 0})); + u.app.run("select.set", json!({"ids": [a]})); + press(&mut u, Key::F8, Modifiers::NONE); + press(&mut u, Key::Tab, Modifiers::NONE); + assert_eq!(u.app.picked(), Some(a)); u.app.run("file.new", json!({})); for _ in 0..3 { u.app.run("shape.rectangle", json!({"x": 0, "y": 0, "width": 10, "height": 10})); diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 6207b59dd..123ce41c2 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -483,7 +483,8 @@ pub struct UiState { pub key_box: Option<[f64; 4]>, /// Pick select (#1836): the layer the pink outline is on while the keys walk the layer tree /// (Tab or the selection-box chord again while the box shows; Space selects it), with the uid - /// of its document: layer ids are only unique within a document ([`FigApp::picked`]). + /// of its document: layer ids are only unique within a document, and the pick holds only on + /// the page shown ([`FigApp::picked`]). pub pick: Option<(u64, NodeId)>, /// Layers panel search: open, the filter text, the shift-click range anchor and the selection /// the tree last revealed (ancestors expand and the row scrolls into view when it changes). diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 2f0826e74..c7c4e81b3 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -611,11 +611,11 @@ pub(crate) fn key_pick(app: &mut FigApp, key: Key, m: Modifiers) -> bool { impl FigApp { /// The layer the pick cursor is on, while its document is the one in front and the layer is - /// still there (#1836). + /// still there, on the page shown (#1836). pub(crate) fn picked(&self) -> Option { let (uid, id) = self.ui.pick?; let ds = self.session.ds().ok()?; - (ds.uid == uid && ds.doc.node(id).is_some()).then_some(id) + (ds.uid == uid && ds.doc.page_of(id) == Some(ds.page)).then_some(id) } /// Pick select: the pink outline on the first selected layer, or on the page's top layer when From 49d1e2e31cea3c3729d38049707b86291b17c506 Mon Sep 17 00:00:00 2001 From: Trevor Brown Date: Thu, 5 Nov 2026 17:32:30 -0500 Subject: [PATCH 4/6] pick select: a Prototype-tab press clears the pick like any canvas press A press on a connection handle or noodle returns from the canvas input before the ordinary press reset, so the pick (and keyboard box) stayed up. Clear them on that press too. Co-Authored-By: Claude Fable 5.1 --- crates/ui-egui/src/canvas/input.rs | 10 ++++++++++ crates/ui-egui/src/layers_keyboard_tests.rs | 19 +++++++++++++++++++ 2 files changed, 29 insertions(+) diff --git a/crates/ui-egui/src/canvas/input.rs b/crates/ui-egui/src/canvas/input.rs index dc5647376..1f0f4a557 100644 --- a/crates/ui-egui/src/canvas/input.rs +++ b/crates/ui-egui/src/canvas/input.rs @@ -140,6 +140,14 @@ fn import_dropped(app: &mut FigApp, ctx: &egui::Context, hover_pos: Option boo if crate::proto_canvas::input(app, ui, resp) { // A prototype gesture is a press on the canvas too: the Pages list gives up the keyboard. app.ui.pages_focus = None; + // And it ends the keyboard selection box and pick select, as any canvas press does. + end_keyboard_box(app, ui.ctx()); return true; } let ctx = ui.ctx().clone(); diff --git a/crates/ui-egui/src/layers_keyboard_tests.rs b/crates/ui-egui/src/layers_keyboard_tests.rs index 871aeff02..6fd06bba7 100644 --- a/crates/ui-egui/src/layers_keyboard_tests.rs +++ b/crates/ui-egui/src/layers_keyboard_tests.rs @@ -311,3 +311,22 @@ fn tab_switches_the_selection_box_to_pick_select() { assert!(u.app.session.selection().is_empty(), "nothing selected in the new document"); assert!(u.app.ui.pick.is_none()); } + +/// A press on a prototype connection handle is a press on the canvas too: it ends pick select +/// like any other canvas press, rather than leaving the pink outline up. +#[test] +fn a_prototype_handle_press_ends_pick_select() { + let (mut u, a, _) = setup(); + u.app.run("select.set", json!({"ids": [a]})); + press(&mut u, Key::F8, Modifiers::NONE); + press(&mut u, Key::Tab, Modifiers::NONE); + assert_eq!(u.app.picked(), Some(a)); + u.app.ui.right_tab = crate::RightTab::Prototype; + u.frame(vec![]); + assert_eq!(u.app.picked(), Some(a), "still picking in the Prototype tab"); + let b = u.app.session.doc().unwrap().world_bounds(a).unwrap(); + let handle = u.app.to_screen(b.x1, (b.y0 + b.y1) / 2.0) + egui::vec2(11.0, 0.0); + u.button(handle, true); + u.button(handle, false); + assert!(u.app.ui.pick.is_none(), "the handle press ends pick select"); +} From 06f29b08ef10a72992d9618342b760f68d0fb14e Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 07:59:24 +0700 Subject: [PATCH 5/6] Pick select: say what the keys do in our own words --- crates/ui-egui/src/shortcuts.rs | 8 ++------ 1 file changed, 2 insertions(+), 6 deletions(-) diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 0e8d214b7..e32be309b 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -575,10 +575,7 @@ fn pick_sibling(doc: &figcraft_doc::Document, id: figcraft_doc::NodeId, dir: i64 /// Keys while the pick cursor shows ([`FigApp::start_pick_select`]): Tab and Shift+Tab move it between /// sibling layers in Layers-panel order, Enter into the top child of a frame or group, Shift+Enter -/// out to the parent (never the page), Space selects the layer under it, Escape drops it ("Use -/// Tab to move between parent objects on the canvas, Return/Enter and ⇧Shift Return / Enter to -/// move between parent and child layers. Press Space to make you selection. Press Esc or click -/// anywhere on the canvas to exit selection mode.", Accessibility at Figma, #1836). +/// out to the parent (never the page), Space selects the layer under it, Escape or a click on the canvas drops it (#1836). pub(crate) fn key_pick(app: &mut FigApp, key: Key, m: Modifiers) -> bool { if app.ui.pick.is_none() { return false; @@ -623,8 +620,7 @@ impl FigApp { } /// Pick select: the pink outline on the first selected layer, or on the page's top layer when - /// nothing is selected ("Once activated, a pink outline is placed on a selected objects on the - /// canvas, or any node if nothing is selected", Accessibility at Figma); the keys walk the + /// nothing is selected; the keys walk the /// layer tree from there ([`key_pick`]). pub(crate) fn start_pick_select(&mut self) { let Ok(ds) = self.session.ds() else { return }; From f9d50abbad9bd62225075e9ec9b7efd6af72f2c2 Mon Sep 17 00:00:00 2001 From: bflatastic Date: Sat, 7 Nov 2026 11:12:02 +0700 Subject: [PATCH 6/6] Pick select: say what it does in our own words --- crates/ui-egui/src/layers_keyboard_tests.rs | 9 +++------ crates/ui-egui/src/shortcuts.rs | 3 +-- docs/ui-parity.md | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/crates/ui-egui/src/layers_keyboard_tests.rs b/crates/ui-egui/src/layers_keyboard_tests.rs index 6fd06bba7..9be301f49 100644 --- a/crates/ui-egui/src/layers_keyboard_tests.rs +++ b/crates/ui-egui/src/layers_keyboard_tests.rs @@ -223,12 +223,9 @@ impl crate::FigApp { } } -/// Pick select (#1836): "Press Tab or ⌥Option Space / Control Space again to switch to pick -/// select." "Once activated, a pink outline is placed on a selected objects on the canvas, or any -/// node if nothing is selected." "Use Tab to move between parent objects on the canvas, -/// Return/Enter and ⇧Shift Return / Enter to move between parent and child layers. Press Space to -/// make you selection. Press Esc or click anywhere on the canvas to exit selection mode." -/// (Accessibility at Figma). +/// Pick select (#1836): Tab turns the selection box into a pink outline on the selected layer (or +/// the page's top layer); Tab walks siblings, Enter and Shift+Enter go down and up the tree, Space +/// selects, and Esc or a click on the canvas leaves it. #[test] fn tab_switches_the_selection_box_to_pick_select() { let (mut u, a, b) = setup(); diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index e32be309b..26bdb60d2 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -523,8 +523,7 @@ fn key_event(app: &mut FigApp, key: Key, physical: Option, m: Modifiers, re /// marquee (Shift toggles, Cmd/Ctrl takes nested layers), Escape drops it. pub(crate) fn key_box(app: &mut FigApp, key: Key, m: Modifiers) -> bool { let Some(mut b) = app.ui.key_box else { return false }; - // "Press Tab ... to switch to pick select" (Accessibility at Figma): the box becomes the - // pick cursor. + // Tab: the box becomes the pick cursor. if key == Key::Tab && m.is_none() { app.ui.key_box = None; app.start_pick_select(); diff --git a/docs/ui-parity.md b/docs/ui-parity.md index 4bda896d8..d08ed4c60 100644 --- a/docs/ui-parity.md +++ b/docs/ui-parity.md @@ -189,7 +189,7 @@ in the table. `crates/ui-egui/src/shortcut_keys_tests.rs` drives the keys a look | 2026-11-06 | patch | M7, #2032: closing a tab whose file has unsaved changes, by its X, a middle click, File ▸ Close tab or its shortcut, only showed a toast ("save first, or close again with Alt to discard") and left the tab open, so closing seemed not to work. It now asks the unsaved-changes question the window asks (`windows::TabClose`): Don't Save closes the tab, Cancel or Escape keeps it, Save saves the file and then closes it (a file without a path, or saved as .fig, asks where with Save as first and stays open if that is cancelled); Alt+close still drops the changes at once, and `file.close`'s `dialog.unsaved` request asks too. The three toasts left the catalogs. Tests: `titlebar_tests::closing_a_tab_with_unsaved_changes_asks`, `titlebar_tests::save_in_the_tab_question_saves_then_closes`. Reordering tabs by dragging, the issue's other half, landed on 2026-10-19 (`dragging_a_tab_along_the_bar_reorders_it_without_detaching`) | | 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`. | | 2026-11-05 | minor | M4, #1830: a paragraph's base direction can be forced (`TextDirection`: Auto, Ltr, Rtl on `ParaList`): Text ▸ Text direction ▸ Auto / Left to right / Right to left (`text.directionAuto` / `Ltr` / `Rtl`) and `text.direction {direction, ids?}` set the edited paragraphs while editing, else the layer's; `node.set textDirection`; the Type settings control once a right-to-left script is in the text; inspect `textDirection` / `lineDirections`; CSS `direction`; `.fig` lines' `sourceDirectionality` in and out. Layout gives each paragraph its base level (`Bidi::of` runs the algorithm per paragraph, as it starts over at each). Before, direction came from the letters only ("controls are available to override text direction per paragraph", Add right-to-left text). Tests: `text_behaviour_tests::text_direction_is_forced_per_paragraph`, `fig::text_direction_tests::a_lines_source_directionality_is_its_paragraphs_direction_both_ways`. | -| 2026-11-02 | minor | M1, #1836: pick select. Tab while the keyboard selection box shows, or Alt/Ctrl+Space again, puts a pink outline on the first selected layer (the page's top layer with nothing selected); Tab and Shift+Tab move it between sibling layers as the Layers panel lists them, Enter into a frame's top child, Shift+Enter out to the parent (never the page), Space selects the layer under it, Escape or a press on the canvas ends it; the chord again while the pick cursor shows brings the box back ("Press Tab or ⌥Option Space / Control Space again to switch to pick select", "a pink outline is placed on a selected objects on the canvas, or any node if nothing is selected", "Use Tab to move between parent objects on the canvas, Return/Enter and ⇧Shift Return / Enter to move between parent and child layers. Press Space to make you selection", Accessibility at Figma). The control channel's `ui.key` sends the box's and the pick cursor's keys to them too. One documented row more (135, 134 bound); four gesture rows. Tests: `layers_keyboard_tests::tab_switches_the_selection_box_to_pick_select`. | +| 2026-11-02 | minor | M1, #1836: pick select. Tab while the keyboard selection box shows, or Alt/Ctrl+Space again, puts a pink outline on the first selected layer (the page's top layer with nothing selected); Tab and Shift+Tab move it between sibling layers as the Layers panel lists them, Enter into a frame's top child, Shift+Enter out to the parent (never the page), Space selects the layer under it, Escape or a press on the canvas ends it; the chord again while the pick cursor shows brings the box back (Accessibility at Figma). The control channel's `ui.key` sends the box's and the pick cursor's keys to them too. One documented row more (135, 134 bound); four gesture rows. Tests: `layers_keyboard_tests::tab_switches_the_selection_box_to_pick_select`. | | 2026-11-05 | patch | M1, #2003: the Design panel's boolean operation menu leads Union, Subtract, Intersect and Exclude with original icons drawn in code (two offset squares, the result filled at reduced alpha). Numbers unchanged. | | 2026-11-05 | patch | M1, #1758: a mouse's side buttons (4 and 5) pan the canvas as the middle button does, the pan ending on release over the canvas or off it; the grab cursor shows while they're held. | | 2026-11-04 | patch | M1, #1852: a press on one of the open gradient's handles or stops on the canvas is the colour picker's own, not a press outside it (Figma keeps the picker and the handles up while a handle or stop is dragged; the stops are made to be dragged: "You can click and drag a stop to change its placement on the gradient", Use gradients as a fill or stroke). The canvas took the press and began the drag, then the picker, drawn later in the same frame, saw a press outside its card and closed, committing the drag's interaction at once; the handles vanished with it and the drag went on without an undo step of its own. Now the picker stays while a gradient handle or stop drags, and the drag is one undo step; a press elsewhere on the canvas still closes it. A handle drag whose button goes up off the canvas (over a panel or the picker) ends there too, instead of waiting, uncommitted, to move the handle to the next press on the canvas. Tests: `colorpicker_selection_tests::dragging_a_gradient_handle_on_the_canvas_keeps_the_picker_open`, `colorpicker_selection_tests::a_gradient_drag_released_off_the_canvas_ends_there`. |