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

commite2ec2795bacf7d32c60a7df66ca374ad4203f413
parent30a5d213d4
authorLucas Galante <lsgalante12@gmail.com>
date2026-09-28 23:35
fix: a trackpad gesture turns the slider or float3 band it begins on

The params pane kept every finger gesture for its own scroll, so a slider
could be turned by a wheel notch and not by a trackpad; spinboxes got an
exception earlier today, and this is the same rule for the rest of the
value controls. A gesture belongs to the control it BEGINS on — a slider
and each row of a float3 by the band's halo, a spinbox by its row — and
to the pane otherwise; the latched control is asked first, so a gesture
the pane or another control owns never lands on a band sliding under the
pointer. The rule it replaces was written for the designer's Settings
list, which stopped being a ParametersBg on 2026-09-24.

Where two zones hold the pointer the NEAREST band takes it. A halo
reaches past its band by more than the gap between rows, and the first
row in order used to win: a scroll over a float3's Y band turned X, with
a wheel as with a trackpad.

Also: the motion switch's test note is a plain comment, not a doc comment
on a macro invocation, which rustc warned about.

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

 CLAUDE.md                             |  17 +++
 src/motion.rs                         |  18 +--
 src/widget/container/parameters_bg.rs | 211 +++++++++++++++++++++-------------
 src/widget/display/float3.rs          |  54 ++++++---
 4 files changed, 197 insertions(+), 103 deletions(-)

diff --git a/CLAUDE.md b/CLAUDE.md
index 10d9818..4c48f1d 100644
--- a/CLAUDE.md
+++ b/CLAUDE.md
@@ -946,3 +946,20 @@ radius included, not just the ones it is about; and a test that RELOADS a
 knob reloads its default back before the empty reload, and asserts the
 globals are back. `test_color_state_lock` orders the reloaders against
 each other; it cannot undo what one of them left behind.
