From 93752791981a40eae70e99cc4d81de051ad6848a Mon Sep 17 00:00:00 2001 From: BFlat Date: Sat, 10 Oct 2026 12:46:45 +0700 Subject: [PATCH] M1: keyboard navigation in the layers panel (#71) After a click on a layer row the panel has the keyboard: Up/Down select the row above or below (Shift extends the range from the anchor), Left collapses a row or goes to its parent, Right expands a row or goes to its first child; the search results navigate the same way. A press on the canvas gives the arrows back to nudging. Co-authored-by: Claude Opus 5.5 --- ROADMAP.md | 2 +- crates/ui-egui/src/canvas.rs | 4 + crates/ui-egui/src/layers.rs | 70 +++++++++++++++ crates/ui-egui/src/layers_drag_tests.rs | 14 +-- crates/ui-egui/src/layers_keys_tests.rs | 109 ++++++++++++++++++++++++ crates/ui-egui/src/lib.rs | 6 ++ crates/ui-egui/src/shortcuts.rs | 4 + 7 files changed, 201 insertions(+), 8 deletions(-) create mode 100644 crates/ui-egui/src/layers_keys_tests.rs diff --git a/ROADMAP.md b/ROADMAP.md index ce6e6d2..75173c2 100644 --- a/ROADMAP.md +++ b/ROADMAP.md @@ -215,7 +215,7 @@ Scores by behaviour, 0–100%. Hours are what remains (one agent). |---|---|---|---|---| | Canvas & navigation | 6 | 75% | rulers and guides on rotated frames, visual QA at extreme zooms | 1–2 | | Selection & transform | 8 | 68% | measure hover polish, scale tool on-canvas handles | 5–8 | -| Layers panel | 5 | 79% | keyboard row navigation | 1–2 | +| Layers panel | 5 | 83% | Tab to rename the next layer, visual QA of long lists | 1 | | Shapes & vector | 8 | 74% | live boolean editing, arc handles on canvas, shape builder on strokes and open paths, one-sided widths | 4–8 | | Paint | 7 | 78% | a wide-gamut canvas (egui draws sRGB, so Display P3 colours outside sRGB clip on screen; exports keep them), images' own embedded colour profiles (images are taken as sRGB) | 1–3 | | Strokes | 3 | 80% | stroke styles, arrowhead previews in the menus | 2–3 | diff --git a/crates/ui-egui/src/canvas.rs b/crates/ui-egui/src/canvas.rs index 45b666a..cbe67ed 100644 --- a/crates/ui-egui/src/canvas.rs +++ b/crates/ui-egui/src/canvas.rs @@ -285,6 +285,10 @@ fn input(app: &mut FigApp, ui: &mut Ui, resp: &egui::Response) { let Some(pos) = hover_pos.filter(|p| app.canvas.rect.contains(*p)) else { return; }; + // A press on the canvas gives the keyboard back to it (arrows nudge again). + if resp.hovered() && ctx.input(|i| i.pointer.any_pressed()) { + app.ui.layers_focus = false; + } // Zoom (pinch, Cmd/Ctrl+scroll) about the pointer; plain scroll pans. if resp.hovered() { if (zoom_delta - 1.0).abs() > 1e-4 { diff --git a/crates/ui-egui/src/layers.rs b/crates/ui-egui/src/layers.rs index 80fe1df..30f952c 100644 --- a/crates/ui-egui/src/layers.rs +++ b/crates/ui-egui/src/layers.rs @@ -351,6 +351,75 @@ fn matching(doc: &Document, id: NodeId, q: &str, out: &mut Vec) { } } +/// Arrow keys while the layers panel has the keyboard: Up/Down select the row above or below +/// (Shift extends the range from the anchor), Left collapses a row or goes to its parent, Right +/// expands a row or goes to its first child. Returns whether the key was used. +pub(crate) fn key_nav(app: &mut FigApp, key: egui::Key, shift: bool) -> bool { + use egui::Key; + if !matches!(key, Key::ArrowUp | Key::ArrowDown | Key::ArrowLeft | Key::ArrowRight) { + return false; + } + let Ok(ds) = app.session.ds() else { return false }; + let (doc, page, sel) = (ds.doc.clone(), ds.page, ds.selection.clone()); + // The rows as listed: the expanded tree, or the search results. + let filter = app.ui.layer_filter.trim().to_lowercase(); + let mut rows = Vec::new(); + if filter.is_empty() { + collect(&doc, page, 0, &app.ui.expanded, &mut rows); + } else { + matching(&doc, page, &filter, &mut rows); + } + let select = |app: &mut FigApp, ids: Vec, anchor: NodeId| { + app.ui.layer_anchor = Some(anchor); + app.run("select.set", json!({"ids": ids})); + }; + let Some((i, row)) = sel.last().and_then(|c| rows.iter().enumerate().find(|(_, r)| r.id == *c)) else { + // Nothing listed is selected: Down starts at the top, Up at the bottom. + let first = if key == Key::ArrowUp { rows.last() } else { rows.first() }; + if let Some(r) = first.filter(|_| matches!(key, Key::ArrowUp | Key::ArrowDown)) { + select(app, vec![r.id], r.id); + } + return true; + }; + let (cur, depth, has_kids) = (row.id, row.depth, row.has_kids); + match key { + Key::ArrowUp | Key::ArrowDown => { + let j = if key == Key::ArrowUp { i.checked_sub(1) } else { Some(i + 1).filter(|j| *j < rows.len()) }; + let Some((j, next)) = j.and_then(|j| rows.get(j).map(|r| (j, r.id))) else { return true }; + if shift { + // The rows from the anchor to the new one, with the moving end last. + let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).unwrap_or(cur); + let a = rows.iter().position(|r| r.id == anchor).unwrap_or(i); + let ids: Vec = if a <= j { + rows.get(a..=j).unwrap_or_default().iter().map(|r| r.id).collect() + } else { + rows.get(j..=a).unwrap_or_default().iter().rev().map(|r| r.id).collect() + }; + select(app, ids, anchor); + } else { + select(app, vec![next], next); + } + } + Key::ArrowLeft => { + if has_kids && filter.is_empty() && app.ui.expanded.contains(&cur) { + app.ui.expanded.remove(&cur); + } else if let Some(p) = doc.node(cur).and_then(|n| n.parent).filter(|p| *p != page) { + select(app, vec![p], p); + } + } + _ => { + if has_kids && filter.is_empty() { + if !app.ui.expanded.contains(&cur) { + app.ui.expanded.insert(cur); + } else if let Some(c) = rows.get(i + 1).filter(|r| r.depth == depth + 1).map(|r| r.id) { + select(app, vec![c], c); + } + } + } + } + true +} + /// Expand or collapse `id` and everything inside it (Alt-click on a disclosure triangle). fn set_expanded_deep(doc: &Document, id: NodeId, open: bool, expanded: &mut BTreeSet) { let mut stack = vec![id]; @@ -737,6 +806,7 @@ fn layers(app: &mut FigApp, ui: &mut Ui) { } } if let Some((id, m)) = clicked { + app.ui.layers_focus = true; let order: Vec = rows.iter().map(|r| r.id).collect(); let pos = |x: NodeId| order.iter().position(|o| *o == x); let anchor = app.ui.layer_anchor.filter(|a| sel.contains(a)).and_then(pos); diff --git a/crates/ui-egui/src/layers_drag_tests.rs b/crates/ui-egui/src/layers_drag_tests.rs index a9aaa62..fa7e9d5 100644 --- a/crates/ui-egui/src/layers_drag_tests.rs +++ b/crates/ui-egui/src/layers_drag_tests.rs @@ -9,14 +9,14 @@ use serde_json::json; use crate::{FigApp, NoServices}; -struct Ui { - ctx: egui::Context, - app: FigApp, +pub(crate) struct Ui { + pub(crate) ctx: egui::Context, + pub(crate) app: FigApp, time: f64, } impl Ui { - fn new(s: Session) -> Self { + pub(crate) fn new(s: Session) -> Self { let mut u = Self { ctx: egui::Context::default(), app: FigApp::new(s, Box::new(NoServices)), time: 0.0 }; for _ in 0..3 { u.frame(vec![]); @@ -24,7 +24,7 @@ impl Ui { u } - fn frame(&mut self, events: Vec) { + pub(crate) fn frame(&mut self, events: Vec) { self.time += 1.0 / 60.0; let input = RawInput { screen_rect: Some(Rect::from_min_size(Pos2::ZERO, vec2(1440.0, 900.0))), time: Some(self.time), events, ..Default::default() }; @@ -33,7 +33,7 @@ impl Ui { } /// The middle of `id`'s row in the layers panel (found by hovering down the panel). - fn row_at(&mut self, id: NodeId) -> Pos2 { + pub(crate) fn row_at(&mut self, id: NodeId) -> Pos2 { let mut hits = Vec::new(); for y in (60..900).step_by(2) { let p = Pos2::new(150.0, y as f32); @@ -50,7 +50,7 @@ impl Ui { Pos2::new(150.0, (hits[0] + hits[hits.len() - 1]) / 2.0) } - fn button(&mut self, p: Pos2, pressed: bool) { + pub(crate) fn button(&mut self, p: Pos2, pressed: bool) { self.frame(vec![ Event::PointerMoved(p), Event::PointerButton { pos: p, button: egui::PointerButton::Primary, pressed, modifiers: Default::default() }, diff --git a/crates/ui-egui/src/layers_keys_tests.rs b/crates/ui-egui/src/layers_keys_tests.rs new file mode 100644 index 0000000..b78890a --- /dev/null +++ b/crates/ui-egui/src/layers_keys_tests.rs @@ -0,0 +1,109 @@ +//! Arrow keys in the layers panel once a row has been clicked: up/down through the rows (Shift +//! extends), left/right to collapse, expand, and go to the parent or first child; a press on the +//! canvas gives arrows back to nudging. + +use egui::{Event, Key, Modifiers, pos2}; +use figcraft_doc::NodeId; +use figcraft_engine::Session; +use serde_json::json; + +use crate::layers_drag_tests::Ui; + +fn id(v: serde_json::Value) -> NodeId { + NodeId(v["id"].as_u64().unwrap()) +} + +fn key(u: &mut Ui, k: Key, shift: bool) { + let modifiers = Modifiers { shift, ..Default::default() }; + u.frame(vec![ + Event::Key { key: k, physical_key: None, pressed: true, repeat: false, modifiers }, + Event::Key { key: k, physical_key: None, pressed: false, repeat: false, modifiers }, + ]); +} + +fn sel(u: &Ui) -> Vec { + u.app.session.selection() +} + +/// Rows top to bottom: `c` (rectangle), `frame` (collapsed, holding `child`), `a` (rectangle). +fn setup() -> (Ui, NodeId, NodeId, NodeId, NodeId) { + let mut s = Session::new(); + let a = id(s.execute("node.create", &json!({"type": "rectangle", "x": 0, "y": 0, "width": 10, "height": 10})).unwrap()); + let frame = id(s.execute("node.create", &json!({"type": "frame", "x": 50, "y": 0, "width": 100, "height": 100})).unwrap()); + let child = id(s.execute("node.create", &json!({"type": "ellipse", "parent": frame, "x": 10, "y": 10, "width": 10, "height": 10})).unwrap()); + let c = id(s.execute("node.create", &json!({"type": "rectangle", "x": 200, "y": 0, "width": 10, "height": 10})).unwrap()); + s.execute("select.none", &json!({})).unwrap(); + let mut u = Ui::new(s); + u.app.ui.expanded.clear(); + u.frame(vec![]); + (u, a, frame, child, c) +} + +fn click_row(u: &mut Ui, id: NodeId) { + let p = u.row_at(id); + u.button(p, true); + u.button(p, false); + assert_eq!(sel(u), vec![id]); +} + +#[test] +fn up_and_down_walk_the_rows_and_shift_extends() { + let (mut u, a, frame, _, c) = setup(); + click_row(&mut u, c); + key(&mut u, Key::ArrowDown, false); + assert_eq!(sel(&u), vec![frame]); + key(&mut u, Key::ArrowDown, false); + assert_eq!(sel(&u), vec![a]); + // The last row stays put. + key(&mut u, Key::ArrowDown, false); + assert_eq!(sel(&u), vec![a]); + key(&mut u, Key::ArrowUp, true); + key(&mut u, Key::ArrowUp, true); + let mut got = sel(&u); + got.sort(); + let mut want = vec![a, frame, c]; + want.sort(); + assert_eq!(got, want, "Shift+Up from the bottom row selects up to the top"); + // Back down with Shift shrinks the range towards the anchor. + key(&mut u, Key::ArrowDown, true); + let mut got = sel(&u); + got.sort(); + let mut want = vec![a, frame]; + want.sort(); + assert_eq!(got, want); + // Nothing moved on the canvas. + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.node(c).unwrap().transform.translation().x, 200.0); +} + +#[test] +fn left_and_right_open_close_and_move_between_levels() { + let (mut u, _, frame, child, _) = setup(); + click_row(&mut u, frame); + key(&mut u, Key::ArrowRight, false); + assert!(u.app.ui.expanded.contains(&frame), "Right opens a closed row"); + assert_eq!(sel(&u), vec![frame]); + key(&mut u, Key::ArrowRight, false); + assert_eq!(sel(&u), vec![child], "Right on an open row goes to its first child"); + key(&mut u, Key::ArrowLeft, false); + assert_eq!(sel(&u), vec![frame], "Left on a leaf goes to its parent"); + key(&mut u, Key::ArrowLeft, false); + assert!(!u.app.ui.expanded.contains(&frame), "Left closes an open row"); + assert_eq!(sel(&u), vec![frame]); +} + +#[test] +fn a_canvas_press_gives_arrows_back_to_nudging() { + let (mut u, _, _, _, c) = setup(); + click_row(&mut u, c); + assert!(u.app.ui.layers_focus); + // Press on empty canvas (deselects), then select c from the canvas side and nudge it. + let p = pos2(900.0, 700.0); + u.button(p, true); + u.button(p, false); + assert!(!u.app.ui.layers_focus); + u.app.run("select.set", json!({"ids": [c]})); + key(&mut u, Key::ArrowRight, false); + let doc = u.app.session.doc().unwrap(); + assert_eq!(doc.node(c).unwrap().transform.translation().x, 201.0); +} diff --git a/crates/ui-egui/src/lib.rs b/crates/ui-egui/src/lib.rs index 4e66fb3..30da60b 100644 --- a/crates/ui-egui/src/lib.rs +++ b/crates/ui-egui/src/lib.rs @@ -44,6 +44,8 @@ pub mod widgets; #[cfg(test)] mod layers_drag_tests; #[cfg(test)] +mod layers_keys_tests; +#[cfg(test)] mod perf_tests; #[cfg(test)] mod present_tests; @@ -162,6 +164,9 @@ pub struct UiState { pub layer_search: bool, pub layer_filter: String, pub layer_anchor: Option, + /// The layers panel has the keyboard: arrow keys move through its rows instead of nudging + /// (set by a click on a row, cleared by a press on the canvas). + pub layers_focus: bool, pub layers_seen_sel: Vec, pub dark: bool, pub follow_system: bool, @@ -228,6 +233,7 @@ impl Default for UiState { layer_search: false, layer_filter: String::new(), layer_anchor: None, + layers_focus: false, layers_seen_sel: Vec::new(), dark: false, follow_system: false, diff --git a/crates/ui-egui/src/shortcuts.rs b/crates/ui-egui/src/shortcuts.rs index 75f955c..a129b37 100644 --- a/crates/ui-egui/src/shortcuts.rs +++ b/crates/ui-egui/src/shortcuts.rs @@ -132,6 +132,10 @@ fn key_event(app: &mut FigApp, key: Key, m: Modifiers, repeat: bool, ctx: &Conte app.run("edit.delete", json!({})); return true; } + // Arrows move through the layers panel while it has the keyboard. + if app.ui.layers_focus && !m.command && !m.alt && crate::layers::key_nav(app, key, m.shift) { + return true; + } // Arrow nudges. let (dx, dy) = match key { Key::ArrowLeft => (-1.0, 0.0),