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

commit5c6383c5557e332675e0d1b4c3214219b549cfea
parent2e8c5532ef
authorLucas Galante <[email protected]>
date2026-09-08 21:10
feat(focus): RangeSlider is a well — one stop, two ends

Focused, the band lights; the arrows step the focused end by a wheel notch
inside the other end's bound, Up / Down switch which end the keys move
(starting at the low end), Home / End send the focused end to its limit.

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

 CLAUDE.md                  |  5 +--
 src/widget/input/slider.rs | 83 +++++++++++++++++++++++++++++++++++++++++++++-
 2 files changed, 85 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index bb57539..774d060 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -150,9 +150,10 @@ What this buys, and where the code is heading:
   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
+  Spinbox, ColorSelector, KeybindRecorder, TreeList, 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`
+  and RangeSlider (one stop, two ends: arrows step the focused end, Up / Down
+  switch ends) 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
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index d5f6d95..4b658a9 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -574,6 +574,10 @@ pub struct RangeSlider {
     pub(crate) active_thumb: Option<ActiveThumb>,
     drag_offset: f32,
     label: Option<String>,
+    /// Keyboard focus (FocusIn / FocusOut): the band lights; `focus_end` is
+    /// the end the arrows move (Up / Down switch it), starting at the low end.
+    focused: bool,
+    focus_end: ActiveThumb,
 }
 
 impl RangeSlider {
@@ -584,6 +588,8 @@ impl RangeSlider {
             active_thumb: None,
             drag_offset: 0.0,
             label: None,
+            focused: false,
+            focus_end: ActiveThumb::Low,
         })
     }
 
@@ -721,14 +727,66 @@ impl Paint for RangeSlider {
         let range = w - thumb_size;
         let lo = x + self.value_low * range + thumb_size / 2.0;
         let hi = x + self.value_high * range + thumb_size / 2.0;
-        let color = if self.active_thumb.is_some() { colors::rangeslider_thumb_drag() } else { colors::rangeslider_thumb() };
+        // A band has no rim to light: focused, the band itself is the highlight.
+        let color = if self.active_thumb.is_some() {
+            colors::rangeslider_thumb_drag()
+        } else if self.focused {
+            crate::color::highlight_primary_color()
+        } else {
+            colors::rangeslider_thumb()
+        };
         paint_band_shape(ctx, x, w, y + h * 0.5, color, &|px| band_profile(x, w, h, px, &[lo, hi], Some((lo, hi))));
     }
 }
 
 impl Input for RangeSlider {
+    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::FocusIn => {
+                self.focused = true;
+                self.focus_end = ActiveThumb::Low;
+                true
+            }
+            Event::FocusOut => {
+                self.focused = false;
+                true
+            }
+            Event::KeyInput(key_event) => {
+                // One stop, two ends: Left / Right step the focused end by a
+                // wheel notch inside the other end's bound, Up / Down switch
+                // ends, Home / End send the focused end to its limit.
+                if !self.focused || key_event.state != ElementState::Pressed {
+                    return false;
+                }
+                let low = matches!(self.focus_end, ActiveThumb::Low);
+                let (cur, min, max) = if low {
+                    (self.value_low, 0.0, self.value_high)
+                } else {
+                    (self.value_high, self.value_low, 1.0)
+                };
+                let target = match key_event.logical_key {
+                    Key::Named(NamedKey::ArrowLeft) => cur - 0.02,
+                    Key::Named(NamedKey::ArrowRight) => cur + 0.02,
+                    Key::Named(NamedKey::Home) => min,
+                    Key::Named(NamedKey::End) => max,
+                    Key::Named(NamedKey::ArrowUp) | Key::Named(NamedKey::ArrowDown) => {
+                        self.focus_end = if low { ActiveThumb::High } else { ActiveThumb::Low };
+                        return true;
+                    }
+                    _ => return false,
+                };
+                let target = target.clamp(min, max);
+                if low {
+                    self.value_low = target;
+                } else {
+                    self.value_high = target;
+                }
+                true
+            }
             Event::MouseWheel { delta, x: px, y: py, .. } => {
                 let Some(ui) = ectx.ui.as_deref_mut() else { return false };
                 if !ui.scroll_gesture_new && ui.scroll_initiate_widget_id != Some(ectx.id) {
@@ -944,6 +1002,29 @@ mod focus_tests {
         Event::KeyInput(KeyEvent { logical_key: Key::Named(key), state: ElementState::Pressed, text: None, repeat: false, ctrl: false, shift: false, alt: false })
     }
 
+    /// A focused range: Right steps the low end, Down switches to the high end,
+    /// Left steps it, End sends it to 1, and the low end can never pass the high.
+    #[test]
+    fn range_arrows_step_the_focused_end_and_up_down_switch() {
+        let mut ctx = UiContext::new();
+        let mut r = RangeSlider::new().with_values(0.2, 0.8);
+        WidgetHost::set_rect(&mut r, 0.0, 0.0, 200.0, 16.0);
+        assert!(!r.handle_event(&press(NamedKey::ArrowRight), &mut ctx), "unfocused: not this range's key");
+        r.handle_event(&Event::FocusIn, &mut ctx);
+        assert!(r.handle_event(&press(NamedKey::ArrowRight), &mut ctx));
+        let (lo, hi) = r.inner().values();
+        assert!((lo - 0.22).abs() < 1e-5 && (hi - 0.8).abs() < 1e-5, "the low end moved");
+        assert!(r.handle_event(&press(NamedKey::ArrowDown), &mut ctx));
+        assert!(r.handle_event(&press(NamedKey::ArrowLeft), &mut ctx));
+        let (lo, hi) = r.inner().values();
+        assert!((lo - 0.22).abs() < 1e-5 && (hi - 0.78).abs() < 1e-5, "then the high end");
+        assert!(r.handle_event(&press(NamedKey::End), &mut ctx));
+        assert_eq!(r.inner().values().1, 1.0);
+        assert!(r.handle_event(&press(NamedKey::ArrowUp), &mut ctx));
+        assert!(r.handle_event(&press(NamedKey::End), &mut ctx));
+        assert_eq!(r.inner().values(), (1.0, 1.0), "the low end stops at the high end");
+    }
+
     /// 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]