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