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

commit99bae2cdfe59d109ccc0ab48f98a9c1985961b3e
parent18283c3970
authorLucas Galante <[email protected]>
date2026-09-08 21:55
feat(focus): focus_stepped tells a caching app to rebuild; CCE_FOCUS_DEBUG

An app that caches its geometry until its own rebuild flag (relief carves
collected in a view pass, widget lists built on layout) never drew the ring
a Tab step moved: the runner only redrew. `Application::focus_stepped`
(default nothing) fires after a step so such an app raises its flag.
`CCE_FOCUS_DEBUG=1` prints the walk's stops with their roles and rects.

Co-Authored-By: Claude Fable 5.1 <[email protected]>

 CLAUDE.md                    |  7 ++++++-
 src/backend/window_runner.rs | 18 ++++++++++++------
 src/context.rs               | 18 ++++++++++++++++++
 3 files changed, 36 insertions(+), 7 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 774d060..b77745b 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -141,7 +141,12 @@ What this buys, and where the code is heading:
   (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
   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).
+  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
+  its geometry until its own rebuild flag raises it there. The walk needs the
+  app's context exposed (`ui_context_mut`); the ring reaches flat-path hosts
+  through `RenderTarget::inset_plate_tinted` and `CarveKind::Boss { tint }`.
+  `CCE_FOCUS_DEBUG=1` prints the stops in walk order.
   The focus ring is the plate's own silhouette: `ControlPlate::with_tint`
   lights the rim (a tinted `Trough`, `Boss` or `Bevel`), the same treatment a
   well's `recess_tinted` gives its rim while editing — never extra geometry.
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 335d893..146336f 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3302,6 +3302,13 @@ pub trait Application: Sized + 'static {
     fn plate_navigation(&self) -> bool {
         false
     }
+
+    /// Keyboard focus just moved by the toolkit's Tab traversal. An app that
+    /// caches its geometry until its own rebuild flag (relief carves collected
+    /// in a view pass, widget lists built on layout) raises that flag here, so
+    /// the new ring is drawn; an app that paints fresh every frame needs
+    /// nothing. Default: nothing.
+    fn focus_stepped(&mut self) {}
     /// Keyboard focus entered/left the window (the compositor keyboard-focuses
     /// the focused window, so this is the "am I the focused window" signal —
     /// e.g. for focus-dependent chrome). Default: ignore.
@@ -4855,13 +4862,12 @@ impl<A: Application> EngineState<A> {
         if !app.plate_navigation() {
             return false;
         }
-        if let Some(ctx) = app.ui_context_mut() {
-            if ctx.focus_step(reverse) {
-                *rebuild = true;
-                return true;
-            }
+        let moved = app.ui_context_mut().is_some_and(|ctx| ctx.focus_step(reverse));
+        if moved {
+            app.focus_stepped();
+            *rebuild = true;
         }
-        false
+        moved
     }
 
     fn route_history_chord(&mut self, event: &KeyEvent, rebuild: &mut bool) -> bool {
diff --git a/src/context.rs b/src/context.rs
index 57ffefe..b48e4a2 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -594,6 +594,9 @@ impl UiContext {
             found.push((y, y + height, x, id));
         }
         if found.is_empty() {
+            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;
         }
         // Reading order: rows first, x within a row. A stop joins the current
@@ -613,6 +616,21 @@ 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.
+        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 { "" }
+                    );
+                }
+            }
+        }
         let n = stops.len();
         let current = self.focused_widget.and_then(|f| stops.iter().position(|s| *s == f));
         let next = match (current, reverse) {