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 <noreply@anthropic.com>
This commit is contained in:
BFlat 2026-10-10 12:46:45 +07:00 committed by GitHub
parent 6f9801790c
commit 9375279198
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
7 changed files with 201 additions and 8 deletions

View File

@ -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 |

View File

@ -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 {

View File

@ -351,6 +351,75 @@ fn matching(doc: &Document, id: NodeId, q: &str, out: &mut Vec<Row>) {
}
}
/// 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<NodeId>, 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<NodeId> = 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<NodeId>) {
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<NodeId> = 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);

View File

@ -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<Event>) {
pub(crate) fn frame(&mut self, events: Vec<Event>) {
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() },

View File

@ -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<NodeId> {
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);
}

View File

@ -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<NodeId>,
/// 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<NodeId>,
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,

View File

@ -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),