GPU-accelerated UI toolkit (Vulkan)
git clone https://git.lucas.co/cce-ui.git
fix: a spinbox row takes the wheel gesture that begins on it, trackpad included
The params pane claimed every finger gesture for its own scroll (the
2026-09-21 rule), so on a trackpad the -/+ buttons were the only pointer
way to step a Rows or Columns value; and the inline arm that served a
mouse notch truncated the notch count, so a scroll factor below 1 stepped
nothing while still swallowing the wheel.
A spinbox row now takes the gesture that BEGINS on it and keeps it until
the gesture ends, as a slider's latch does, feeding the widget's own wheel
arm, which accumulates fractional notches into whole steps (60 px of
finger travel per step). A gesture the pane or a slider acquired never
lands on a spinbox sliding under the pointer, and a spinbox-owned gesture
reaches no other row when the pointer drifts onto a slider's halo.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
src/widget/container/parameters_bg.rs | 128 ++++++++++++++++++++++++++++++----
1 file changed, 113 insertions(+), 15 deletions(-)
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 6a88c57..a7f469e 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -3041,12 +3041,41 @@ impl Input for ParametersBg {
crate::widget::scroll_motion::current_scroll_phase(),
crate::widget::ScrollPhase::Finger | crate::widget::ScrollPhase::FingerEnd
);
- let pane_takes = pane_owns || finger;
let rects = self.get_param_rects();
+ // The one exception to the finger rule: a spinbox row takes
+ // the gesture that BEGINS on it, trackpad included, and keeps
+ // it until the gesture ends — the slider's own latch. A
+ // spinbox holds a whole number, so a swipe over it is a
+ // count, not a scroll: the widget's wheel arm accumulates a
+ // finger's fractional notches into whole steps (2026-09-28;
+ // until then a two-finger scroll over a Rows or Columns row
+ // scrolled the pane, and the only way to step the value by
+ // pointer was the -/+ buttons). A gesture the pane or a
+ // slider acquired never lands on a spinbox sliding under the
+ // pointer — that is the leak the latch exists to stop.
+ let spin_target = (0..self.display_params.len()).find(|&i| {
+ let Some(sb) = self.spinboxes[i].as_ref() else {
+ return false;
+ };
+ let latched = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(sb.base().id());
+ let r = rects[i];
+ let under = r.3 > 0.0
+ && py >= r.1
+ && py <= r.1 + r.3
+ && px >= self.rect.x
+ && px <= self.rect.x + self.rect.width;
+ latched || (!pane_owns && under)
+ });
+ let pane_takes = pane_owns || (finger && spin_target.is_none());
for (i, p) in self.display_params.iter_mut().enumerate() {
if pane_takes {
break;
}
+ // A gesture a spinbox owns is nobody else's: the pointer
+ // may have drifted onto a slider's halo since it began.
+ if spin_target.is_some() && spin_target != Some(i) {
+ continue;
+ }
if p.2.starts_with("slider") {
// The capture zone is the slider's own shape halo
// (`Slider::scroll_hit` — the band plus the traveling
@@ -3107,22 +3136,19 @@ impl Input for ParametersBg {
}
}
} else if p.2.starts_with("spinbox") {
- let r = rects[i];
- let row_y = r.1;
- if py >= row_y && py <= row_y + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
+ if spin_target == Some(i) {
if let Some(sb) = &mut self.spinboxes[i] {
+ // The widget's own wheel arm: one step per
+ // notch, fractions carried between events.
+ // Ungated — `spin_target` already tested the
+ // row, and a latched gesture may have
+ // drifted off it.
wheel_taken = true;
- let scroll_amount = match delta {
- MouseScrollDelta::LineDelta(_x, y) => *y as i32,
- MouseScrollDelta::PixelDelta(pos) => {
- let dy = pos.y;
- if dy > 0.0 { 1 } else if dy < 0.0 { -1 } else { 0 }
- }
- };
- let new_val = (sb.value + scroll_amount * sb.step).clamp(sb.min, sb.max);
- if sb.value != new_val {
- sb.value = new_val;
- p.1 = new_val.to_string();
+ ui.scroll_initiate_widget_id = Some(sb.base().id());
+ sb.mouse_wheel_ungated(delta, px, py, ui);
+ let new_val_str = sb.value.to_string();
+ if p.1 != new_val_str {
+ p.1 = new_val_str;
changed = true;
}
}
@@ -4309,4 +4335,76 @@ mod tests {
assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took it");
set_scroll_phase(ScrollPhase::Wheel);
}
+
+ /// The finger rule's one exception: a gesture that begins on a spinbox
+ /// row steps the spinbox — a finger's fractional notches accumulating
+ /// into whole steps — and stays the spinbox's, while a gesture the pane
+ /// acquired still scrolls past it. A wheel notch on the row steps it too,
+ /// however the scroll factor scales the notch.
+ #[test]
+ fn a_gesture_beginning_on_a_spinbox_row_steps_it_trackpad_included() {
+ use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
+ let rows = |n: usize| -> Vec<(String, String, String)> {
+ (0..n)
+ .map(|i| if i == 1 { ("Count".to_string(), "10".to_string(), "spinbox:0:100:1".to_string()) } else { (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()) })
+ .collect()
+ };
+ let panel = |n: usize, h: f32| {
+ let mut p = ParametersBg::new();
+ ParamController::set_display_params(&mut *p, &rows(n));
+ WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, h);
+ p
+ };
+ let row_point = |p: &Adapted<ParametersBg>| {
+ let r = p.get_param_rects()[1];
+ (r.0 + r.2 * 0.3, r.1 + r.3 * 0.5)
+ };
+ let count = |p: &Adapted<ParametersBg>| p.display_params[1].1.clone();
+
+ // A finger gesture beginning on the row: 60 px of travel is one step,
+ // the spinbox owns the gesture, and the pane does not scroll.
+ let mut ctx = UiContext::new();
+ let mut p = panel(30, 200.0);
+ let (x, y) = row_point(&p);
+ ctx.scroll_gesture_new = true;
+ set_scroll_phase(ScrollPhase::Finger);
+ assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 }), x, y, &mut ctx));
+ assert_eq!(count(&p), "10", "half a notch: no step yet");
+ let sb_id = p.spinboxes[1].as_ref().unwrap().base().id();
+ assert_eq!(ctx.scroll_initiate_widget_id, Some(sb_id), "the spinbox owns the gesture");
+ ctx.scroll_gesture_new = false;
+ assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: 30.0 }), x, y, &mut ctx));
+ assert_eq!(count(&p), "11", "the second half completes the notch");
+ assert_eq!(p.scroll_y, 0.0, "the pane did not scroll");
+ // Still its gesture when the pointer drifts onto the row below.
+ let below = p.get_param_rects()[2];
+ assert!(p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -120.0 }), x, below.1 + below.3 * 0.5, &mut ctx));
+ assert_eq!(count(&p), "9", "two notches down, latched");
+ assert_eq!(p.display_params[2].1, "1.00", "the slider under the drifted pointer holds");
+
+ // A gesture the pane acquired scrolls past the row without stepping it.
+ let mut ctx = UiContext::new();
+ let mut p = panel(30, 200.0);
+ ctx.scroll_gesture_new = true;
+ set_scroll_phase(ScrollPhase::Finger);
+ p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), 2.0, 2.0, &mut ctx);
+ assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took the gesture");
+ let (x, y) = row_point(&p);
+ ctx.scroll_gesture_new = false;
+ p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -120.0 }), x, y, &mut ctx);
+ assert_eq!(count(&p), "10", "a pane-owned gesture never steps a spinbox");
+ assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()));
+
+ // A scaled wheel notch (scroll_factor 0.5) still steps, over two notches.
+ let mut ctx = UiContext::new();
+ let mut p = panel(30, 200.0);
+ let (x, y) = row_point(&p);
+ ctx.scroll_gesture_new = true;
+ set_scroll_phase(ScrollPhase::Wheel);
+ assert!(p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), x, y, &mut ctx));
+ assert_eq!(count(&p), "10");
+ ctx.scroll_gesture_new = false;
+ assert!(p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, 0.5), x, y, &mut ctx));
+ assert_eq!(count(&p), "11", "a half-notch factor is not truncated to nothing");
+ }
}