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

commit0a02bd2770d8ea2df9cc05d7339242e09f8b6677
parente2ec2795ba
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 23:42
feat: a slider's notch follows the value's magnitude on a wide range

One wheel notch or arrow press moved a slider 2% of its range whatever
the range, which over -1000..1000 (the designer's pull vector) is 40
units: a value of 0.06 could not be reached by scrolling at all. A range
wider than FINE_SPAN (20) now steps 2% of a span that grows with the
value — 20 times its magnitude, floored at 20, capped at the range — so
it moves 0.4 a notch near zero, 4 at ten, and the old 40 from a hundred
up. Ranges up to 20 wide step exactly as before. By magnitude rather
than a finer flat step, which would take thousands of notches to cross
the range; this takes under sixty.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

 CLAUDE.md                  | 12 ++++++
 src/widget/input/slider.rs | 93 ++++++++++++++++++++++++++++++++++++++++++++--
 2 files changed, 102 insertions(+), 3 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 4c48f1d..6ee4789 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -963,3 +963,15 @@ list, which stopped being a `ParametersBg` on 2026-09-24, and what the
 rule left behind was sliders a trackpad could not turn. The pane still
 scrolls from the label column, the gaps between bands, and every row that
 is not a value control.
+
+### A slider's notch follows the value on a wide range
+
+One wheel notch or arrow press moves a `Slider` by `notch_step`: 2% of the
+range for a range up to `FINE_SPAN` (20) wide, which leaves every slider
+the toolkit had exactly as it was. A WIDER range steps 2% of a span that
+grows with the value's magnitude — 20 times it, floored at 20 and capped
+at the range — so -1000..1000 moves 0.4 a notch near zero, 4 at ten, and
+the old 40 only from a hundred up. By magnitude rather than a finer flat
+step: one fine enough to set 0.06 takes thousands of notches to reach
+1000, and this takes under sixty. A drag still maps the pointer to the
+whole range; the readout is still where an exact value is typed.
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 4e8b51f..417b846 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -108,6 +108,34 @@ impl Slider {
         (self.min, self.max)
     }
 
+    /// The widest range a notch steps a flat 2% of. Wider than this, the
+    /// step follows the value's magnitude instead ([`Self::notch_step`]).
+    pub const FINE_SPAN: f32 = 20.0;
+
+    /// What one wheel notch, or one arrow press, moves the value by, as a
+    /// fraction of the range.
+    ///
+    /// 2% of the range — for a range up to [`Self::FINE_SPAN`] wide, which
+    /// is every slider the toolkit had until a host asked for one over
+    /// -1000..1000 (the designer's pull vector, 2026-09-28): there 2% is 40
+    /// units a notch, and a value of 0.06 cannot be reached by scrolling at
+    /// all. A wide range steps 2% of a SPAN that grows with the value —
+    /// `FINE_SPAN` times its magnitude, never less than `FINE_SPAN` itself
+    /// and never more than the range — so near zero it moves as a 20-wide
+    /// slider does (0.4 a notch, under a hundredth per pixel of trackpad
+    /// travel) and far from zero by the full 2%, and the whole range is
+    /// still a few dozen notches end to end. By magnitude rather than by a
+    /// finer flat step, because a flat step fine enough for 0.06 is one
+    /// that takes thousands of notches to reach 1000.
+    pub fn notch_step(&self) -> f32 {
+        let range = (self.max - self.min).abs();
+        if range <= Self::FINE_SPAN {
+            return 0.02;
+        }
+        let span = (Self::FINE_SPAN * self.get_scaled_value().abs().max(1.0)).min(range);
+        0.02 * span / range
+    }
+
     pub fn set_scaled_value(&mut self, val: f32) {
         let range = self.max - self.min;
         if range != 0.0 {
@@ -398,7 +426,7 @@ impl Input for Slider {
                     if latched || self.scroll_hit(r, *px, *py) {
                         ui.scroll_initiate_widget_id = Some(ectx.id);
                         let scroll_amount = delta.notches_y();
-                        let new_val = (self.value - scroll_amount * 0.02).clamp(0.0, 1.0);
+                        let new_val = (self.value - scroll_amount * self.notch_step()).clamp(0.0, 1.0);
                         let applied = new_val - self.value;
                         self.set_value_marking(new_val);
                         // Velocity estimate for the release glide (the Ramp
@@ -454,8 +482,8 @@ impl Input for Slider {
                     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::ArrowLeft) | Key::Named(NamedKey::ArrowDown) => self.value - self.notch_step(),
+                    Key::Named(NamedKey::ArrowRight) | Key::Named(NamedKey::ArrowUp) => self.value + self.notch_step(),
                     Key::Named(NamedKey::Home) => 0.0,
                     Key::Named(NamedKey::End) => 1.0,
                     Key::Named(NamedKey::Enter) if self.show_readout => {
@@ -1058,6 +1086,53 @@ fn probe_slider_bridge() {
         assert!(sl.take_change());
     }
 
+    /// A notch steps 2% of the range up to a 20-wide one, exactly as it
+    /// always did; a wider range steps by the value's magnitude — as a
+    /// 20-wide slider near zero, by the full 2% far from it — for the wheel
+    /// and the arrow keys alike.
+    #[test]
+    fn a_wide_range_steps_by_the_values_magnitude() {
+        let notch = |min: f32, max: f32, at: f32| -> f32 {
+            let mut ctx = UiContext::new();
+            let mut sl = Slider::new().with_range(min, max);
+            let (id, ptr) = (sl.id(), sl.as_ptr_mut());
+            ctx.register_widget(id, ptr);
+            WidgetHost::set_rect(&mut sl, 0.0, 0.0, 200.0, 20.0);
+            sl.inner_mut().set_scaled_value(at);
+            let before = sl.inner().get_scaled_value();
+            ctx.scroll_gesture_new = true;
+            // Over the band at the value, where the halo is.
+            let x = 200.0 * sl.inner().value();
+            assert!(sl.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), x.clamp(1.0, 199.0), 10.0, &mut ctx));
+            sl.inner().get_scaled_value() - before
+        };
+        let close = |got: f32, want: f32| (got - want).abs() <= want * 0.01 + 1e-3;
+        // Ordinary ranges: 2% of the range, untouched.
+        assert!(close(notch(0.0, 2.0, 1.0), 0.04), "{}", notch(0.0, 2.0, 1.0));
+        assert!(close(notch(-10.0, 10.0, 0.0), 0.4), "{}", notch(-10.0, 10.0, 0.0));
+        assert!(close(notch(-10.0, 10.0, 8.0), 0.4));
+        // -1000..1000: near zero a notch is what a 20-wide slider's is…
+        assert!(close(notch(-1000.0, 1000.0, 0.0), 0.4), "{}", notch(-1000.0, 1000.0, 0.0));
+        assert!(close(notch(-1000.0, 1000.0, 0.5), 0.4));
+        // …it grows with the magnitude, on either side of zero…
+        assert!(close(notch(-1000.0, 1000.0, 10.0), 4.0), "{}", notch(-1000.0, 1000.0, 10.0));
+        assert!(close(notch(-1000.0, 1000.0, -10.0), 4.0));
+        // …and is capped at the 2% of the range it used to be everywhere.
+        assert!(close(notch(-1000.0, 1000.0, 500.0), 40.0), "{}", notch(-1000.0, 1000.0, 500.0));
+
+        // End to end it is still a few dozen notches, not thousands.
+        let mut sl = Slider::new().with_range(-1000.0, 1000.0);
+        sl.inner_mut().set_scaled_value(0.0);
+        let mut notches = 0;
+        while sl.inner().get_scaled_value() < 999.0 && notches < 1000 {
+            let step = sl.inner().notch_step();
+            let v = (sl.inner().value() + step).clamp(0.0, 1.0);
+            sl.inner_mut().set_value(v);
+            notches += 1;
+        }
+        assert!(notches < 60, "0 to 1000 took {notches} notches");
+    }
+
     /// A slider hovers like every other control: the adapter's hover
     /// bookkeeping turns a move over the row into `MouseEnter`, a move away
     /// into `MouseLeave`, and the band reads the flag. A float3's three rows
@@ -1120,6 +1195,18 @@ mod focus_tests {
 
     /// 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.
+    /// The arrows step a wide range by the wheel's own notch.
+    #[test]
+    fn arrows_step_a_wide_range_by_the_values_magnitude() {
+        let mut ctx = UiContext::new();
+        let mut s = Slider::new().with_range(-1000.0, 1000.0).with_readout(true);
+        WidgetHost::set_rect(&mut s, 0.0, 0.0, 200.0, 16.0);
+        s.inner_mut().set_scaled_value(0.0);
+        s.handle_event(&Event::FocusIn, &mut ctx);
+        assert!(s.handle_event(&press(NamedKey::ArrowRight), &mut ctx));
+        assert!((s.inner().get_scaled_value() - 0.4).abs() < 1e-2, "{}", s.inner().get_scaled_value());
+    }
+
     #[test]
     fn arrows_step_the_band_and_enter_opens_the_readout() {
         let mut ctx = UiContext::new();