GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
feat: Button takes keyboard focus — Enter/Space activation + a focus ring
Button was the only interactive widget with no focus story: it handled mouse
events only, and had no notion of being focused, so anything that focused one
(ctrl-nav in settings, for instance) landed somewhere invisible and inert.
Tracks `focused` from FocusIn/FocusOut exactly as Checkbox does — Paint never
sees the Widget base, so the flag has to live on the model to be paintable.
Enter/Space on a focused button set `just_clicked` and fire `on_click_cb`, the
same chord Dropdown and Menu already use, routed so a keyboard press is
indistinguishable downstream from a mouse one.
The ring reuses the border the button already draws, tinted with the DE's
existing tree_border_focus_color rather than inventing a visual: no new
geometry, and an unfocused button renders byte-identically to before. It
overrides the ListRow border opt-out, since a focused row showing the ring is
the entire point.
Live-verified in cce-system-interface's Storage page: ctrl+i onto the Run Backup
button flips its border from near-black to the focus colour (pixel-diffed to a
clean rectangle outline). NB the ring only appears on the flat fill+border path
— a `raised` button draws an SDF bevel instead, and hosts whose RenderTarget
lacks `bevel` (PageContent) drop the plate entirely.
Co-Authored-By: Claude Opus 5 <[email protected]>
src/widget/input/button.rs | 43 ++++++++++++++++++++++++++++++++++++++++---
1 file changed, 40 insertions(+), 3 deletions(-)
diff --git a/src/widget/input/button.rs b/src/widget/input/button.rs
index 19b6edf..9290334 100644
--- a/src/widget/input/button.rs
+++ b/src/widget/input/button.rs
@@ -8,8 +8,8 @@ use crate::colors;
use crate::scene::layout::{Rect, Size};
use crate::scene::paint::PaintCtx;
use crate::widget::{
- Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Justification, Layout,
- MouseButton, Paint,
+ Adapted, WidgetHost, ElementState, Event, EventCtx, Input, Justification, Key, Layout,
+ MouseButton, NamedKey, Paint,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
@@ -36,6 +36,10 @@ pub struct Button {
/// centered in place of the label (see [`crate::upload_icon`]).
icon: Option<(u32, f32, f32)>,
hovered: bool,
+ /// Keyboard focus, tracked from `FocusIn`/`FocusOut` the way Checkbox does —
+ /// `Paint` never sees the `Widget` base, so the flag has to live here to be
+ /// paintable. Drives the focus ring and gates Enter/Space activation.
+ focused: bool,
/// Raised style: the background is an SDF-lit `Bevel` plate — fill plus a
/// rolled, lit edge — instead of a flat fill + border stroke.
raised: bool,
@@ -70,6 +74,7 @@ impl Button {
label: None,
icon: None,
hovered: false,
+ focused: false,
raised: crate::layout::control_relief(),
}
}
@@ -300,7 +305,13 @@ impl Paint for Button {
// reads as a 1px ring when the fill is opaque — a row's
// transparent idle fill left the whole row painted in the config
// button border_color (an accidental coupling).
- let border_color = if self.kind == ButtonKind::ListRow {
+ // Keyboard focus reuses the border the button already draws, tinted with
+ // the DE's existing focus-border colour — no new geometry, and nothing
+ // changes for a button that is not focused. It overrides the ListRow
+ // opt-out too: a focused row must show the ring, which is the whole point.
+ let border_color = if self.focused {
+ Some(colors::tree_border_focus_color())
+ } else if self.kind == ButtonKind::ListRow {
None
} else {
colors::button_border_color()
@@ -410,6 +421,32 @@ impl Input for Button {
self.hovered = false;
false
}
+ Event::FocusIn => {
+ self.focused = true;
+ false
+ }
+ Event::FocusOut => {
+ self.focused = false;
+ false
+ }
+ Event::KeyInput(key_event) => {
+ // Enter/Space activate a focused button, the same chord Dropdown
+ // and Menu use. Routed through `just_clicked` + `on_click_cb` so a
+ // keyboard press is indistinguishable downstream from a mouse one.
+ if !self.focused || key_event.state != ElementState::Pressed {
+ return false;
+ }
+ match key_event.logical_key {
+ Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => {
+ self.just_clicked = true;
+ if let Some(ref cb) = self.on_click_cb {
+ cb();
+ }
+ true
+ }
+ _ => false,
+ }
+ }
_ => false,
}
}