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

commit1c595619253edc5a21b288eaf90b8830e56ae2a2
parent574a573102
authorLucas Galante <[email protected]>
date2026-09-20 21:04
ParametersBg: a gesture the pane acquired is not captured by a control sliding under the pointer

Scrolling a params pane moved the rows until a slider's halo or a
spinbox row arrived under the pointer, which then took every remaining
event of the same gesture and adjusted a value the user never aimed at
(the Alt+D settings tab). The pane now claims the gesture when its first
event falls on no control (scroll_initiate_widget_id = the pane), and
while it owns one the value-row capture is skipped. A gesture that began
ON a control is still that control's; one nobody claimed stays open to
the band slider's spatial acquisition. Regression test added.

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

 src/widget/container/parameters_bg.rs | 65 +++++++++++++++++++++++++++++++++++
 1 file changed, 65 insertions(+)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 6e7fa3c..1690870 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -2581,8 +2581,23 @@ impl Input for ParametersBg {
                 // a slider-vs-pane tug-of-war that shifted the rows under the
                 // pointer mid-adjust.
                 let mut wheel_taken = false;
+                // Gesture ownership. A gesture the PANE acquired — its first
+                // event fell on no control and scrolled the rows — stays the
+                // pane's until the gesture ends (`scroll_gesture_new`), however
+                // the rows travel under the pointer meanwhile. Without this a
+                // list scroll ran until a slider's halo or a spinbox row slid
+                // under the pointer, which then took every remaining event of
+                // the same gesture and adjusted a value the user never aimed
+                // at (the Alt+D settings tab, 2026-09-20). A gesture that
+                // began ON a control is that control's, as before, and one
+                // nobody claimed (dead space, a control at its limit) is still
+                // open to spatial acquisition — the band slider's feel.
+                let pane_owns = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(self_id);
                 let rects = self.get_param_rects();
                 for (i, p) in self.display_params.iter_mut().enumerate() {
+                    if pane_owns {
+                        break;
+                    }
                     if p.2.starts_with("slider") {
                         // The capture zone is the slider's own shape halo
                         // (`Slider::scroll_hit` — the band plus the traveling
@@ -2682,6 +2697,8 @@ impl Input for ParametersBg {
                             if crate::scroll_debug() {
                                 eprintln!("[scroll] params: PANE-SCROLL fallback at ({px:.0},{py:.0})");
                             }
+                            // The pane takes the gesture (see `pane_owns`).
+                            ui.scroll_initiate_widget_id = Some(self_id);
                             let max_scroll = (self.content_h - self.rect.height).max(0.0);
                             self.scroll_motion.reconcile(0.0, self.scroll_y);
                             let moved = self.scroll_motion.apply(
@@ -3397,4 +3414,52 @@ mod tests {
         p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
         assert!(p.scroll_y >= before, "scroll never decreases on a downward wheel");
     }
+
+    /// A gesture the pane acquired stays the pane's: rows travelling under
+    /// the pointer mid-gesture must not hand the wheel to the slider that
+    /// arrives there (the Alt+D settings-tab leak, 2026-09-20). A NEW gesture
+    /// over the same slider still adjusts it.
+    #[test]
+    fn pane_owned_scroll_gesture_is_not_captured_by_a_slider_sliding_under_the_pointer() {
+        let rows: Vec<(String, String, String)> = (0..30)
+            .map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string()))
+            .collect();
+        let fresh = || {
+            let mut p = ParametersBg::new();
+            ParamController::set_display_params(&mut *p, &rows);
+            WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, 200.0);
+            p
+        };
+        // A point on a slider's band: row 1's rect, below the label strip.
+        let band_point = |p: &Adapted<ParametersBg>| {
+            let r = p.get_param_rects()[1];
+            (r.0 + r.2 * 0.5, r.1 + r.3 * 0.7)
+        };
+        let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
+
+        // Control: a new gesture ON the band adjusts the slider (the point is a real hit).
+        let mut ctx = UiContext::new();
+        let mut p = fresh();
+        let (bx, by) = band_point(&p);
+        ctx.scroll_gesture_new = true;
+        ctx.scroll_initiate_widget_id = None;
+        p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
+        assert_ne!(values(&p)[1], "1.00", "a new gesture on the band adjusts the slider");
+
+        // The case: the gesture starts on the pane (dead space), the pane owns it...
+        let mut ctx = UiContext::new();
+        let mut p = fresh();
+        ctx.scroll_gesture_new = true;
+        ctx.scroll_initiate_widget_id = None;
+        p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), 2.0, 2.0, &mut ctx);
+        // (The scroll itself is animated by scroll_motion over later ticks, so
+        // ownership — set only on the pane-scroll path — is the witness.)
+        assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane owns the gesture");
+        // ...and the same gesture continuing over a band adjusts nothing.
+        let (bx, by) = band_point(&p);
+        ctx.scroll_gesture_new = false;
+        p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
+        assert!(values(&p).iter().all(|v| v == "1.00"), "no slider took the pane's gesture: {:?}", values(&p));
+        assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane still owns it");
+    }
 }