+
+### A scroll gesture in the params pane belongs to what it begins on
+
+`ParametersBg`'s wheel arm gives a gesture to the VALUE CONTROL it begins
+on and to the pane otherwise, for a wheel notch and a trackpad finger
+gesture alike: a slider and each row of a float3 by the band's halo
+(`Slider::scroll_hit`), a spinbox by its row. The control that acquired it
+keeps it until the gesture ends (`scroll_initiate_widget_id`, 250 ms of
+quiet), so the latched control is asked first and a gesture the pane or
+another control owns never lands on a band that slides under the pointer.
+From 2026-09-21 to 2026-09-28 every FINGER gesture was the pane's from
+anywhere, on the reasoning that a pane of mostly controls was scrollable
+only from a label; that reasoning was about the designer's Alt+D Settings
+list, which stopped being a `ParametersBg` on 2026-09-24, and what the
+rule left behind was sliders a trackpad could not turn. The pane still
+scrolls from the label column, the gaps between bands, and every row that
+is not a value control.
diff --git a/src/motion.rs b/src/motion.rs
index 4153f63..51a3f6f 100644
--- a/src/motion.rs
+++ b/src/motion.rs
@@ -43,15 +43,15 @@ pub fn read_state() -> Option<bool> {
 
 static CACHE: Mutex<Option<(Instant, bool)>> = Mutex::new(None);
 
-/// Under `cfg(test)` the switch is the SUITE's, not the machine's: on,
-/// unless a test forces it with [`force_for_test`]. Until 2026-09-28
-/// `enabled` read `/run/cce/animations` in the test binary too, so three
-/// glide and fade tests passed or failed with the laptop's power mode —
-/// off on battery, on when plugged in — and read as a broken glide rather
-/// than a borrowed switch. Thread-local rather than the shared cache,
-/// because libtest runs tests in parallel and a process-wide override set
-/// by one test would race every other test's read; a test that forces it
-/// does so for its own thread only, and the value resets with the thread.
+// Under `cfg(test)` the switch is the SUITE's, not the machine's: on,
+// unless a test forces it with [`force_for_test`]. Until 2026-09-28
+// `enabled` read `/run/cce/animations` in the test binary too, so three
+// glide and fade tests passed or failed with the laptop's power mode —
+// off on battery, on when plugged in — and read as a broken glide rather
+// than a borrowed switch. Thread-local rather than the shared cache,
+// because libtest runs tests in parallel and a process-wide override set
+// by one test would race every other test's read; a test that forces it
+// does so for its own thread only, and the value resets with the thread.
 #[cfg(test)]
 thread_local! {
     static FORCED: std::cell::Cell<bool> = const { std::cell::Cell::new(true) };
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 2897682..571bdce 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -3151,38 +3151,79 @@ impl Input for ParametersBg {
                         crate::widget::ScrollPhase::Finger | crate::widget::ScrollPhase::FingerEnd
                     );
                 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;
+                // The exception to the finger rule: a VALUE control takes the
+                // gesture that BEGINS on it, trackpad included, and keeps it
+                // until the gesture ends — a spinbox by its row, a slider
+                // and each row of a float3 by the band's own halo
+                // (`Slider::scroll_hit`), the same zones a wheel notch
+                // acquires by. A two-finger scroll is aimed like a wheel:
+                // over a band it is the value being turned, anywhere else
+                // (the label column, the gaps, a toggle, a section header)
+                // the pane being scrolled. Spinboxes first (2026-09-28,
+                // earlier the same day), sliders and float3 rows after: the
+                // slider's wheel arm was written for trackpad streams all
+                // along, and the pane's rule kept every one of them from
+                // reaching it. A gesture the pane or another control
+                // acquired never lands on a control sliding under the
+                // pointer — that is the leak the latch exists to stop — so
+                // the LATCHED control is asked first, and only a gesture
+                // nobody owns is open to the control under the pointer.
+                let owner = {
+                    let latched_to = |id| !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(id);
+                    let in_row = |r: (f32, f32, f32, f32)| {
+                        py >= r.1 && py <= r.1 + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width
                     };
-                    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());
+                    let n = self.display_params.len();
+                    let latched = (0..n).find(|&i| {
+                        self.spinboxes[i].as_ref().is_some_and(|sb| latched_to(sb.base().id()))
+                            || self.sliders[i].as_ref().is_some_and(|sl| latched_to(sl.base().id()))
+                            || self.float3s[i]
+                                .as_ref()
+                                .is_some_and(|f| f.sliders().iter().any(|sl| latched_to(sl.base().id())))
+                    });
+                    // Under the pointer: the NEAREST control whose zone
+                    // holds it, by the distance to the band's (or the
+                    // spinbox row's) centre line. A band's halo reaches
+                    // past the band by more than the gap between rows, so
+                    // two neighbours' zones hold any point between them,
+                    // and the first in row order is the wrong answer for
+                    // the lower half of that gap.
+                    latched.or_else(|| {
+                        if pane_owns {
+                            return None;
+                        }
+                        (0..n)
+                            .filter_map(|i| {
+                                if rects[i].3 <= 0.0 {
+                                    return None;
+                                }
+                                if self.spinboxes[i].is_some() {
+                                    let r = rects[i];
+                                    return in_row(r).then(|| (i, (py - (r.1 + r.3 * 0.5)).abs()));
+                                }
+                                if let Some(sl) = self.sliders[i].as_ref() {
+                                    let (sx, sy, sw, sh) = sl.rect();
+                                    let ty = sl.label_strip();
+                                    let band = Rect { x: sx, y: sy + ty, width: sw, height: sh - ty };
+                                    return sl
+                                        .inner()
+                                        .scroll_hit(band, px, py)
+                                        .then(|| (i, (py - (band.y + band.height * 0.5)).abs()));
+                                }
+                                self.float3s[i].as_ref().and_then(|f| f.nearest_band(px, py)).map(|(_, d)| (i, d))
+                            })
+                            .min_by(|a, b| a.1.total_cmp(&b.1))
+                            .map(|(i, _)| i)
+                    })
+                };
+                let pane_takes = pane_owns || (finger && owner.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) {
+                    // A gesture a control owns is nobody else's: the pointer
+                    // may have drifted onto another band's halo since it began.
+                    if owner.is_some() && owner != Some(i) {
                         continue;
                     }
                     if p.2.starts_with("slider") {
@@ -3190,22 +3231,12 @@ impl Input for ParametersBg {
                         // (`Slider::scroll_hit` — the band plus the traveling
                         // swell, inset), so scrolls off the shape fall through
                         // to the pane's viewport scroll below.
-                        let in_zone = self.sliders[i].as_ref().map_or(false, |s| {
-                            // The same gesture latch the slider's own wheel
-                            // test applies: mid-gesture the slider that
-                            // acquired the scroll keeps it (its halo travels
-                            // away from the pointer as the value moves).
-                            let latched = !ui.scroll_gesture_new
-                                && ui.scroll_initiate_widget_id == Some(s.base().id());
-                            let (sx, sy, sw, sh) = s.rect();
-                            let ty = s.label_strip();
-                            latched
-                                || s.inner().scroll_hit(
-                                    Rect { x: sx, y: sy + ty, width: sw, height: sh - ty },
-                                    px,
-                                    py,
-                                )
-                        });
+                        // `owner` made the zone test: this slider is latched
+                        // (mid-gesture the slider that acquired the scroll
+                        // keeps it — its halo travels away from the pointer
+                        // as the value moves), or its band is the nearest
+                        // whose halo holds the pointer.
+                        let in_zone = owner == Some(i);
                         if in_zone {
                             if let Some(s) = &mut self.sliders[i] {
                                 let was_scroll = s.scroll_enabled;
@@ -3228,8 +3259,7 @@ impl Input for ParametersBg {
                             }
                         }
                     } else if p.2.starts_with("float3") {
-                        let r = rects[i];
-                        if py >= r.1 - 2.0 && py <= r.1 + r.3 && px >= self.rect.x && px <= self.rect.x + self.rect.width {
+                        if owner == Some(i) {
                             // The group's rows apply the slider-row contract
                             // themselves (band halo / gesture latch, else the
                             // row strip; ungated forward).
@@ -3245,11 +3275,11 @@ impl Input for ParametersBg {
                             }
                         }
                     } else if p.2.starts_with("spinbox") {
-                        if spin_target == Some(i) {
+                        if owner == 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
+                                // Ungated — `owner` already tested the
                                 // row, and a latched gesture may have
                                 // drifted off it.
                                 wheel_taken = true;
@@ -4484,11 +4514,15 @@ mod tests {
         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane still owns it");
     }
 
-    /// A trackpad gesture is the pane's from anywhere — even beginning on a
-    /// slider band, and even in a pane whose content fits — while a wheel
-    /// notch on the band still adjusts the slider.
+    /// A trackpad gesture belongs to what it BEGINS on: over a slider's band
+    /// it turns the slider — in a pane that overflows and in one that fits
+    /// alike — and keeps turning it as the pointer drifts; beginning on the
+    /// label column it scrolls the pane, and stays the pane's as bands pass
+    /// under the pointer. A float3's rows are three such bands. (Until
+    /// 2026-09-28 every finger gesture was the pane's, from anywhere, and a
+    /// slider could be turned by a wheel notch but not by a trackpad.)
     #[test]
-    fn finger_gesture_goes_to_the_pane_even_from_a_control() {
+    fn a_finger_gesture_belongs_to_the_control_it_begins_on() {
         use crate::widget::{scroll_motion::set_scroll_phase, Position, ScrollPhase};
         let rows = |n: usize| -> Vec<(String, String, String)> {
             (0..n).map(|i| (format!("P{i}"), "1.00".to_string(), "slider:0:2".to_string())).collect()
@@ -4499,49 +4533,72 @@ mod tests {
             WidgetHost::set_rect(&mut p, 0.0, 0.0, 300.0, h);
             p
         };
+        // A point on row 1's band: the slider's own rect, below its 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 s = p.sliders[1].as_ref().unwrap();
+            let (sx, sy, sw, sh) = s.rect();
+            let ty = s.label_strip();
+            (sx + sw * 0.3, sy + ty + (sh - ty) * 0.5)
         };
         let values = |p: &Adapted<ParametersBg>| -> Vec<String> { p.display_params.iter().map(|d| d.1.clone()).collect() };
-
-        // Overflowing pane, finger gesture starting ON the band: the pane scrolls, the slider holds.
+        let finger = |dy: f64| MouseScrollDelta::PixelDelta(Position { x: 0.0, y: dy });
+
+        for (n, h) in [(30, 200.0), (3, 600.0)] {
+            // Beginning ON the band: the slider turns, and owns the gesture.
+            let mut ctx = UiContext::new();
+            let mut p = panel(n, h);
+            let (bx, by) = band_point(&p);
+            ctx.scroll_gesture_new = true;
+            set_scroll_phase(ScrollPhase::Finger);
+            assert!(p.mouse_wheel(&finger(-60.0), bx, by, &mut ctx));
+            let slider_id = p.sliders[1].as_ref().unwrap().base().id();
+            assert_ne!(values(&p)[1], "1.00", "a finger gesture on the band turns the slider ({n} rows)");
+            assert_eq!(ctx.scroll_initiate_widget_id, Some(slider_id), "the slider owns the gesture");
+            assert_eq!(p.scroll_y, 0.0, "the pane did not scroll");
+            // Still its gesture with the pointer drifted onto the next band.
+            let turned = values(&p)[1].clone();
+            let (_, sy2, _, sh2) = p.sliders[2].as_ref().unwrap().rect();
+            ctx.scroll_gesture_new = false;
+            assert!(p.mouse_wheel(&finger(-60.0), bx, sy2 + sh2 * 0.7, &mut ctx));
+            assert_ne!(values(&p)[1], turned, "latched: the first slider keeps turning");
+            assert_eq!(values(&p)[2], "1.00", "the band under the drifted pointer holds");
+        }
+
+        // Beginning on the label column: the pane scrolls and owns the
+        // gesture, and a band passing under the pointer adjusts nothing.
         let mut ctx = UiContext::new();
         let mut p = panel(30, 200.0);
-        assert!(p.content_h > 200.0);
-        let (bx, by) = band_point(&p);
+        let (_, by) = band_point(&p);
         ctx.scroll_gesture_new = true;
         set_scroll_phase(ScrollPhase::Finger);
-        p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), bx, by, &mut ctx);
-        assert!(values(&p).iter().all(|v| v == "1.00"), "finger over a band did not adjust: {:?}", values(&p));
+        p.mouse_wheel(&finger(-30.0), ROW_X_INSET + 2.0, by, &mut ctx);
         assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took the gesture");
         assert!(p.scroll_y > 0.0, "the pane scrolled (finger tracks 1:1): {}", p.scroll_y);
-
-        // Same pane, a wheel notch on the band: the slider adjusts.
-        let mut ctx = UiContext::new();
-        let mut p = panel(30, 200.0);
         let (bx, by) = band_point(&p);
-        ctx.scroll_gesture_new = true;
-        set_scroll_phase(ScrollPhase::Wheel);
-        p.mouse_wheel(&MouseScrollDelta::LineDelta(0.0, -3.0), bx, by, &mut ctx);
-        assert_ne!(values(&p)[1], "1.00", "a wheel notch on the band adjusts the slider");
+        ctx.scroll_gesture_new = false;
+        p.mouse_wheel(&finger(-60.0), bx, by, &mut ctx);
+        assert!(values(&p).iter().all(|v| v == "1.00"), "a pane-owned gesture turns nothing: {:?}", values(&p));
 
-        // A pane whose content fits: the finger gesture is still the pane's (nothing to
-        // scroll, nothing adjusted) — the same rule whatever the node's parameter count.
+        // A float3's rows are three bands: a finger gesture on the Y row
+        // turns Y alone.
         let mut ctx = UiContext::new();
-        let mut p = panel(3, 600.0);
-        assert!(p.content_h <= 600.0);
-        let (bx, by) = band_point(&p);
+        let mut p = panel_with(&[("Name", "x", "text"), ("Offset", "0.00:0.00:0.00", "float3:-1:1")]);
+        let f = p.float3s[1].as_ref().unwrap();
+        let (rx, ry, rw, rh) = f.get_row_rects()[1];
+        let chrome = crate::widget::input::slider::Slider::readout_chrome();
+        let (bx, by) = (rx + (rw - chrome) * 0.5, ry + rh * 0.5);
         ctx.scroll_gesture_new = true;
         set_scroll_phase(ScrollPhase::Finger);
-        p.mouse_wheel(&MouseScrollDelta::PixelDelta(Position { x: 0.0, y: -30.0 }), bx, by, &mut ctx);
-        assert!(values(&p).iter().all(|v| v == "1.00"), "a finger gesture never adjusts: {:?}", values(&p));
-        assert_eq!(ctx.scroll_initiate_widget_id, Some(p.base().id()), "the pane took it");
+        assert!(p.mouse_wheel(&finger(-120.0), bx, by, &mut ctx));
+        let parts: Vec<f32> = p.display_params[1].1.split(':').map(|v| v.parse().unwrap()).collect();
+        assert_eq!((parts[0], parts[2]), (0.0, 0.0), "X and Z hold: {parts:?}");
+        assert_ne!(parts[1], 0.0, "Y turned: {parts:?}");
+        assert_eq!(p.scroll_y, 0.0);
         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
+    /// The same rule on a spinbox, whose zone is its row: 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.
diff --git a/src/widget/display/float3.rs b/src/widget/display/float3.rs
index 4424bbf..249608a 100644
--- a/src/widget/display/float3.rs
+++ b/src/widget/display/float3.rs
@@ -116,24 +116,44 @@ impl Float3 {
     /// otherwise the row's rect; a row in zone takes the wheel ungated (the halo already gated
     /// spatially, and the adapter's rect gate would clip its fringe). Returns whether a row took
     /// it, whether or not the value string ticked over.
+    ///
+    /// ONE row takes it: the latched one, else the NEAREST of the rows whose
+    /// halo holds the pointer. A halo reaches past its band by more than the
+    /// gap between rows, so two rows' halos hold any point between them —
+    /// and until 2026-09-28 the first in X/Y/Z order won, so a scroll over
+    /// the Y band turned X.
     pub fn wheel(&mut self, delta: &MouseScrollDelta, px: f32, py: f32, ui: &mut UiContext) -> bool {
-        let rows = self.get_row_rects();
-        for (s, r) in self.sliders.iter_mut().zip(rows) {
-            let rect = Rect { x: r.0, y: r.1, width: r.2, height: r.3 };
-            let latched = !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(s.base().id());
-            let in_zone = latched || s.inner().scroll_hit(rect, px, py);
-            if !in_zone {
-                continue;
-            }
-            let was_scroll = s.scroll_enabled;
-            s.set_scroll(true);
-            let taken = s.mouse_wheel_ungated(delta, px, py, ui);
-            s.set_scroll(was_scroll);
-            if taken {
-                return true;
-            }
-        }
-        false
+        let Some(i) = self.wheel_row(px, py, ui) else {
+            return false;
+        };
+        let s = &mut self.sliders[i];
+        let was_scroll = s.scroll_enabled;
+        s.set_scroll(true);
+        let taken = s.mouse_wheel_ungated(delta, px, py, ui);
+        s.set_scroll(was_scroll);
+        taken
+    }
+
+    /// The row a wheel at `(px, py)` belongs to, if any — see [`Self::wheel`] —
+    /// with its band centre's distance from the pointer for a host choosing
+    /// between this group and its neighbours.
+    pub fn wheel_row(&self, px: f32, py: f32, ui: &UiContext) -> Option<usize> {
+        let latched = self
+            .sliders
+            .iter()
+            .position(|s| !ui.scroll_gesture_new && ui.scroll_initiate_widget_id == Some(s.base().id()));
+        latched.or_else(|| self.nearest_band(px, py).map(|(i, _)| i))
+    }
+
+    /// The nearest row whose halo holds the pointer, with the distance from
+    /// the pointer to that band's centre line.
+    pub fn nearest_band(&self, px: f32, py: f32) -> Option<(usize, f32)> {
+        self.get_row_rects()
+            .into_iter()
+            .enumerate()
+            .filter(|(i, r)| self.sliders[*i].inner().scroll_hit(Rect { x: r.0, y: r.1, width: r.2, height: r.3 }, px, py))
+            .map(|(i, r)| (i, (py - (r.1 + r.3 * 0.5)).abs()))
+            .min_by(|a, b| a.1.total_cmp(&b.1))
     }
 }