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

commit2e8c5532eff15d5fd6e9c1f0276ebb24cc12d5dd
parent9d4545d0f2
authorLucas Galante <[email protected]>
date2026-09-08 20:58
feat(focus): ButtonStrip, Breadcrumb and Slider take their places in the walk

ButtonStrip is a plate: focused, the arrows along its axis move the
selection between its segment plates (a keyboard press of the neighbour, so
hosts see the click), wrapping, and Enter / Space press the selected one
again; the selected plateau's rim lights, the flat style wearing a hairline
ring instead.

Breadcrumb is a plate: focus lands a cursor on the current directory, Left /
Right walk the visible segments (the ellipsis is not a stop), Enter / Space
navigate to the cursor's segment — the click. The run's rim lights and the
cursor segment wears the hover wash's banded silhouette in the highlight.

Slider is a well, a band entered and adjusted in place: the arrows step it
by a wheel notch, Home / End go to the ends, Enter opens the readout for
typing; the band itself lights in the highlight, having no rim.

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

 CLAUDE.md                          |  10 +++-
 src/widget/container/breadcrumb.rs | 112 ++++++++++++++++++++++++++++++++++++-
 src/widget/input/button_strip.rs   |  86 +++++++++++++++++++++++++++-
 src/widget/input/slider.rs         |  78 +++++++++++++++++++++++++-
 4 files changed, 275 insertions(+), 11 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 987846b..bb57539 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -147,9 +147,13 @@ What this buys, and where the code is heading:
   well's `recess_tinted` gives its rim while editing — never extra geometry.
   A Checkbox lights the ring its mark already draws; a Toggle lights its
   label (its rocker halves carve partial rings, which cannot be tinted).
