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:
parent
6f9801790c
commit
9375279198
|
|
@ -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 |
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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() },
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
|
|
|
|||
Loading…
Reference in New Issue