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

commite948b08d56b96562c5a73cbddeeb7a4ea0e55fcf
parent2a7ab8659b
authorLucas Galante <[email protected]>
date2026-09-08 19:58
feat(spinbox): the wheel steps the value

Wheel up steps up and wheel down steps down, one step per notch, with a
trackpad's fractional notches accumulating to whole steps. The wheel is
consumed whenever it lands on the spinbox, mid-accumulation included, so a
host's page never scrolls under it. Stepping goes through step_by, so an
open edit commits and refreshes like the buttons do.

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

 src/widget/input/spinbox.rs | 46 +++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 46 insertions(+)

diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index e4531ff..cbde643 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -28,6 +28,9 @@ pub struct Spinbox {
     pub editor_state: TextEditorState,
     pub just_changed: bool,
     label: Option<String>,
+    /// Wheel notches carried between events: a trackpad's fractional notches
+    /// add up to whole steps instead of being dropped.
+    wheel_accum: f32,
     /// Char-index → x offsets of the value text, recorded by [`Paint::prepare_text`]
     /// from the same shaped buffer the renderer draws (`ctx.text`, size 14, default
     /// family). The caret and click→index math read these; the `8.4` px/char guess
@@ -66,6 +69,7 @@ impl Spinbox {
             editor_state: TextEditorState::new(String::new()),
             just_changed: false,
             label: None,
+            wheel_accum: 0.0,
             glyph_offsets: Vec::new(),
         })
     }
@@ -489,6 +493,21 @@ impl Input for Spinbox {
                     false
                 }
             }
+            Event::MouseWheel { delta, .. } => {
+                // Wheel up steps up, wheel down steps down, one step per notch;
+                // fractional (trackpad) notches accumulate. Always consumed, so
+                // a host's page never scrolls under a spinbox mid-gesture.
+                self.wheel_accum += delta.notches_y();
+                while self.wheel_accum >= 1.0 {
+                    self.wheel_accum -= 1.0;
+                    self.step_by(1);
+                }
+                while self.wheel_accum <= -1.0 {
+                    self.wheel_accum += 1.0;
+                    self.step_by(-1);
+                }
+                true
+            }
             Event::KeyInput(key_event) => {
                 if !self.editing || key_event.state != ElementState::Pressed {
                     return false;
@@ -640,6 +659,33 @@ mod tests {
         assert_eq!(sb.value, 7);
     }
 
+    #[test]
+    fn spinbox_wheel_steps_by_notch_and_accumulates_fractions() {
+        use crate::widget::MouseScrollDelta;
+        let mut ctx = UiContext::new();
+        let mut sb = Spinbox::new(10, 0, 100, 5);
+        let (id, ptr) = (sb.id(), sb.as_ptr_mut());
+        ctx.register_widget(id, ptr);
+        WidgetHost::set_rect(&mut sb, 10.0, 20.0, 100.0, 26.0);
+
+        // One notch up steps up, one notch down steps down — and the wheel is consumed.
+        assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 50.0, 33.0, &mut ctx));
+        assert_eq!(sb.value, 15);
+        assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -1.0), 50.0, 33.0, &mut ctx));
+        assert_eq!(sb.value, 10);
+        assert!(sb.take_change());
+
+        // Fractional (trackpad) notches accumulate to a whole step, consumed all the while.
+        assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), 50.0, 33.0, &mut ctx));
+        assert_eq!(sb.value, 10, "half a notch: no step yet");
+        assert!(sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), 50.0, 33.0, &mut ctx));
+        assert_eq!(sb.value, 15, "the second half completes the notch");
+
+        // Outside the rect the wheel is not the spinbox's (hit-gated by the adapter).
+        assert!(!sb.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 1.0), 200.0, 200.0, &mut ctx));
+        assert_eq!(sb.value, 15);
+    }
+
     #[test]
     fn spinbox_value_string_decimals_round_trip() {
         let mut sb = Spinbox::new(150, 0, 1000, 5).with_decimals(2);