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