GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat(focus): navigation by group — members walk together, chords jump between runs
`UiContext::focus_clusters` regroups the reading-order stops: a registered
Group's members become one contiguous run, placed where the group's first
member falls and ordered among themselves by reading order; every other stop
is a run of one, and a member of two groups belongs to the first. `focus_step`
(Tab) walks the runs end to end; the new `focus_step_group` jumps to the next
or previous run's first stop, wrapping. The runner routes the `focus_next_group`
/ `focus_prev_group` chords (input.kdl, cce-ui domain; defaults `ctrl+tab` /
`ctrl+shift+tab`) to it for apps that opted into plate navigation, ahead of
the bare Tab. `CCE_FOCUS_DEBUG=1` now prints the runs.
Co-Authored-By: Claude Fable 5.1 <[email protected]>
CLAUDE.md | 6 +-
src/backend/window_runner.rs | 27 +++++---
src/context.rs | 152 ++++++++++++++++++++++++++++++++++++-------
3 files changed, 154 insertions(+), 31 deletions(-)
diff --git a/CLAUDE.md b/CLAUDE.md
index fc7b121..5a4a2ed 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -148,7 +148,11 @@ What this buys, and where the code is heading:
widget is to the keyboard: a `Plate` (a thing you press — Enter / Space act
on it while focused), a `Well` (opens for typing when focused), or `None`
(not a stop). `UiContext::focus_step` walks the stops in reading order (row,
- then x), wrapping; the runner calls it for Tab / Shift+Tab when the app opts
+ then x) with a `Group`'s members as one contiguous run where the group's
+ first member falls (`focus_clusters`), wrapping; `focus_step_group` jumps
+ between runs (input.kdl `focus_next_group` / `focus_prev_group`, defaults
+ `ctrl+tab` / `ctrl+shift+tab`). The runner calls them for Tab / Shift+Tab
+ and the chords when the app opts
in with `Application::plate_navigation` (default off, so an app that routes
Tab itself — a terminal, a web view, its own field order — is undisturbed)
and tells the app through `Application::focus_stepped` — an app that caches
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 146336f..765cf8f 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3530,6 +3530,10 @@ pub struct EngineState<A: Application> {
pub ctrl_pressed: bool,
/// The `undo` / `redo` chords, resolved from `input.kdl` at startup.
pub undo_chord: String,
+ /// `focus_next_group` / `focus_prev_group` (input.kdl, cce-ui domain):
+ /// the plate-navigation group jump, for apps that opt in.
+ pub group_next_chord: String,
+ pub group_prev_chord: String,
pub redo_chord: String,
pub shift_pressed: bool,
pub alt_pressed: bool,
@@ -4849,20 +4853,25 @@ impl<A: Application> EngineState<A> {
/// keyboard focus to the next / previous plate or well. Returns whether it
/// moved; otherwise the key is dispatched as usual.
fn route_plate_navigation(&mut self, event: &KeyEvent, rebuild: &mut bool) -> bool {
- if event.state != ElementState::Pressed
- || event.logical_key != Key::Named(NamedKey::Tab)
- || self.ctrl_pressed
- || self.alt_pressed
- || self.logo_pressed
- {
+ if event.state != ElementState::Pressed {
+ return false;
+ }
+ // The group jump first (its chords carry ctrl); then a bare Tab.
+ let group_next = crate::widget::match_key_shortcut(event, &self.group_next_chord);
+ let group_prev = !group_next && crate::widget::match_key_shortcut(event, &self.group_prev_chord);
+ let bare_tab = event.logical_key == Key::Named(NamedKey::Tab)
+ && !self.ctrl_pressed
+ && !self.alt_pressed
+ && !self.logo_pressed;
+ if !group_next && !group_prev && !bare_tab {
return false;
}
- let reverse = self.shift_pressed;
+ let reverse = if bare_tab { self.shift_pressed } else { group_prev };
let app = self.inner.as_mut().unwrap();
if !app.plate_navigation() {
return false;
}
- let moved = app.ui_context_mut().is_some_and(|ctx| ctx.focus_step(reverse));
+ let moved = app.ui_context_mut().is_some_and(|ctx| if bare_tab { ctx.focus_step(reverse) } else { ctx.focus_step_group(reverse) });
if moved {
app.focus_stepped();
*rebuild = true;
@@ -5469,6 +5478,8 @@ fn run_session<'l, A: Application>(
ctrl_pressed: false,
undo_chord: crate::input::app_chord("undo", "ctrl+z"),
redo_chord: crate::input::app_chord("redo", "ctrl+shift+z"),
+ group_next_chord: crate::input::app_chord("focus_next_group", "ctrl+tab"),
+ group_prev_chord: crate::input::app_chord("focus_prev_group", "ctrl+shift+tab"),
shift_pressed: false,
alt_pressed: false,
logo_pressed: false,
diff --git a/src/context.rs b/src/context.rs
index c913f64..1fb271d 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -567,16 +567,11 @@ impl UiContext {
self.focused_widget.is_some()
}
- /// Keyboard navigation in plate terms (see "Plates, wells and seams" in
- /// `CLAUDE.md`): move focus to the next (`reverse` = previous) plate or
- /// well in reading order. The stops are the registered, visible widgets
- /// with a `focus_role` and a non-empty rect, ordered by row (y) then x;
- /// the traversal wraps, and with nothing focused the first (or last) stop
- /// takes it. Focusing goes through `set_focused_id`, so the new stop gets
- /// its `FocusIn` — a well opens for typing, a plate arms Enter / Space.
- /// Returns whether focus moved. The runner calls this for Tab when the app
- /// opts in (`Application::plate_navigation`).
- pub fn focus_step(&mut self, reverse: bool) -> bool {
+ /// The keyboard stops in reading order (row, then x): the registered,
+ /// visible, on-screen widgets with a `focus_role`. Rows are bucketed by
+ /// vertical overlap, so a short control centred beside a taller one is on
+ /// its row. `CCE_FOCUS_DEBUG=1` prints them.
+ fn focus_stops(&self) -> Vec<WidgetId> {
// (y, bottom, x, id) per stop.
let mut found: Vec<(f32, f32, f32, WidgetId)> = Vec::new();
for (id, ptr) in self.tree.iter_registered() {
@@ -602,7 +597,7 @@ impl UiContext {
if std::env::var_os("CCE_FOCUS_DEBUG").is_some() {
eprintln!("[focus] no stops: no registered, visible widget with a focus role and a rect");
}
- return false;
+ return Vec::new();
}
// Reading order: rows first, x within a row. A stop joins the current
// row when its top lies above the row's first stop's bottom — a 12px
@@ -621,21 +616,89 @@ impl UiContext {
row.sort_by(|a, b| a.2.partial_cmp(&b.2).unwrap_or(std::cmp::Ordering::Equal));
stops.extend(row.into_iter().map(|s| s.3));
}
- // CCE_FOCUS_DEBUG=1: the stops in walk order, with what each is.
+ stops
+ }
+
+ /// The stops in WALK order, clustered: a registered `Group`'s members are
+ /// one contiguous run, placed where the group's first member falls in
+ /// reading order and ordered among themselves by reading order; every
+ /// other stop is a run of one. A member of two groups belongs to the
+ /// group that comes first. Tab walks the runs end to end
+ /// (`focus_step`); the group chords jump between them (`focus_step_group`).
+ pub fn focus_clusters(&self) -> Vec<Vec<WidgetId>> {
+ let stops = self.focus_stops();
+ if stops.is_empty() {
+ return Vec::new();
+ }
+ // Each group's member positions among the stops, groups ordered by
+ // their first member.
+ let mut groups: Vec<Vec<usize>> = Vec::new();
+ for (_, ptr) in self.tree.iter_registered() {
+ if ptr.is_null() {
+ continue;
+ }
+ let w = unsafe { &*ptr };
+ let Some(g) = w.as_any().downcast_ref::<crate::widget::Group>() else { continue };
+ let mut pos: Vec<usize> = g.members().iter().filter_map(|m| stops.iter().position(|s| s == m)).collect();
+ pos.sort_unstable();
+ pos.dedup();
+ if !pos.is_empty() {
+ groups.push(pos);
+ }
+ }
+ groups.sort_by_key(|p| p[0]);
+ let mut claimed = vec![false; stops.len()];
+ let mut clusters: Vec<(usize, Vec<WidgetId>)> = Vec::new();
+ for pos in groups {
+ let free: Vec<usize> = pos.into_iter().filter(|&i| !claimed[i]).collect();
+ if free.is_empty() {
+ continue;
+ }
+ for &i in &free {
+ claimed[i] = true;
+ }
+ clusters.push((free[0], free.iter().map(|&i| stops[i]).collect()));
+ }
+ for (i, id) in stops.iter().enumerate() {
+ if !claimed[i] {
+ clusters.push((i, vec![*id]));
+ }
+ }
+ clusters.sort_by_key(|c| c.0);
+ let clusters: Vec<Vec<WidgetId>> = clusters.into_iter().map(|c| c.1).collect();
+ // CCE_FOCUS_DEBUG=1: the runs in walk order, with what each stop is.
if std::env::var_os("CCE_FOCUS_DEBUG").is_some() {
- for (i, id) in stops.iter().enumerate() {
- if let Some(ptr) = self.tree.get_ptr(*id) {
- let w = unsafe { &*ptr };
- let (x, y, width, height) = w.rect();
- eprintln!(
- "[focus] stop {i}: {} {:?} at ({x:.0},{y:.0} {width:.0}x{height:.0}){}",
- w.type_name(),
- w.focus_role(),
- if self.focused_widget == Some(*id) { " <- focused" } else { "" }
- );
+ for (ci, run) in clusters.iter().enumerate() {
+ for (i, id) in run.iter().enumerate() {
+ if let Some(ptr) = self.tree.get_ptr(*id) {
+ let w = unsafe { &*ptr };
+ let (x, y, width, height) = w.rect();
+ eprintln!(
+ "[focus] run {ci} stop {i}: {} {:?} at ({x:.0},{y:.0} {width:.0}x{height:.0}){}",
+ w.type_name(),
+ w.focus_role(),
+ if self.focused_widget == Some(*id) { " <- focused" } else { "" }
+ );
+ }
}
}
}
+ clusters
+ }
+
+ /// Keyboard navigation in plate terms (see "Plates, wells and seams" in
+ /// `CLAUDE.md`): move focus to the next (`reverse` = previous) plate or
+ /// well in walk order — reading order, a group's members walked together
+ /// (`focus_clusters`). The traversal wraps, and with nothing focused the
+ /// first (or last) stop takes it. Focusing goes through `set_focused_id`,
+ /// so the new stop gets its `FocusIn` — a well opens for typing, a plate
+ /// arms Enter / Space. Returns whether focus moved. The runner calls this
+ /// for Tab when the app opts in (`Application::plate_navigation`).
+ pub fn focus_step(&mut self, reverse: bool) -> bool {
+ let stops: Vec<WidgetId> = self.focus_clusters().into_iter().flatten().collect();
+ if stops.is_empty() {
+ return false;
+ }
let n = stops.len();
let current = self.focused_widget.and_then(|f| stops.iter().position(|s| *s == f));
let next = match (current, reverse) {
@@ -652,6 +715,32 @@ impl UiContext {
true
}
+ /// Jump to the next (`reverse` = previous) run of `focus_clusters` — the
+ /// next group, or the next ungrouped stop — landing on its first stop;
+ /// wraps. The runner calls this for the `focus_next_group` /
+ /// `focus_prev_group` chords (input.kdl, cce-ui domain; defaults
+ /// `ctrl+tab` / `ctrl+shift+tab`) when the app opts in.
+ pub fn focus_step_group(&mut self, reverse: bool) -> bool {
+ let clusters = self.focus_clusters();
+ if clusters.is_empty() {
+ return false;
+ }
+ let n = clusters.len();
+ let current = self.focused_widget.and_then(|f| clusters.iter().position(|c| c.contains(&f)));
+ let next = match (current, reverse) {
+ (Some(i), false) => (i + 1) % n,
+ (Some(i), true) => (i + n - 1) % n,
+ (None, false) => 0,
+ (None, true) => n - 1,
+ };
+ let id = clusters[next][0];
+ if self.focused_widget == Some(id) {
+ return false;
+ }
+ self.set_focused_id(id);
+ true
+ }
+
// `navigate_focus` (tree-walk ctrl-nav) is DELETED (the plumbing retype): it had
// zero callers — its `focus::navigate_focus` twin was the one wired up, and that one
// walked an empty dummy context (provably inert). Section-level keyboard nav lives
@@ -1268,6 +1357,25 @@ mod focus_step_tests {
WidgetHost::set_rect(&mut sep, 300.0, 10.0, 10.0, 1.0);
assert_eq!(WidgetHost::focus_role(&sep), crate::widget::FocusRole::None);
+ // A group's members walk together, where the group's first member falls:
+ // grouping a and t (skipping b, which sits between them in reading order)
+ // makes the walk a, t, b — and the group chord jumps a -> b -> a.
+ let mut g = crate::widget::Group::new(vec![ia, it]);
+ let (gid, gptr) = (g.base().id(), &mut g as *mut dyn WidgetHost);
+ let gptr = unsafe { std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(gptr) };
+ ctx.register_widget(gid, gptr);
+ assert_eq!(ctx.focus_clusters(), vec![vec![ia, it], vec![ib]]);
+ ctx.set_focused_id(ia);
+ assert!(ctx.focus_step(false));
+ assert!(ctx.is_focused_id(it), "the group's second member before the ungrouped stop");
+ assert!(ctx.focus_step(false));
+ assert!(ctx.is_focused_id(ib));
+ assert!(ctx.focus_step_group(false));
+ assert!(ctx.is_focused_id(ia), "the group chord wraps to the group's first stop");
+ assert!(ctx.focus_step_group(false));
+ assert!(ctx.is_focused_id(ib), "then to the next run");
+ ctx.unregister_widget(gid);
+
// A plate parked off-screen (the hidden-editor idiom) is not a stop either.
let mut parked = Button::new(0.0, 0.0, 1.0, 1.0).with_label("parked");
WidgetHost::set_rect(&mut parked, -1000.0, -1000.0, 1.0, 1.0);