-  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`.
+  Roles today: Button, Checkbox, Toggle, Dropdown, FontSelector, ButtonStrip
+  (arrows move the selection between its segment plates) and Breadcrumb
+  (arrows walk its visible segments, Enter navigates) are plates; TextBox,
+  Spinbox, ColorSelector, KeybindRecorder, TreeList and Slider (a band, but
+  entered and adjusted in place — arrows step it, Enter opens the readout)
+  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/widget/container/breadcrumb.rs b/src/widget/container/breadcrumb.rs
index 60a4cbf..df25e9f 100644
--- a/src/widget/container/breadcrumb.rs
+++ b/src/widget/container/breadcrumb.rs
@@ -59,6 +59,11 @@ pub struct Breadcrumb {
     /// than sitting inset into a toolbar. Flat (non-relief) styling and the
     /// seams are identical in both stances.
     pub raised: bool,
+    /// Keyboard focus (FocusIn / FocusOut): the run's rim lights and a cursor
+    /// segment (`focus_seg`, a logical index) wears the wash; the arrows walk
+    /// the visible segments, Enter / Space navigate to the cursor's.
+    focused: bool,
+    focus_seg: Option<usize>,
 }
 
 impl Breadcrumb {
@@ -69,6 +74,8 @@ impl Breadcrumb {
             hovered_seg: None,
             clicked_seg: None,
             right_clicked_seg: None,
+            focused: false,
+            focus_seg: None,
             network_opacity: 1.0,
             raised: false,
         })
@@ -350,11 +357,20 @@ impl Paint for Breadcrumb {
                     (crate::widget::PlateStance::Flush, face)
                 };
                 ctx.control_plate(
-                    &crate::widget::ControlPlate::control(run_rect, r, stance, face).with_depth(depth),
+                    &crate::widget::ControlPlate::control(run_rect, r, stance, face)
+                        .with_depth(depth)
+                        .with_tint(self.focused.then(crate::widget::ControlPlate::focus_tint)),
                 );
                 for (a, b) in self.seams(rect) {
                     ctx.groove(a, b, Self::SEAM_WIDTH, depth, run_rect);
                 }
+            } else if self.focused {
+                // Flat: no rim to light, so the run wears a hairline ring in the highlight.
+                let t = crate::widget::ControlPlate::focus_tint();
+                ctx.border(run_rect, (r, r, r, r), self.bg_color(), [t[0], t[1], t[2], 1.0], 1.0);
+                for (a, b) in self.seams(rect) {
+                    ctx.vector(a.0, a.1, b.0, b.1, 1.0, [0.0, 0.0, 0.0, 0.25], crate::scene::paint::Cap::Flat);
+                }
             } else {
                 ctx.rounded_rect(run_rect, r, (true, true, true, true), self.bg_color());
                 for (a, b) in self.seams(rect) {
@@ -369,7 +385,17 @@ impl Paint for Breadcrumb {
         // same seam-lean and corner-arc math the seams and run box use.
         // ~24 plain Quads, hover-only — and Quads survive the flat hosts'
         // rounded-quad bridge, so cce-files' mirror gets the same shape.
-        if let Some(hovered) = self.hovered_seg {
+        // The hover wash, and the keyboard cursor's wash in the highlight while
+        // the run holds focus — the same banded silhouette.
+        let mut washes: Vec<(usize, [f32; 4])> = Vec::new();
+        if let Some(h) = self.hovered_seg {
+            washes.push((h, [1.0, 1.0, 1.0, 0.06]));
+        }
+        if let (true, Some(f)) = (self.focused, self.focus_seg) {
+            let t = crate::widget::ControlPlate::focus_tint();
+            washes.push((f, [t[0], t[1], t[2], 0.18]));
+        }
+        for (hovered, wash) in washes {
             if let (Some((sx0, sw)), Some((rx, ry, rw, rh))) = (
                 segs.iter().find(|s| s.logical == Some(hovered)).map(|s| (s.x, s.w)),
                 self.run_box(rect),
@@ -390,7 +416,6 @@ impl Paint for Breadcrumb {
                     };
                     rr - (rr * rr - dy * dy).max(0.0).sqrt()
                 };
-                let wash = [1.0, 1.0, 1.0, 0.06];
                 let mut y = hy;
                 while y < hy + hh {
                     let bh = 1.0f32.min(hy + hh - y);
@@ -436,8 +461,61 @@ impl Input for Breadcrumb {
         self.seg_at(rect, px, py).is_some()
     }
 
+    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::FocusIn => {
+                self.focused = true;
+                // The cursor starts on the current directory (the last segment).
+                self.focus_seg = self.path.len().checked_sub(1);
+                true
+            }
+            Event::FocusOut => {
+                self.focused = false;
+                self.focus_seg = None;
+                true
+            }
+            Event::KeyInput(key_event) => {
+                // Left / Right walk the VISIBLE segments (the ellipsis is not a
+                // stop); Enter / Space navigate to the cursor's segment, the click.
+                if !self.focused || key_event.state != crate::widget::ElementState::Pressed {
+                    return false;
+                }
+                let logicals: Vec<usize> =
+                    self.visible_segs(ectx.rect).into_iter().filter_map(|s| s.logical).collect();
+                match key_event.logical_key {
+                    crate::widget::Key::Named(crate::widget::NamedKey::ArrowLeft)
+                    | crate::widget::Key::Named(crate::widget::NamedKey::ArrowRight) => {
+                        if logicals.is_empty() {
+                            return false;
+                        }
+                        let right = key_event.logical_key == crate::widget::Key::Named(crate::widget::NamedKey::ArrowRight);
+                        let pos = self.focus_seg.and_then(|f| logicals.iter().position(|l| *l == f));
+                        let next = match (pos, right) {
+                            (Some(p), true) => (p + 1).min(logicals.len() - 1),
+                            (Some(p), false) => p.saturating_sub(1),
+                            (None, true) => 0,
+                            (None, false) => logicals.len() - 1,
+                        };
+                        self.focus_seg = Some(logicals[next]);
+                        true
+                    }
+                    crate::widget::Key::Named(crate::widget::NamedKey::Enter)
+                    | crate::widget::Key::Named(crate::widget::NamedKey::Space) => {
+                        match self.focus_seg {
+                            Some(i) if i < self.path.len() => {
+                                self.clicked_seg = Some(i);
+                                true
+                            }
+                            _ => false,
+                        }
+                    }
+                    _ => false,
+                }
+            }
             Event::PointerMove { x: px, y: py, .. } => {
                 let r = ectx.rect;
                 let was = self.hovered;
@@ -753,3 +831,31 @@ mod tests {
         assert_eq!(breadcrumb.path, vec!["a".to_string()]);
     }
 }
+
+#[cfg(test)]
+mod focus_tests {
+    use super::*;
+    use crate::widget::{ElementState, Event, Key, KeyEvent, NamedKey, UiContext, WidgetHost};
+
+    fn press(key: NamedKey) -> Event {
+        Event::KeyInput(KeyEvent { logical_key: Key::Named(key), state: ElementState::Pressed, text: None, repeat: false, ctrl: false, shift: false, alt: false })
+    }
+
+    /// Focus lands the cursor on the current directory; Left walks back a
+    /// visible segment; Enter navigates to the cursor's segment (the click).
+    #[test]
+    fn cursor_walks_segments_and_enter_navigates() {
+        let mut ctx = UiContext::new();
+        let mut b = Breadcrumb::new();
+        b.set_path(&["home".to_string(), "lsgalante".to_string(), "projects".to_string()]);
+        WidgetHost::set_rect(&mut b, 10.0, 20.0, 400.0, 26.0);
+        b.handle_event(&Event::FocusIn, &mut ctx);
+        assert_eq!(b.inner().focus_seg, Some(2), "cursor on the current directory");
+        assert!(b.handle_event(&press(NamedKey::ArrowLeft), &mut ctx));
+        assert_eq!(b.inner().focus_seg, Some(1));
+        assert!(b.handle_event(&press(NamedKey::Enter), &mut ctx));
+        assert_eq!(b.inner().clicked_seg, Some(1), "Enter is the click on the cursor's segment");
+        b.handle_event(&Event::FocusOut, &mut ctx);
+        assert_eq!(b.inner().focus_seg, None);
+    }
+}
diff --git a/src/widget/input/button_strip.rs b/src/widget/input/button_strip.rs
index 46d66a6..1e9e45a 100644
--- a/src/widget/input/button_strip.rs
+++ b/src/widget/input/button_strip.rs
@@ -29,6 +29,9 @@ pub struct ButtonStrip {
     /// and press a wash. Defaults to `control_relief()`; the flat style keeps
     /// the plain state quads.
     pub recessed: bool,
+    /// Keyboard focus (FocusIn / FocusOut): the selected segment's plate wears
+    /// the ring; arrows move the selection, Enter / Space press it.
+    focused: bool,
 }
 
 impl ButtonStrip {
@@ -52,6 +55,7 @@ impl ButtonStrip {
             inherit_menubar_font: false,
             label: None,
             recessed: crate::layout::control_relief(),
+            focused: false,
         }
     }
 
@@ -375,16 +379,25 @@ impl crate::widget::Paint for ButtonStrip {
             let inset = if self.recessed { depth * 0.5 } else { 0.0 };
             let seg = Rect { x: r.0 + inset, y: r.1 + inset, width: (r.2 - 2.0 * inset).max(0.0), height: (r.3 - 2.0 * inset).max(0.0) };
             let seg_r = (radius - inset).max(0.0);
+            let focus_ring = self.focused && Some(i) == self.selected;
             if bg_color != [0.0, 0.0, 0.0, 0.0] {
-                pc.rounded_rect(seg, seg_r, (true, true, true, true), bg_color);
+                if focus_ring && !self.recessed {
+                    // Flat: no rim to light, so the selected fill wears a hairline
+                    // ring in the highlight.
+                    let t = crate::widget::ControlPlate::focus_tint();
+                    pc.border(seg, (seg_r, seg_r, seg_r, seg_r), bg_color, [t[0], t[1], t[2], 1.0], 1.0);
+                } else {
+                    pc.rounded_rect(seg, seg_r, (true, true, true, true), bg_color);
+                }
             }
             if self.recessed && Some(i) == self.selected {
                 // The selected segment: a raised control plate standing on the
                 // well floor, faceless (the floor shows through), at the well's
-                // depth.
+                // depth — its rim lit while the strip holds keyboard focus.
                 pc.control_plate(
                     &crate::widget::ControlPlate::control(seg, seg_r, crate::widget::PlateStance::Raised, [0.0; 4])
-                        .with_depth(depth),
+                        .with_depth(depth)
+                        .with_tint(focus_ring.then(crate::widget::ControlPlate::focus_tint)),
                 );
             }
 
@@ -449,8 +462,45 @@ impl crate::widget::Input for ButtonStrip {
         changed
     }
 
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Plate
+    }
+
     fn on_event(&mut self, event: &Event, _ectx: &mut crate::widget::EventCtx) -> bool {
         match event {
+            Event::FocusIn => {
+                self.focused = true;
+                true
+            }
+            Event::FocusOut => {
+                self.focused = false;
+                true
+            }
+            Event::KeyInput(key_event) => {
+                // The segments are plates in a row: the arrows along the strip's
+                // axis move the selection (a keyboard press of the neighbour),
+                // Enter / Space press the selected one again.
+                if !self.focused || key_event.state != ElementState::Pressed || self.buttons.is_empty() {
+                    return false;
+                }
+                let n = self.buttons.len();
+                let step: Option<isize> = match key_event.logical_key {
+                    Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowUp) => Some(-1),
+                    Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::ArrowDown) => Some(1),
+                    Key::Named(NamedKey::Enter) | Key::Named(NamedKey::Space) => Some(0),
+                    _ => None,
+                };
+                let Some(step) = step else { return false };
+                let target = match (self.selected, step) {
+                    (Some(i), 0) => i,
+                    (None, _) => if step < 0 { n - 1 } else { 0 },
+                    (Some(i), s) => ((i as isize + s).rem_euclid(n as isize)) as usize,
+                };
+                self.selected = Some(target);
+                self.just_clicked = Some(target);
+                self.generate_rotated_labels();
+                true
+            }
             Event::MouseButton { button, state, x: px, y: py, .. } => {
                 if *button != MouseButton::Left {
                     return false;
@@ -591,3 +641,33 @@ impl ButtonStrip {
         labels
     }
 }
+
+#[cfg(test)]
+mod focus_tests {
+    use super::*;
+    use crate::widget::{Event, KeyEvent, UiContext, WidgetHost};
+
+    fn press(key: NamedKey) -> Event {
+        Event::KeyInput(KeyEvent { logical_key: Key::Named(key), state: ElementState::Pressed, text: None, repeat: false, ctrl: false, shift: false, alt: false })
+    }
+
+    /// Focused, the arrows move the selection between the segment plates (a
+    /// keyboard press of the neighbour, so hosts see a click), wrapping; Enter
+    /// presses the selected one again.
+    #[test]
+    fn arrows_move_the_selection_and_enter_presses_it() {
+        let mut ctx = UiContext::new();
+        let mut s = Adapted::new(ButtonStrip::new(0.0, 0.0, 200.0, 28.0).with_buttons(vec!["One".into(), "Two".into(), "Three".into()]).with_selected(Some(0)));
+        WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 28.0);
+        assert!(!s.handle_event(&press(NamedKey::ArrowRight), &mut ctx), "unfocused: not this strip's key");
+        s.handle_event(&Event::FocusIn, &mut ctx);
+        assert!(s.handle_event(&press(NamedKey::ArrowRight), &mut ctx));
+        assert_eq!(s.selected(), Some(1));
+        assert_eq!(s.inner_mut().take_click(), Some(1), "hosts see a click");
+        assert!(s.handle_event(&press(NamedKey::ArrowLeft), &mut ctx));
+        assert!(s.handle_event(&press(NamedKey::ArrowLeft), &mut ctx));
+        assert_eq!(s.selected(), Some(2), "wraps");
+        assert!(s.handle_event(&press(NamedKey::Enter), &mut ctx));
+        assert_eq!(s.inner_mut().take_click(), Some(2));
+    }
+}
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index edcb142..d5f6d95 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -43,6 +43,9 @@ pub struct Slider {
     /// exponential decay instead of stopping dead.
     scroll_vel: f32,
     last_wheel: Option<std::time::Instant>,
+    /// Keyboard focus (FocusIn / FocusOut): the band lights in the highlight;
+    /// the arrows adjust, Home / End go to the ends, Enter opens the readout.
+    focused: bool,
     /// Readout / edit-buffer display precision (decimal places).
     decimals: usize,
 }
@@ -64,6 +67,7 @@ impl Slider {
             label: None,
             scroll_vel: 0.0,
             last_wheel: None,
+            focused: false,
             decimals: 2,
         })
     }
@@ -150,7 +154,14 @@ impl Slider {
     /// The slider: the band spanning the whole track, swelling at the value
     /// (`paint_band_shape`).
     fn paint_band(&self, g: &SliderGeom, ctx: &mut PaintCtx) {
-        let color = if self.dragging { colors::slider_thumb_drag() } else { colors::slider_thumb() };
+        // A band has no rim to light: focused, the band itself is the highlight.
+        let color = if self.dragging {
+            colors::slider_thumb_drag()
+        } else if self.focused {
+            crate::color::highlight_primary_color()
+        } else {
+            colors::slider_thumb()
+        };
         paint_band_shape(ctx, g.track_x, g.track_w, g.y + g.h * 0.5, color, &|x| self.band_height_at(g, x));
     }
 
@@ -384,6 +395,33 @@ impl Input for Slider {
                 }
                 false
             }
+            Event::FocusIn => {
+                self.focused = true;
+                true
+            }
+            Event::KeyInput(key_event) if !self.editing => {
+                // A focused band: the arrows step the value by a wheel notch,
+                // Home / End go to the ends, Enter opens the readout for typing.
+                if !self.focused || key_event.state != ElementState::Pressed {
+                    return false;
+                }
+                let target = match key_event.logical_key {
+                    Key::Named(NamedKey::ArrowLeft) | Key::Named(NamedKey::ArrowDown) => self.value - 0.02,
+                    Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::ArrowUp) => self.value + 0.02,
+                    Key::Named(NamedKey::Home) => 0.0,
+                    Key::Named(NamedKey::End) => 1.0,
+                    Key::Named(NamedKey::Enter) if self.show_readout => {
+                        self.editing = true;
+                        self.edit_buffer = self.scaled_string();
+                        return true;
+                    }
+                    _ => return false,
+                };
+                self.scroll_vel = 0.0;
+                self.last_wheel = None;
+                self.set_value_marking(target.clamp(0.0, 1.0));
+                true
+            }
             Event::KeyInput(key_event) => {
                 if !self.editing || key_event.state != ElementState::Pressed {
                     return false;
@@ -425,13 +463,18 @@ impl Input for Slider {
             }
             // Focus loss commits the readout edit (legacy `unfocus` override).
             Event::FocusOut => {
+                self.focused = false;
                 self.commit_edit();
-                false
+                true
             }
             _ => false,
         }
     }
 
+    fn focus_role(&self) -> crate::widget::FocusRole {
+        crate::widget::FocusRole::Well
+    }
+
     fn opens_context_menu(&self) -> bool {
         true
     }
@@ -891,3 +934,34 @@ fn probe_slider_bridge() {
         assert!(sl.take_change());
     }
 }
+
+#[cfg(test)]
+mod focus_tests {
+    use super::*;
+    use crate::widget::{Event, KeyEvent, UiContext, WidgetHost};
+
+    fn press(key: NamedKey) -> Event {
+        Event::KeyInput(KeyEvent { logical_key: Key::Named(key), state: ElementState::Pressed, text: None, repeat: false, ctrl: false, shift: false, alt: false })
+    }
+
+    /// A focused band steps by a wheel notch on the arrows, jumps on Home / End,
+    /// and opens its readout on Enter; unfocused it ignores the keys.
+    #[test]
+    fn arrows_step_the_band_and_enter_opens_the_readout() {
+        let mut ctx = UiContext::new();
+        let mut s = Slider::new().with_readout(true);
+        WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 16.0);
+        let v0 = s.inner().value;
+        assert!(!s.handle_event(&press(NamedKey::ArrowRight), &mut ctx));
+        assert_eq!(s.inner().value, v0, "unfocused: untouched");
+        s.handle_event(&Event::FocusIn, &mut ctx);
+        assert!(s.handle_event(&press(NamedKey::ArrowRight), &mut ctx));
+        assert!((s.inner().value - (v0 + 0.02)).abs() < 1e-5);
+        assert!(s.handle_event(&press(NamedKey::End), &mut ctx));
+        assert_eq!(s.inner().value, 1.0);
+        assert!(s.handle_event(&press(NamedKey::Home), &mut ctx));
+        assert_eq!(s.inner().value, 0.0);
+        assert!(s.handle_event(&press(NamedKey::Enter), &mut ctx));
+        assert!(s.inner().editing, "Enter opens the readout for typing");
+    }
+}