GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
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)
}