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

commitc04b851592174fa14e114ced1c3a34f7966355bb
parent77ea4968e4
authorLucas Galante <[email protected]>
date2026-07-31 10:36
fix: slider wheel scrolling glides like the DE's scroll surfaces; param rows own their wheel

Two halves of the choppy trackpad feel on ParametersBg sliders:

- The pane's viewport-scroll fallback was gated on the row's 2-decimal
  STRING changing, so every sub-tick trackpad event scrolled the pane
  instead — a slider-vs-pane tug-of-war that shifted the rows under the
  pointer mid-adjust. A value row (slider/float3/spinbox) that takes the
  wheel now swallows the event whether or not its readout ticked.

- Slider adopts the Ramp hover-scroll idiom: wheel events feed an EMA
  velocity estimate, and once the stream stops (>60ms) tick() coasts the
  value with exponential decay — killed by a grab, an edit, or hitting
  either end. ParametersBg ticks its slider rows and folds coasting
  values back into display_params, so hosts apply glide changes exactly
  like live wheel input (the designer already syncs param ticks).

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

 src/widget/container/parameters_bg.rs | 30 ++++++++++++++++--
 src/widget/input/slider.rs            | 57 +++++++++++++++++++++++++++++++++++
 2 files changed, 85 insertions(+), 2 deletions(-)

diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index 1beea9d..c998325 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -1532,6 +1532,22 @@ impl Input for ParametersBg {
                 }
             }
         }
+        // Slider rows tick their wheel-glide inertia — fold a coasting value
+        // back into the row string so hosts syncing off display_params apply
+        // it, exactly like a live wheel event would.
+        for i in 0..self.sliders.len() {
+            if let Some(s) = &mut self.sliders[i] {
+                if s.tick(dt, &mut dummy) {
+                    let (min, max) = parse_slider_range(&self.display_params[i].2);
+                    let new_val = min + s.value * (max - min);
+                    let new_val_str = format!("{:.2}", new_val);
+                    if self.display_params[i].1 != new_val_str {
+                        self.display_params[i].1 = new_val_str;
+                    }
+                    changed = true;
+                }
+            }
+        }
         // Ramp rows tick their field widgets (preset application, slider→key
         // sync) and drain their change flag — fold the curve back into the row
         // value when it moved.
@@ -2179,6 +2195,13 @@ impl Input for ParametersBg {
                     return false;
                 };
                 let mut changed = false;
