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

commitb176a6a684843058843d37ca54dc403b3a84ef97
parent79cead898e
authorLucas Galante <[email protected]>
date2026-09-08 20:50
feat: keyboard navigation in plate terms

Focus moves between plates and wells, a press acts on a plate, a well opens
for typing, and the focus ring is the plate's own silhouette.

- `Input::focus_role`: a widget is a Plate (a thing you press), a Well (a
  thing you enter) or neither. Button, Checkbox, Toggle, Dropdown and
  FontSelector are plates; TextBox, Spinbox, ColorSelector, KeybindRecorder
  and TreeList are wells.
- `UiContext::focus_step`: the traversal — the registered, visible stops in
  reading order (row, then x), wrapping, focused through `set_focused_id` so
  the new stop gets its FocusIn (a well opens, a plate arms Enter / Space).
- The runner routes a bare Tab / Shift+Tab to it when the app opts in with
  `Application::plate_navigation` (default off: a terminal, a web view, an
  app with its own field order keeps its Tab).
- The ring: `ControlPlate::with_tint` lights the plate's rim — a tinted
  Trough (new: `Prim::Trough::tint`, `PaintCtx::trough_tinted`, reaching the
  shader like a Recess's), Boss or Bevel — the treatment a well already gives
  its rim while editing. Button, Dropdown and FontSelector wear it; the
  Checkbox lights the ring its mark already draws. No new geometry anywhere.
- Enter / Space press a focused Checkbox, Toggle or FontSelector (which
  gained an `open_picker` shared with the click), and arm a focused
  KeybindRecorder.

Unfocused paint is prim-identical to before (the face dump byte-equal).

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

 CLAUDE.md                            | 16 +++++--
 src/backend/window_runner.rs         | 50 ++++++++++++++++++--
 src/context.rs                       | 88 ++++++++++++++++++++++++++++++++++++
 src/scene/paint.rs                   | 59 +++++++++++++++++++++---
 src/widget/container/treelist.rs     |  3 ++
 src/widget/input/button.rs           | 37 ++++++++++++++-
 src/widget/input/checkbox.rs         | 51 ++++++++++++++++++++-
 src/widget/input/color_selector.rs   |  3 ++
 src/widget/input/dropdown.rs         | 11 ++++-
 src/widget/input/font_selector.rs    | 78 ++++++++++++++++++++++++--------
 src/widget/input/keybind_recorder.rs | 26 ++++++++++-
 src/widget/input/spinbox.rs          |  3 ++
 src/widget/input/text_box.rs         |  3 ++
 src/widget/mod.rs                    |  7 +++
 src/widget/model.rs                  | 24 ++++++++++
 15 files changed, 420 insertions(+), 39 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 71a16af..2de2f1a 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -135,9 +135,19 @@ widget does not fit one of them, say so rather than stretching a word.
 
 What this buys, and where the code is heading:
 
-- **Navigation is stated in plate terms.** Focus moves between plates, a press
-  acts on a plate, a well opens for typing. Hit testing and focus rings are the
-  plate's silhouette.
+- **Navigation is stated in plate terms.** `Input::focus_role` says what a
+  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
+  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).
+  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.
+  Roles today: Button, Checkbox, Toggle, Dropdown, FontSelector are plates;
+  TextBox, Spinbox, ColorSelector, KeybindRecorder, TreeList are wells. A new
+  focusable widget declares its role and handles `FocusIn` / `FocusOut`.
 - **One plate spec per rung, not five copies.** The root and pane rungs are
   `scene::paint::PlateSpec` (RFC 7b, painted by `PaintCtx::plate`). The
   control rung is `scene::paint::ControlPlate` (re-exported from `widget`):
diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index e4df153..335d893 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -1946,12 +1946,13 @@ pub fn tessellate_display_list(
             Prim::Recess { rect, radii, depth, edges, .. }
             | Prim::Boss { rect, radii, depth, edges, .. }
             | Prim::Ridge { rect, radii, depth, edges }
-            | Prim::Trough { rect, radii, depth, edges }
+            | Prim::Trough { rect, radii, depth, edges, .. }
                 if shader_plates =>
             {
                 let tint = match &item.prim {
                     Prim::Recess { tint, .. } => *tint,
                     Prim::Boss { tint, .. } => *tint,
+                    Prim::Trough { tint, .. } => *tint,
                     _ => None,
                 };
                 // Recess carves down into the surface; Boss raises a plateau out
@@ -2270,7 +2271,7 @@ pub fn tessellate_display_list(
                     EdgeKind::Step, &mut verts,
                 );
             }
-            Prim::Trough { rect, radii, depth, edges } => {
+            Prim::Trough { rect, radii, depth, edges, .. } => {
                 // Legacy approximation, the Ridge arm's two steps with the light
                 // signs swapped: down at the boundary, back up half a width in.
                 // The banded machinery has no valley profile, so this is the old
@@ -3291,6 +3292,16 @@ pub trait Application: Sized + 'static {
     fn redo(&mut self, _needs_rebuild: &mut bool) -> bool {
         false
     }
+
+    /// Opt into the toolkit's keyboard navigation in plate terms: Tab and
+    /// Shift+Tab move focus to the next / previous plate or well in reading
+    /// order (`UiContext::focus_step`), a press (Enter / Space) acts on the
+    /// focused plate, a well opens for typing when focused. Default false: an
+    /// app that routes Tab itself (a terminal, a web view, its own field
+    /// order) is undisturbed. See "Plates, wells and seams" in `CLAUDE.md`.
+    fn plate_navigation(&self) -> bool {
+        false
+    }
     /// 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.
@@ -4826,6 +4837,33 @@ impl<A: Application> EngineState<A> {
     /// it; otherwise the key is dispatched as usual, so an app with its own
     /// scheme is undisturbed. Runs for repeats too — holding the chord walks
     /// the history like holding Backspace walks the text.
+    /// The toolkit's Tab traversal, for apps that opt in
+    /// (`Application::plate_navigation`): a bare Tab / Shift+Tab press moves
+    /// 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
+        {
+            return false;
+        }
+        let reverse = self.shift_pressed;
+        let app = self.inner.as_mut().unwrap();
+        if !app.plate_navigation() {
+            return false;
+        }
+        if let Some(ctx) = app.ui_context_mut() {
+            if ctx.focus_step(reverse) {
+                *rebuild = true;
+                return true;
+            }
+        }
+        false
+    }
+
     fn route_history_chord(&mut self, event: &KeyEvent, rebuild: &mut bool) -> bool {
         if event.state != ElementState::Pressed {
             return false;
@@ -4945,7 +4983,9 @@ impl<A: Application> EngineState<A> {
         }
 
         let mut rebuild = false;
-        if self.route_history_chord(&custom_event, &mut rebuild) {
+        if self.route_history_chord(&custom_event, &mut rebuild)
+            || self.route_plate_navigation(&custom_event, &mut rebuild)
+        {
             self.redraw = true;
             return;
         }
@@ -5761,7 +5801,9 @@ fn run_session<'l, A: Application>(
                     }
 
                     let mut key_rebuild = false;
-                    if engine_state.route_history_chord(&custom_event, &mut key_rebuild) {
+                    if engine_state.route_history_chord(&custom_event, &mut key_rebuild)
+                        || engine_state.route_plate_navigation(&custom_event, &mut key_rebuild)
+                    {
                         engine_state.redraw = true;
                     } else if let Some(msg) = engine_state.inner.as_mut().unwrap().handle_key_input(&custom_event, &mut key_rebuild) {
                         let mut update_rebuild = false;
diff --git a/src/context.rs b/src/context.rs
index b8b83ae..9ab9560 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -567,6 +567,51 @@ 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 {
+        let mut stops: Vec<(i32, i32, WidgetId)> = Vec::new();
+        for (id, ptr) in self.tree.iter_registered() {
+            if ptr.is_null() {
+                continue;
+            }
+            let w = unsafe { &*ptr };
+            if w.focus_role() == crate::widget::FocusRole::None || !w.visible() {
+                continue;
+            }
+            let (x, y, width, height) = w.rect();
+            if width <= 0.0 || height <= 0.0 {
+                continue;
+            }
+            stops.push((y.round() as i32, x.round() as i32, id));
+        }
+        if stops.is_empty() {
+            return false;
+        }
+        stops.sort_by(|a, b| (a.0, a.1).cmp(&(b.0, b.1)));
+        let n = stops.len();
+        let current = self.focused_widget.and_then(|f| stops.iter().position(|s| s.2 == 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 = stops[next].2;
+        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
@@ -1140,3 +1185,46 @@ mod tests {
         assert!(!ctx.drag_allowed_at(20.0, 20.0), "a drag-blocking widget denies the drag");
     }
 }
+
+#[cfg(test)]
+mod focus_step_tests {
+    use super::*;
+    use crate::widget::{Button, TextBox, WidgetHost};
+
+    /// Tab walks plates and wells in reading order (row, then x), wraps, and
+    /// Shift+Tab walks back; a focused well opened for typing on the way.
+    #[test]
+    fn focus_step_walks_plates_and_wells_in_reading_order() {
+        let mut ctx = UiContext::new();
+        let mut a = Button::new(0.0, 0.0, 80.0, 24.0).with_label("A");
+        let mut b = Button::new(0.0, 0.0, 80.0, 24.0).with_label("B");
+        let mut t = TextBox::new("well".to_string());
+        // Placed out of registration order: b is right of a on the first row, t below.
+        WidgetHost::set_rect(&mut b, 100.0, 10.0, 80.0, 24.0);
+        WidgetHost::set_rect(&mut a, 10.0, 10.0, 80.0, 24.0);
+        WidgetHost::set_rect(&mut t, 10.0, 50.0, 200.0, 24.0);
+        for w in [&mut b as &mut dyn WidgetHost, &mut a, &mut t] {
+            let (id, ptr) = (w.base().id(), w as *mut dyn WidgetHost);
+            let ptr = unsafe { std::mem::transmute::<*mut dyn WidgetHost, *mut (dyn WidgetHost + 'static)>(ptr) };
+            ctx.register_widget(id, ptr);
+        }
+        let (ia, ib, it) = (a.id(), b.id(), t.id());
+
+        assert!(ctx.focus_step(false));
+        assert!(ctx.is_focused_id(ia), "first stop: the top-left plate");
+        assert!(ctx.focus_step(false));
+        assert!(ctx.is_focused_id(ib), "then the plate to its right");
+        assert!(ctx.focus_step(false));
+        assert!(ctx.is_focused_id(it), "then the well on the next row");
+        assert!(t.editing, "a well opens for typing when focused");
+        assert!(ctx.focus_step(false));
+        assert!(ctx.is_focused_id(ia), "wraps to the first stop");
+        assert!(ctx.focus_step(true));
+        assert!(ctx.is_focused_id(it), "Shift+Tab wraps back to the last");
+
+        // A widget with no role is not a stop.
+        let mut sep = crate::widget::Separator::new(0.0, 0.0, 10.0, 1.0, [1.0; 4]);
+        WidgetHost::set_rect(&mut sep, 300.0, 10.0, 10.0, 1.0);
+        assert_eq!(WidgetHost::focus_role(&sep), crate::widget::FocusRole::None);
+    }
+}
diff --git a/src/scene/paint.rs b/src/scene/paint.rs
index b408a48..b62299d 100644
--- a/src/scene/paint.rs
+++ b/src/scene/paint.rs
@@ -173,6 +173,9 @@ pub struct ControlPlate {
     pub stance: PlateStance,
     pub face: [f32; 4],
     pub depth: f32,
+    /// The rim lit in this colour: the keyboard-focus ring, drawn on the
+    /// plate's own silhouette rather than as extra geometry. `None` unlit.
+    pub tint: Option<[f32; 3]>,
 }
 
 impl ControlPlate {
@@ -180,7 +183,21 @@ impl ControlPlate {
     /// the DE relief width, capped at a fifth of the plate's height.
     pub fn control(rect: Rect, radius: f32, stance: PlateStance, face: [f32; 4]) -> Self {
         let depth = crate::layout::bevel_width().min(rect.height * 0.2);
-        Self { rect, radii: (radius, radius, radius, radius), stance, face, depth }
+        Self { rect, radii: (radius, radius, radius, radius), stance, face, depth, tint: None }
+    }
+
+    /// Light the rim — the focus ring on the plate's silhouette. Pass the
+    /// highlight colour while the control holds keyboard focus, `None` otherwise.
+    pub fn with_tint(mut self, tint: Option<[f32; 3]>) -> Self {
+        self.tint = tint;
+        self
+    }
+
+    /// The DE's focus-ring colour for a plate rim: the highlight accent, the
+    /// same the wells light their rims with while editing.
+    pub fn focus_tint() -> [f32; 3] {
+        let c = crate::color::highlight_primary_color();
+        [c[0], c[1], c[2]]
     }
 
     /// Per-corner silhouette (a concentric corner-frame adjustment).
@@ -475,7 +492,10 @@ pub enum Prim {
     /// `edges` and the host-box fade behave exactly as [`Prim::Recess`]'s.
     /// SDF path only; the legacy banded tessellation approximates it with the
     /// old two-step stack (like `Ridge`, which approximates itself there).
-    Trough { rect: Rect, radii: Radii, depth: f32, edges: (bool, bool, bool, bool) },
+    ///
+    /// `tint` lights the rim like [`Prim::Recess`]'s — the focus treatment of a
+    /// flush control plate (`PaintCtx::control_plate`).
+    Trough { rect: Rect, radii: Radii, depth: f32, edges: (bool, bool, bool, bool), tint: Option<[f32; 3]> },
     /// The window's glass slab: a rounded fill plus a rolled, lit edge around its whole
     /// perimeter, drawn at full size. Distinct from `Bevel`, which insets its fill by
     /// `depth` — a plate must fill the window exactly, or the compositor's rounded window
@@ -914,7 +934,10 @@ impl PaintCtx {
                 None => self.boss_edges(rect, radii, depth, edges),
             },
             Prim::Ridge { rect, radii, depth, edges } => self.ridge_edges(rect, radii, depth, edges),
-            Prim::Trough { rect, radii, depth, edges } => self.trough_edges(rect, radii, depth, edges),
+            Prim::Trough { rect, radii, depth, edges, tint } => match tint {
+                Some(t) => self.trough_tinted(rect, radii, depth, t),
+                None => self.trough_edges(rect, radii, depth, edges),
+            },
             Prim::Plate { rect, radii, color, depth, shape } => {
                 self.plate_shaped(rect, radii, color, depth, shape)
             }
@@ -1028,15 +1051,30 @@ impl PaintCtx {
             PlateStance::Raised => {
                 // abs(): a negative alpha is the frost sentinel, a real face.
                 if plate.face[3].abs() > 0.001 {
-                    self.bevel(plate.rect, plate.radii, plate.face, plate.depth);
+                    match plate.tint {
+                        Some(t) => self.bevel_tinted(plate.rect, plate.radii, plate.face, plate.depth, t),
+                        None => self.bevel(plate.rect, plate.radii, plate.face, plate.depth),
+                    }
                 } else {
                     let (plateau, radii) = crate::layout::carve_inside(plate.rect, plate.radii, plate.depth);
-                    self.boss(plateau, radii, plate.depth);
+                    match plate.tint {
+                        Some(t) => self.boss_edges_tinted(plateau, radii, plate.depth, (true, true, true, true), t),
+                        None => self.boss(plateau, radii, plate.depth),
+                    }
                 }
             }
             PlateStance::Flush => {
                 let (trough, radii) = crate::layout::carve_inside(plate.rect, plate.radii, plate.depth);
-                self.inset_plate(trough, radii, plate.face, plate.depth);
+                match plate.tint {
+                    Some(t) => {
+                        // `inset_plate`'s face fill, then the trough with its rim lit.
+                        if plate.face[3].abs() > 0.001 {
+                            self.border(trough, radii, plate.face, [0.0; 4], 0.0);
+                        }
+                        self.trough_tinted(trough, radii, plate.depth, t);
+                    }
+                    None => self.inset_plate(trough, radii, plate.face, plate.depth),
+                }
             }
         }
     }
@@ -1116,7 +1154,14 @@ impl PaintCtx {
         edges: (bool, bool, bool, bool),
     ) {
         let rect = self.apply_offset(rect);
-        self.push(Prim::Trough { rect, radii, depth, edges });
+        self.push(Prim::Trough { rect, radii, depth, edges, tint: None });
+    }
+
+    /// [`PaintCtx::trough`] with the rim lit — see `Prim::Trough::tint` (the
+    /// focused flush control plate).
+    pub fn trough_tinted(&mut self, rect: Rect, radii: Radii, depth: f32, tint: [f32; 3]) {
+        let rect = self.apply_offset(rect);
+        self.push(Prim::Trough { rect, radii, depth, edges: (true, true, true, true), tint: Some(tint) });
     }
 
     /// Raise a rim along `rect`'s boundary — see `Prim::Ridge`. `depth` is the
diff --git a/src/widget/container/treelist.rs b/src/widget/container/treelist.rs
index 94a1014..0ce43a0 100644
--- a/src/widget/container/treelist.rs
+++ b/src/widget/container/treelist.rs
@@ -1079,6 +1079,9 @@ impl Paint for TreeList {
 }
 
 impl Input for TreeList {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Well
+    }
     fn blocks_root_plate_drag(&self) -> bool {
         true
     }
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 480123b..ccee436 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -197,7 +197,12 @@ impl Button {
             return None;
         }
         let radius = crate::layout::button_corner_radius();
-        Some(crate::widget::ControlPlate::control(rect, radius, crate::widget::PlateStance::Flush, self.color()))
+        // Keyboard focus lights the plate's own rim — the ring IS the silhouette.
+        let tint = self.focused.then(crate::widget::ControlPlate::focus_tint);
+        Some(
+            crate::widget::ControlPlate::control(rect, radius, crate::widget::PlateStance::Flush, self.color())
+                .with_tint(tint),
+        )
     }
 
     /// [`Button::plate`] as the legacy `(rect, corner radius, depth, face
@@ -467,6 +472,9 @@ impl Paint for Button {
 }
 
 impl Input for Button {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Plate
+    }
     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
         match event {
             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
@@ -622,3 +630,30 @@ mod tests {
         assert!(b.selected);
     }
 }
+
+#[cfg(test)]
+mod focus_ring_tests {
+    use super::*;
+    use crate::scene::paint::{PaintCtx, Prim};
+    use crate::widget::{Event, WidgetHost};
+
+    /// The focus ring is the plate's own rim lit: focused, the trough carries
+    /// the highlight tint; unfocused, the same trough untinted — no extra geometry.
+    #[test]
+    fn focus_lights_the_plate_rim() {
+        let mut ctx = crate::widget::UiContext::new();
+        let mut b = Button::new(0.0, 0.0, 120.0, 26.0).with_label("Plate").with_raised(true);
+        WidgetHost::set_rect(&mut b, 10.0, 20.0, 120.0, 26.0);
+        let rect = Rect { x: 10.0, y: 20.0, width: 120.0, height: 26.0 };
+        let troughs = |b: &Adapted<Button>| -> Vec<Option<[f32; 3]>> {
+            let mut pc = PaintCtx::new();
+            Paint::paint(b.inner(), rect, &mut pc);
+            pc.finish().items.into_iter().filter_map(|i| match i.prim { Prim::Trough { tint, .. } => Some(tint), _ => None }).collect()
+        };
+        assert_eq!(troughs(&b), vec![None], "unfocused: one untinted trough");
+        b.handle_event(&Event::FocusIn, &mut ctx);
+        assert_eq!(troughs(&b), vec![Some(crate::widget::ControlPlate::focus_tint())], "focused: the rim lit");
+        b.handle_event(&Event::FocusOut, &mut ctx);
+        assert_eq!(troughs(&b), vec![None]);
+    }
+}
diff --git a/src/widget/input/checkbox.rs b/src/widget/input/checkbox.rs
index 9c75778..1f3101c 100644
--- a/src/widget/input/checkbox.rs
+++ b/src/widget/input/checkbox.rs
@@ -64,11 +64,19 @@ impl Checkbox {
     /// toggle-on colour when checked. Shared with hosts that draw their own rows
     /// (cce-list) so a list's marks and a `Checkbox` agree pixel for pixel.
     pub fn paint_round_mark(ctx: &mut PaintCtx, cx: f32, cy: f32, radius: f32, checked: bool) {
+        Self::paint_round_mark_ringed(ctx, cx, cy, radius, checked, colors::TEXT_DIM);
+    }
+
+    /// [`Checkbox::paint_round_mark`] with the ring in `ring` — the mark's
+    /// silhouette lit in the highlight colour is its keyboard-focus ring (a
+    /// mark is not a plate, so it has no rim to tint; the ring it already
+    /// draws is the silhouette).
+    pub fn paint_round_mark_ringed(ctx: &mut PaintCtx, cx: f32, cy: f32, radius: f32, checked: bool, ring: [f32; 4]) {
         ctx.border(
             Rect { x: cx - radius, y: cy - radius, width: 2.0 * radius, height: 2.0 * radius },
             (radius, radius, radius, radius),
             [0.0, 0.0, 0.0, 0.0],
-            colors::TEXT_DIM,
+            ring,
             1.5,
         );
         if checked {
@@ -109,7 +117,9 @@ impl Paint for Checkbox {
         // Standalone, the mark fills the rect.
         let r = if self.label.is_some() { Self::ROUND_RADIUS } else { (w.min(h) / 2.0).max(1.0) };
         let (cx, cy) = if self.label.is_some() { (x + r, y + h / 2.0) } else { (x + w / 2.0, y + h / 2.0) };
-        Self::paint_round_mark(ctx, cx, cy, r, self.checked);
+        // Focused: the mark's own ring lit in the highlight colour.
+        let ring = if self.focused { crate::color::highlight_primary_color() } else { colors::TEXT_DIM };
+        Self::paint_round_mark_ringed(ctx, cx, cy, r, self.checked, ring);
         if let Some(ref label) = self.label {
             let (_, font_size) = crate::layout::control_label_font_parsed();
             let ty = crate::layout::align_text_y(y, h, font_size, 0.0);
@@ -125,6 +135,9 @@ impl Paint for Checkbox {
 }
 
 impl Input for Checkbox {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Plate
+    }
     fn on_event(&mut self, event: &Event, _ectx: &mut EventCtx) -> bool {
         match event {
             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
@@ -150,6 +163,22 @@ impl Input for Checkbox {
                 self.focused = false;
                 false
             }
+            Event::KeyInput(key_event) => {
+                // A focused plate is pressed by Enter / Space, as a Button is.
+                if !self.focused || key_event.state != ElementState::Pressed {
+                    return false;
+                }
+                match key_event.logical_key {
+                    crate::widget::Key::Named(crate::widget::NamedKey::Enter)
+                    | crate::widget::Key::Named(crate::widget::NamedKey::Space) => {
+                        self.checked = !self.checked;
+                        self.just_clicked = true;
+                        self.just_changed = true;
+                        true
+                    }
+                    _ => false,
+                }
+            }
             _ => false,
         }
     }
@@ -524,6 +553,9 @@ impl Paint for Toggle {
 }
 
 impl Input for Toggle {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Plate
+    }
     fn on_event(&mut self, event: &Event, _ectx: &mut EventCtx) -> bool {
         match event {
             Event::MouseButton { button: MouseButton::Left, state: ElementState::Pressed, .. } => {
@@ -547,6 +579,21 @@ impl Input for Toggle {
                 self.focused = false;
                 false
             }
+            Event::KeyInput(key_event) => {
+                // A focused plate is pressed by Enter / Space, as a Button is.
+                if !self.focused || key_event.state != ElementState::Pressed {
+                    return false;
+                }
+                match key_event.logical_key {
+                    crate::widget::Key::Named(crate::widget::NamedKey::Enter)
+                    | crate::widget::Key::Named(crate::widget::NamedKey::Space) => {
+                        self.toggled = !self.toggled;
+                        self.just_toggled = true;
+                        true
+                    }
+                    _ => false,
+                }
+            }
             _ => false,
         }
     }
diff --git a/src/widget/input/color_selector.rs b/src/widget/input/color_selector.rs
index 90e6f6f..5ce9e2b 100644
--- a/src/widget/input/color_selector.rs
+++ b/src/widget/input/color_selector.rs
@@ -481,6 +481,9 @@ impl Paint for ColorSelector {
 }
 
 impl Input for ColorSelector {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Well
+    }
     fn opens_context_menu(&self) -> bool {
         true
     }
diff --git a/src/widget/input/dropdown.rs b/src/widget/input/dropdown.rs
index 875b08f..601342a 100644
--- a/src/widget/input/dropdown.rs
+++ b/src/widget/input/dropdown.rs
@@ -107,6 +107,8 @@ pub struct Dropdown {
     /// Raised style: the closed control's background is an SDF-lit `Bevel`
     /// plate (fill + rolled lit edge) instead of a flat fill + border stroke.
     raised: bool,
+    /// Keyboard focus (FocusIn / FocusOut): lights the trigger plate's rim.
+    focused: bool,
     /// The open menu REPLACES the trigger instead of growing out of it: no
     /// trigger band (display text + ▼) in the open surface, the rows alone,
     /// with the menu's edge anchored where the trigger's was (its bottom for
@@ -153,6 +155,7 @@ impl Dropdown {
             hovered: false,
             corner_frame: None,
             raised: crate::layout::control_relief(),
+            focused: false,
             menu_replaces_trigger: false,
             anim_from: 0.0,
             anim_start: None,
@@ -462,7 +465,8 @@ impl Dropdown {
                 crate::widget::ControlPlate::face_from_fill(raw_bg),
             )
             .with_radii((r4[0], r4[1], r4[2], r4[3]))
-            .with_depth(depth);
+            .with_depth(depth)
+            .with_tint(self.focused.then(crate::widget::ControlPlate::focus_tint));
             ctx.control_plate(&plate);
             return;
         }
@@ -990,6 +994,9 @@ impl Paint for Dropdown {
 }
 
 impl Input for Dropdown {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Plate
+    }
     /// The legacy geometric test: the widget rect (edges inclusive), extended to the open
     /// popover. `rect` is the full base rect (label strip included), as legacy `hit_test` used.
     fn hit(&self, rect: Rect, x: f32, y: f32) -> bool {
@@ -1157,11 +1164,13 @@ impl Input for Dropdown {
             Event::FocusIn => {
                 // Legacy `focus()` claimed the global focus slot on every direct call
                 // (test-interface focuses the ramp's preset dropdown this way).
+                self.focused = true;
                 ectx.request_focus();
                 false
             }
             Event::FocusOut => {
                 // Legacy `unfocus` closed the dropdown.
+                self.focused = false;
                 self.begin_close();
                 false
             }
diff --git a/src/widget/input/font_selector.rs b/src/widget/input/font_selector.rs
index 3b229a9..02b726b 100644
--- a/src/widget/input/font_selector.rs
+++ b/src/widget/input/font_selector.rs
@@ -21,6 +21,9 @@ pub struct FontSelector {
     /// states a wash inside it. Defaults to `control_relief()`; the flat style
     /// keeps the framed dark field.
     raised: bool,
+    /// Keyboard focus (FocusIn / FocusOut): lights the plate's rim and arms
+    /// Enter / Space to open the picker.
+    focused: bool,
 }
 
 impl FontSelector {
@@ -32,6 +35,7 @@ impl FontSelector {
             hovered: false,
             child: Arc::new(Mutex::new(None)),
             raised: crate::layout::control_relief(),
+            focused: false,
         })
     }
 
@@ -157,7 +161,10 @@ impl Paint for FontSelector {
         if self.raised {
             // The closed-dropdown chrome: a flush control plate with a
             // transparent face, the state fill rounded to sit inside it.
-            ctx.control_plate(&crate::widget::ControlPlate::control(rect, r, crate::widget::PlateStance::Flush, [0.0; 4]));
+            ctx.control_plate(
+                &crate::widget::ControlPlate::control(rect, r, crate::widget::PlateStance::Flush, [0.0; 4])
+                    .with_tint(self.focused.then(crate::widget::ControlPlate::focus_tint)),
+            );
             let wash = if self.pressed {
                 Some(colors::button_press_color())
             } else if self.hovered {
@@ -202,7 +209,36 @@ impl FontSelector {
     }
 }
 
+impl FontSelector {
+    /// Spawn `cce-fonts --select` (once — a picker already open keeps it); the
+    /// tick reaps it. The press of this plate, by pointer or by key.
+    fn open_picker(&mut self) {
+        let mut child_guard = self.child.lock().unwrap();
+        if child_guard.is_none() {
+            let home = std::env::var("HOME").unwrap_or_default();
+            let local_fonts = std::path::Path::new(&home).join(".local/bin/cce-fonts");
+            let cmd_path = if local_fonts.exists() {
+                local_fonts.to_string_lossy().into_owned()
+            } else {
+                "cce-fonts".to_string()
+            };
+            if let Ok(child) = std::process::Command::new(&cmd_path)
+                .arg("--select")
+                .arg(&self.font_family)
+                .stdout(std::process::Stdio::piped())
+                .spawn()
+            {
+                *child_guard = Some(child);
+            }
+        }
+    }
+}
+
 impl Input for FontSelector {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Plate
+    }
+
     fn wants_tick(&self) -> bool {
         true
     }
@@ -236,6 +272,27 @@ impl Input for FontSelector {
 
     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
         match event {
+            Event::FocusIn => {
+                self.focused = true;
+                false
+            }
+            Event::FocusOut => {
+                self.focused = false;
+                false
+            }
+            Event::KeyInput(key_event) => {
+                // A focused plate is pressed by Enter / Space, as a Button is.
+                if !self.focused || key_event.state != ElementState::Pressed {
+                    return false;
+                }
+                match key_event.logical_key {
+                    Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
+                        self.open_picker();
+                        true
+                    }
+                    _ => false,
+                }
+            }
             Event::MouseButton { button, state, x, y, .. } => {
                 if *button != MouseButton::Left {
                     return false;
@@ -250,24 +307,7 @@ impl Input for FontSelector {
                         let inside = *x >= r.x && *x <= r.x + r.width && *y >= r.y && *y <= r.y + r.height;
                         if self.pressed && inside {
                             self.pressed = false;
-                            let mut child_guard = self.child.lock().unwrap();
-                            if child_guard.is_none() {
-                                let home = std::env::var("HOME").unwrap_or_default();
-                                let local_fonts = std::path::Path::new(&home).join(".local/bin/cce-fonts");
-                                let cmd_path = if local_fonts.exists() {
-                                    local_fonts.to_string_lossy().into_owned()
-                                } else {
-                                    "cce-fonts".to_string()
-                                };
-                                if let Ok(child) = std::process::Command::new(&cmd_path)
-                                    .arg("--select")
-                                    .arg(&self.font_family)
-                                    .stdout(std::process::Stdio::piped())
-                                    .spawn()
-                                {
-                                    *child_guard = Some(child);
-                                }
-                            }
+                            self.open_picker();
                             return true;
                         }
                         let was = self.pressed;
diff --git a/src/widget/input/keybind_recorder.rs b/src/widget/input/keybind_recorder.rs
index 622cf30..dba1af5 100644
--- a/src/widget/input/keybind_recorder.rs
+++ b/src/widget/input/keybind_recorder.rs
@@ -19,6 +19,8 @@ pub struct KeybindRecorder {
     /// accent while recording (the TextBox's editing treatment). Defaults to
     /// `control_relief()`; the flat style keeps the framed dark field.
     recessed: bool,
+    /// Keyboard focus (FocusIn / FocusOut): Enter / Space arm recording.
+    focused: bool,
 }
 
 impl KeybindRecorder {
@@ -30,6 +32,7 @@ impl KeybindRecorder {
             pressed: false,
             hovered: false,
             recessed: crate::layout::control_relief(),
+            focused: false,
         })
     }
 
@@ -111,6 +114,9 @@ impl KeybindRecorder {
 }
 
 impl Input for KeybindRecorder {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Well
+    }
     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
         match event {
             Event::MouseButton { button, state, x, y, .. } => {
@@ -139,10 +145,21 @@ impl Input for KeybindRecorder {
                     }
                 }
             }
-            Event::KeyInput(key_event) => {
-                if !self.recording {
+            Event::KeyInput(key_event) if !self.recording => {
+                // A focused well not yet recording: Enter / Space arm it (the
+                // click's job, by key). Anything else is not this field's.
+                if !self.focused || key_event.state != ElementState::Pressed {
                     return false;
                 }
+                match key_event.logical_key {
+                    Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
+                        self.recording = true;
+                        true
+                    }
+                    _ => false,
+                }
+            }
+            Event::KeyInput(key_event) => {
                 let Some(ui) = ectx.ui.as_deref_mut() else {
                     return false;
                 };
@@ -236,7 +253,12 @@ impl Input for KeybindRecorder {
                 self.hovered = false;
                 false
             }
+            Event::FocusIn => {
+                self.focused = true;
+                false
+            }
             Event::FocusOut => {
+                self.focused = false;
                 self.recording = false;
                 false
             }
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index cbde643..05b5682 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -452,6 +452,9 @@ impl Paint for Spinbox {
 }
 
 impl Input for Spinbox {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Well
+    }
     fn on_event(&mut self, event: &Event, ectx: &mut EventCtx) -> bool {
         match event {
             Event::PointerMove { x: px, y: py, .. } => {
diff --git a/src/widget/input/text_box.rs b/src/widget/input/text_box.rs
index 1bfd635..a373fe3 100644
--- a/src/widget/input/text_box.rs
+++ b/src/widget/input/text_box.rs
@@ -1594,6 +1594,9 @@ impl Paint for TextBox {
 }
 
 impl Input for TextBox {
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Well
+    }
     /// Advances the wheel glide / trackpad coast behind the scroll offsets.
     /// Cheap when idle (the common case); `wants_tick` is unconditional
     /// because it is sampled once at registration.
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index ad38bbd..a2c20ff 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -140,6 +140,7 @@ pub struct LayoutTree {
 }
 
 pub use crate::scene::paint::{ControlPlate, PlateStance};
+pub use crate::widget::model::FocusRole;
 pub use crate::context::UiContext;
 
 #[derive(Debug, Clone, PartialEq)]
@@ -515,6 +516,12 @@ pub trait WidgetHost {
         (12.0, (false, false, false, false))
     }
 
+    /// This widget's part in keyboard navigation — `Input::focus_role` through
+    /// the adapter; `FocusRole::None` for anything that is not a plate or a well.
+    fn focus_role(&self) -> FocusRole {
+        FocusRole::None
+    }
+
     fn corner_radii(&self) -> CornerRadii {
         let (r, (tl, tr, br, bl)) = self.corner_style();
         CornerRadii::new(
diff --git a/src/widget/model.rs b/src/widget/model.rs
index ce5cc5a..a4c3816 100644
--- a/src/widget/model.rs
+++ b/src/widget/model.rs
@@ -381,6 +381,17 @@ impl EventCtx<'_> {
 /// events are hit-gated by the adapter *before* they reach [`on_event`](Input::on_event), so a
 /// narrow widget never re-implements the "am I actually under the cursor?" boilerplate that every
 /// legacy `mouse_input` override carries.
+/// A widget's part in keyboard navigation — see [`Input::focus_role`].
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum FocusRole {
+    /// Not a stop: the traversal skips it.
+    None,
+    /// A plate — a thing you press. Enter / Space act on it while focused.
+    Plate,
+    /// A well — a thing you enter. It opens for typing when focused.
+    Well,
+}
+
 pub trait Input {
     /// Whether the point `(x, y)` hits this widget, given its laid-out `rect`. Override for
     /// non-rectangular hit shapes. Default: containment (edges inclusive, matching the legacy
@@ -412,6 +423,16 @@ pub trait Input {
         false
     }
 
+    /// What this widget is to keyboard navigation — see "Plates, wells and
+    /// seams" in `CLAUDE.md`. A [`FocusRole::Plate`] is a thing you press
+    /// (Enter / Space act on it while focused); a [`FocusRole::Well`] opens
+    /// for typing when focused. Both are stops for `UiContext::focus_step`.
+    /// Default: [`FocusRole::None`] — skipped by the traversal. A widget that
+    /// declares a role must handle `FocusIn` / `FocusOut`.
+    fn focus_role(&self) -> FocusRole {
+        FocusRole::None
+    }
+
     /// Container hit policy: hit whenever any [`Layout::child_visible`] child hits (Layer,
     /// Switcher). The container's own rect is not consulted. Default: own-rect hit.
     fn hits_through_children(&self) -> bool {
@@ -1228,6 +1249,9 @@ impl<W: Layout + Paint + Input + 'static> WidgetHost for Adapted<W> {
         Paint::corner_style(&self.inner, self.content_rect())
             .unwrap_or((12.0, (false, false, false, false)))
     }
+    fn focus_role(&self) -> FocusRole {
+        Input::focus_role(&self.inner)
+    }
     fn solid_border(&self) -> Option<([f32; 4], f32)> {
         Paint::solid_border(&self.inner)
     }