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