+                // A value row that took the wheel (slider/float3/spinbox),
+                // whether or not its 2-decimal string ticked over. Gating the
+                // pane's viewport-scroll fallback on the STRING (`changed`)
+                // let every sub-tick trackpad event scroll the pane instead —
+                // a slider-vs-pane tug-of-war that shifted the rows under the
+                // pointer mid-adjust.
+                let mut wheel_taken = false;
                 let rects = self.get_param_rects();
                 for (i, p) in self.display_params.iter_mut().enumerate() {
                     if p.2.starts_with("slider") {
@@ -2220,6 +2243,7 @@ impl Input for ParametersBg {
                                 // spatially, and the adapter's rect gate would
                                 // clip the halo's fringe outside the row rect.
                                 if s.mouse_wheel_ungated(delta, px, py, ui) {
+                                    wheel_taken = true;
                                     let (min, max) = parse_slider_range(&p.2);
                                     let new_val = min + s.value * (max - min);
                                     let old_val = &p.1;
@@ -2241,6 +2265,7 @@ impl Input for ParametersBg {
                                 for j in 0..3 {
                                     let r_inner = rects_inner[j];
                                     if py >= r_inner.1 && py <= r_inner.1 + r_inner.3 {
+                                        wheel_taken = true;
                                         let scroll_amount = delta.notches_y();
                                         let step = 0.02;
                                         let new_val = (f.values[j] - scroll_amount * step).clamp(0.0, 1.0);
@@ -2269,6 +2294,7 @@ impl Input for ParametersBg {
                         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 let Some(sb) = &mut self.spinboxes[i] {
+                                wheel_taken = true;
                                 let scroll_amount = match delta {
                                     MouseScrollDelta::LineDelta(_x, y) => *y as i32,
                                     MouseScrollDelta::PixelDelta(pos) => {
@@ -2289,7 +2315,7 @@ impl Input for ParametersBg {
 
                 // The legacy tail's `self.hit_test(px, py, ctx)`: occlusion via the adapter's
                 // address, then rect-or-popover containment.
-                let mut swallowed = changed;
+                let mut swallowed = changed || wheel_taken;
                 if !ui.is_coordinate_covered(self_id, px, py) {
                     let in_rect = px >= self.rect.x
                         && px <= self.rect.x + self.rect.width
@@ -2299,7 +2325,7 @@ impl Input for ParametersBg {
                         px >= rx && px <= rx + rw && py >= ry && py <= ry + rh
                     });
                     if in_rect || in_popover {
-                        if !changed {
+                        if !changed && !wheel_taken {
                             let scroll_speed = 24.0;
                             let dy = match delta {
                                 MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 64e2346..3a597ff 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -53,6 +53,12 @@ pub struct Slider {
     /// channel, and with a transparent track color the plate itself is its
     /// floor.
     recessed: bool,
+    /// Wheel-scroll glide velocity (normalized value units/sec) and the last
+    /// wheel-event instant — the Ramp hover-scroll idiom: when the event
+    /// stream stops (fingers lifted), `tick` keeps the value coasting with
+    /// exponential decay instead of stopping dead.
+    scroll_vel: f32,
+    last_wheel: Option<std::time::Instant>,
 }
 
 impl Slider {
@@ -71,6 +77,8 @@ impl Slider {
             just_changed: false,
             label: None,
             recessed: crate::layout::control_relief(),
+            scroll_vel: 0.0,
+            last_wheel: None,
         })
     }
 
@@ -617,6 +625,9 @@ impl Input for Slider {
                         if *px >= g.track_x && *px <= g.track_x + g.track_w && *py >= g.y && *py <= g.y + g.h {
                             self.dragging = true;
                             self.drag_offset = px - thumb_x;
+                            // A grab overrides any wheel glide in flight.
+                            self.scroll_vel = 0.0;
+                            self.last_wheel = None;
                             return true;
                         }
                         false
@@ -655,7 +666,25 @@ impl Input for Slider {
                         }
                         let scroll_amount = delta.notches_y();
                         let new_val = (self.value - scroll_amount * 0.02).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
+                        // hover-scroll idiom): EMA of applied delta over
+                        // inter-event time. A leisurely wheel produces
+                        // negligible velocity (big gaps clamp to 0.1s); fast
+                        // trackpad streams build real speed. Hitting an end
+                        // stops dead — no glide pinned at the bounds.
+                        let now = std::time::Instant::now();
+                        let idt = self
+                            .last_wheel
+                            .map_or(0.1, |l| now.duration_since(l).as_secs_f32())
+                            .clamp(0.008, 0.1);
+                        self.last_wheel = Some(now);
+                        self.scroll_vel = if new_val == 0.0 || new_val == 1.0 {
+                            0.0
+                        } else {
+                            self.scroll_vel * 0.65 + (applied / idt) * 0.35
+                        };
                         return true;
                     }
                 }
@@ -713,6 +742,31 @@ impl Input for Slider {
         true
     }
 
+    /// Wheel-glide inertia: once the event stream stops (>60ms), the value
+    /// coasts on the estimated velocity with exponential decay — the same
+    /// release feel as the pane scrolls and the Ramp's hover-scroll.
+    fn tick(&mut self, dt: f32, _rect: Rect) -> bool {
+        let Some(last) = self.last_wheel else { return false };
+        if last.elapsed().as_secs_f32() <= 0.06 {
+            return false;
+        }
+        if self.scroll_vel.abs() > 0.02 && !self.dragging && !self.editing {
+            let new_val = (self.value + self.scroll_vel * dt).clamp(0.0, 1.0);
+            let moved = self.set_value_marking(new_val);
+            if new_val == 0.0 || new_val == 1.0 {
+                self.scroll_vel = 0.0;
+                self.last_wheel = None;
+            } else {
+                self.scroll_vel *= (-5.0 * dt).exp();
+            }
+            moved
+        } else {
+            self.scroll_vel = 0.0;
+            self.last_wheel = None;
+            false
+        }
+    }
+
     fn draggable(&self, _rect: Rect) -> bool {
         true
     }
@@ -721,6 +775,9 @@ impl Input for Slider {
     }
     fn drag_begin(&mut self, px: f32, _py: f32, rect: Rect) {
         self.dragging = true;
+        // A grab overrides any wheel glide in flight.
+        self.scroll_vel = 0.0;
+        self.last_wheel = None;
         let g = self.geom(rect);
         let thumb_x = g.track_x + self.value * self.value_span(&g);
         self.drag_offset = px - thumb_x;