git.lucas.co / cce-ui
GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git

commita23b28b542e96e0a0a1465908e4d17cfd110f95d
parenta640310c42
authorLucas Galante <[email protected]>
date2026-09-09 08:13
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);