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

commitbc2268af38e2cf6742940d0d455969ef04f86978
parent4a9764a70e
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-30 12:16
fix: a value control reads the wheel as up is more, a natural finger too

Every value control had picked its own sign for the wheel: the slider
was right for a natural trackpad and backwards for a wheel, the spinbox
and the menu slider the other way round. value_notches_y is the one
reading: a wheel notch up is more, and a finger's delta is negated under
natural scrolling, since it is what a list scrolls by.

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

 CLAUDE.md                   | 19 +++++++++++++++++
 src/input.rs                | 46 +++++++++++++++++++++++++++++++++++++++++
 src/widget/core.rs          |  2 +-
 src/widget/input/slider.rs  | 15 +++++++-------
 src/widget/input/spinbox.rs |  2 +-
 src/widget/mod.rs           | 50 +++++++++++++++++++++++++++++++++++++++++++++
 6 files changed, 125 insertions(+), 9 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 321144f..a4e366e 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -959,6 +959,25 @@ assignment, a rebuild, a section collapsing (`apply_label_layout` /
 comes OFF a widget, and `slider::detached_strip` reads an empty label as
 none for the widgets that store whatever they are handed.
 
+### A value control reads the wheel as "up is more", a natural finger too
+
+`MouseScrollDelta::value_notches_y` (2026-09-30) is what a VALUE control —
+`Slider`, `Slider2D`, `Spinbox`, a context menu's slider row — turns by:
+a wheel notch up is positive, and a finger's pixel delta is taken as it
+comes with natural scrolling off and NEGATED with it on
+(`input::natural_scroll`, input.kdl's `trackpad { natural_scroll }`, read
+once). The delta the runner hands out is what a LIST scrolls by, and a
+natural list moves its content the way the fingers went; a value has no
+content to move, so under natural scrolling the fingers going up is
+more. Until then each control read `notches_y` with a sign of its own:
+the slider was right for a natural trackpad and backwards for a wheel,
+the spinbox and the menu slider the other way round. Under `cfg(test)`
+the toolkit's suite reads natural as off; `force_natural_scroll` sets it
+per THREAD for a test that drives a control with a finger — a dependent's
+test binary links cce-ui without `cfg(test)` and would otherwise read the
+machine's. `a_value_control_reads_up_as_more_on_a_wheel_and_a_natural_finger`
+is the test.
+
 ### A flick coasts with animations off
 
 The animations switch (`motion::enabled`) stops a wheel notch's GLIDE and
diff --git a/src/input.rs b/src/input.rs
index 6921ba1..90537e9 100644
--- a/src/input.rs
+++ b/src/input.rs
@@ -370,6 +370,52 @@ pub fn app_chord(name: &str, default: &str) -> String {
     cached().resolve_chord(&app, name, default)
 }
 
+/// Whether the trackpad scrolls NATURALLY — the content following the
+/// fingers — per input.kdl's `trackpad { natural_scroll }`, as the
+/// compositor applies it. Read once per process; a thread's override
+/// (`force_natural_scroll`, for a test) wins over it, and under `cfg(test)`
+/// with no override the answer is `false`, so the toolkit's own suite does
+/// not read the machine.
+///
+/// What it is for: a VALUE control — a slider, a spinbox, a menu's slider
+/// row — takes the wheel as "up is more", and a finger under natural
+/// scrolling as the same thing, which is the opposite sign of the pixel
+/// delta the runner hands it (the delta is what a LIST scrolls by, and a
+/// list under natural scrolling moves its content the way the fingers
+/// went). See `MouseScrollDelta::value_notches_y`.
+pub fn natural_scroll() -> bool {
+    if let Some(forced) = NATURAL_OVERRIDE.with(|f| f.get()) {
+        return forced;
+    }
+    #[cfg(test)]
+    {
+        false
+    }
+    #[cfg(not(test))]
+    {
+        *NATURAL_SCROLL.get_or_init(|| {
+            let input = cached();
+            let app = crate::config::get_app_name().unwrap_or_default();
+            input.resolve_setting(&app, "trackpad", "natural_scroll").and_then(SettingValue::as_bool).unwrap_or(false)
+        })
+    }
+}
+
+#[cfg(not(test))]
+static NATURAL_SCROLL: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+
+thread_local! {
+    static NATURAL_OVERRIDE: std::cell::Cell<Option<bool>> = const { std::cell::Cell::new(None) };
+}
+
+/// Force what [`natural_scroll`] answers on this thread, for a test that
+/// drives a value control with a finger; `None` lifts it. Thread-local,
+/// because a suite runs its tests in parallel and a process-wide override
+/// set by one would race every other test's read.
+pub fn force_natural_scroll(natural: Option<bool>) {
+    NATURAL_OVERRIDE.with(|f| f.set(natural));
+}
+
 /// This app's effective wheel-delta multipliers, resolved once per process.
 /// Pixel (smooth) deltas scale by `trackpad`, discrete clicks by `mouse`.
 #[derive(Debug, Clone, Copy, PartialEq)]
diff --git a/src/widget/core.rs b/src/widget/core.rs
index 3169a47..9185e5d 100644
--- a/src/widget/core.rs
+++ b/src/widget/core.rs
@@ -853,7 +853,7 @@ pub mod context_menu {
                 self.scroll_by(-delta.notches_y() * ROW_H);
                 return true;
             };
-            self.wheel_accum += delta.notches_y();
+            self.wheel_accum += delta.value_notches_y();
             let whole = self.wheel_accum.trunc();
             if whole == 0.0 {
                 return false;
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 528725a..b377960 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -438,8 +438,9 @@ impl Input for Slider {
                     let latched = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(ectx.id);
                     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 * self.notch_step()).clamp(0.0, 1.0);
+                        // Up is more, for a wheel and for a finger alike.
+                        let scroll_amount = delta.value_notches_y();
+                        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
@@ -911,7 +912,7 @@ impl Input for RangeSlider {
                     let center_high = x + self.value_high * range + thumb_size / 2.0;
                     let dist_low = (px - center_low).abs();
                     let dist_high = (px - center_high).abs();
-                    let scroll_amount = delta.notches_y();
+                    let scroll_amount = delta.value_notches_y();
                     let step = 0.02;
                     let adjust_low = if dist_low < dist_high {
                         true
@@ -921,12 +922,12 @@ impl Input for RangeSlider {
                         scroll_amount > 0.0
                     };
                     if adjust_low {
-                        let new_val = (self.value_low - scroll_amount * step).clamp(0.0, self.value_high);
+                        let new_val = (self.value_low + scroll_amount * step).clamp(0.0, self.value_high);
                         if (new_val - self.value_low).abs() > 0.0001 {
                             self.value_low = new_val;
                         }
                     } else {
-                        let new_val = (self.value_high - scroll_amount * step).clamp(self.value_low, 1.0);
+                        let new_val = (self.value_high + scroll_amount * step).clamp(self.value_low, 1.0);
                         if (new_val - self.value_high).abs() > 0.0001 {
                             self.value_high = new_val;
                         }
@@ -1095,7 +1096,7 @@ fn probe_slider_bridge() {
             10.0,
             &mut ctx,
         ));
-        assert!(sl.inner().value() < before, "scroll up decreases value");
+        assert!(sl.inner().value() > before, "a wheel notch up is more");
         assert!(sl.take_change());
     }
 
@@ -1116,7 +1117,7 @@ fn probe_slider_bridge() {
             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));
+            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;
diff --git a/src/widget/input/spinbox.rs b/src/widget/input/spinbox.rs
index f31172c..cfdc2c9 100644
--- a/src/widget/input/spinbox.rs
+++ b/src/widget/input/spinbox.rs
@@ -507,7 +507,7 @@ impl Input for Spinbox {
                 // 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();
+                self.wheel_accum += delta.value_notches_y();
                 while self.wheel_accum >= 1.0 {
                     self.wheel_accum -= 1.0;
                     self.step_by(1);
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index 5020571..9c9235f 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -43,6 +43,30 @@ impl MouseScrollDelta {
             MouseScrollDelta::PixelDelta(pos) => (pos.y as f32) / 60.0,
         }
     }
+
+    /// The notches a VALUE control takes, "up is more": a wheel notch up
+    /// is positive, and a finger's travel is positive when the fingers
+    /// went UP — which under natural scrolling is the negative of the
+    /// pixel delta, since that delta is what a list scrolls by and a
+    /// natural list moves its content the way the fingers went. Until
+    /// 2026-09-30 every value control read `notches_y` and each had picked
+    /// a sign: the slider was right for a natural trackpad and backwards
+    /// for a wheel, the spinbox and the menu and palette sliders the other
+    /// way round.
+    pub fn value_notches_y(&self) -> f32 {
+        self.value_notches_of(crate::input::natural_scroll())
+    }
+
+    /// [`Self::value_notches_y`] for a given natural-scroll setting.
+    pub fn value_notches_of(&self, natural: bool) -> f32 {
+        match self {
+            MouseScrollDelta::LineDelta(_x, y) => *y,
+            MouseScrollDelta::PixelDelta(pos) => {
+                let n = (pos.y as f32) / 60.0;
+                if natural { -n } else { n }
+            }
+        }
+    }
 }
 
 #[derive(Debug, Clone, PartialEq, Eq, Hash)]
@@ -776,3 +800,29 @@ pub fn match_key_shortcut(event: &KeyEvent, shortcut_str: &str) -> bool {
     false
 }
 
+
+#[cfg(test)]
+mod value_notch_tests {
+    use super::{MouseScrollDelta, Position};
+
+    /// A value control reads "up is more": a wheel notch up is positive
+    /// either way; a finger's pixel delta is taken as it comes with natural
+    /// scrolling off, and negated with it on, since that delta is what a
+    /// list scrolls by and a natural list follows the fingers.
+    #[test]
+    fn a_value_control_reads_up_as_more_on_a_wheel_and_a_natural_finger() {
+        let wheel_up = MouseScrollDelta::LineDelta(0.0, 1.0);
+        let finger = MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -60.0 });
+        assert_eq!(wheel_up.value_notches_of(false), 1.0);
+        assert_eq!(wheel_up.value_notches_of(true), 1.0);
+        assert_eq!(finger.value_notches_of(false), -1.0, "natural off: the delta as it comes");
+        assert_eq!(finger.value_notches_of(true), 1.0, "natural on: the fingers went up, so more");
+        // Under `cfg(test)` the toolkit's own suite reads natural as off,
+        // unless a thread forces it.
+        assert_eq!(finger.value_notches_y(), -1.0);
+        crate::input::force_natural_scroll(Some(true));
+        assert_eq!(finger.value_notches_y(), 1.0);
+        crate::input::force_natural_scroll(None);
+        assert_eq!(finger.value_notches_y(), -1.0);
+    }
+}