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

commitc9d2f53e706593d2652c14c7871b1ed97ac5f66a
parentc04b851592
authorLucas Galante <[email protected]>
date2026-07-31 12:17
fix: slider scroll feel — kill the quantize snap-back, calibrate trackpad rate, add scroll tracing

Diagnosed from a CCE_SCROLL_DEBUG trace of a real trackpad session:

- ParametersBg re-seeded slider/float3 rows from the 2-decimal param
  string on every host push-back, snapping the value backward between
  wheel events and glide ticks (live jitter, oscillating glide). Rows
  now re-seed only when the incoming string differs from what the
  current value prints — external changes still apply, the echo of the
  row's own scroll does not.
- Trackpad pixel deltas convert at 60 axis units per 2% step (measured
  stream: 10-20 units/event at ~130Hz; 15/notch slammed bound-to-bound
  in ~150ms and pinned 144 events of one session at the ends).
- CCE_SCROLL_DEBUG=1 traces the wheel pipeline (runner coalescing,
  router gesture gating, slider gate/apply/glide, pane fallback) for
  future input debugging.

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

 src/backend/window_runner.rs          |  8 +++++++
 src/context.rs                        |  6 ++++++
 src/lib.rs                            |  8 +++++++
 src/widget/container/parameters_bg.rs | 40 +++++++++++++++++++++++++++--------
 src/widget/input/slider.rs            | 24 +++++++++++++++++++++
 src/widget/mod.rs                     | 17 +++++++++------
 6 files changed, 88 insertions(+), 15 deletions(-)

diff --git a/src/backend/window_runner.rs b/src/backend/window_runner.rs
index 5f35e7d..882c242 100644
--- a/src/backend/window_runner.rs
+++ b/src/backend/window_runner.rs
@@ -3458,6 +3458,14 @@ impl<A: Application> PointerHandler for EngineState<A> {
                 let v_lines = if discrete_v != 0 { discrete_v as f32 } else { coalesced_v as f32 / 10.0 };
                 MouseScrollDelta::LineDelta(-h_lines * factors.mouse as f32, -v_lines * factors.mouse as f32)
             };
+            if crate::scroll_debug() {
+                static T0: std::sync::OnceLock<std::time::Instant> = std::sync::OnceLock::new();
+                let t = T0.get_or_init(std::time::Instant::now).elapsed().as_millis();
+                eprintln!(
+                    "[scroll {t}ms] runner: coalesced=({coalesced_h:.2},{coalesced_v:.2}) discrete=({discrete_h},{discrete_v}) factors=(tp {:.2}, m {:.2}) -> {delta:?} at ({last_lx:.0},{last_ly:.0})",
+                    factors.trackpad, factors.mouse
+                );
+            }
             let mut rebuild = false;
             if let Some(ctx) = self.inner.as_mut().unwrap().ui_context_mut() {
                 ctx.ctrl_pressed = self.ctrl_pressed;
diff --git a/src/context.rs b/src/context.rs
index 952792f..36f29ec 100644
--- a/src/context.rs
+++ b/src/context.rs
@@ -136,6 +136,12 @@ impl UiContext {
                 } else {
                     self.scroll_gesture_new = false;
                 }
+                if crate::scroll_debug() {
+                    eprintln!(
+                        "[scroll] router: gap={elapsed_ms}ms new_gesture={is_new_gesture} initiator={:?}",
+                        self.scroll_initiate_widget_id
+                    );
+                }
                 self.last_scroll_time = Some(now);
             }
         }
diff --git a/src/lib.rs b/src/lib.rs
index 531afb4..adbb4c0 100644
--- a/src/lib.rs
+++ b/src/lib.rs
@@ -24,6 +24,14 @@ pub mod colors {
 pub static IS_VERTICAL: std::sync::atomic::AtomicBool = std::sync::atomic::AtomicBool::new(false);
 pub static BAR_THICKNESS: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(24);
 
+/// `CCE_SCROLL_DEBUG=1` traces the wheel pipeline to stderr: raw coalesced
+/// axis input (runner), routing decisions (ParametersBg), slider gate/value
+/// steps, and glide ticks. Diagnostic-only; checked once per process.
+pub fn scroll_debug() -> bool {
+    static ON: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
+    *ON.get_or_init(|| std::env::var_os("CCE_SCROLL_DEBUG").is_some())
+}
+
 /// Directory bundled fonts are loaded from: `$CCE_FONTS_DIR`, else `~/Dropbox/Fonts`.
 /// Resolving via `$HOME` keeps the existing location without a hardcoded username.
 pub fn fonts_dir() -> String {
diff --git a/src/widget/container/parameters_bg.rs b/src/widget/container/parameters_bg.rs
index c998325..8972034 100644
--- a/src/widget/container/parameters_bg.rs
+++ b/src/widget/container/parameters_bg.rs
@@ -2326,6 +2326,9 @@ impl Input for ParametersBg {
                     });
                     if in_rect || in_popover {
                         if !changed && !wheel_taken {
+                            if crate::scroll_debug() {
+                                eprintln!("[scroll] params: PANE-SCROLL fallback at ({px:.0},{py:.0})");
+                            }
                             let scroll_speed = 24.0;
                             let dy = match delta {
                                 MouseScrollDelta::LineDelta(_, y) => -y * scroll_speed,
@@ -2529,18 +2532,37 @@ impl ParamController for ParametersBg {
                 if Some(i) != self.focused_param && Some(i) != self.dragging_param {
                     self.display_params[i].1 = p_new.1.clone();
                     if let Some(ref mut s) = self.sliders[i] {
-                        let val = p_new.1.parse::<f32>().unwrap_or(0.0);
                         let (min, max) = parse_slider_range(&p_new.2);
-                        let t = if max - min != 0.0 {
-                            ((val - min) / (max - min)).clamp(0.0, 1.0)
-                        } else {
-                            0.0
-                        };
-                        s.set_value(t);
+                        // Idempotence guard: hosts push params straight back
+                        // after every sync, and re-seeding from the 2-decimal
+                        // string quantizes away the slider's sub-tick state —
+                        // mid-scroll that snaps the value BACKWARD between
+                        // wheel events/glide ticks (visible as jitter). Only
+                        // re-seed when the incoming string says something the
+                        // current value doesn't (a genuinely external change).
+                        let cur_str = format!("{:.2}", min + s.value * (max - min));
+                        if cur_str != p_new.1 {
+                            let val = p_new.1.parse::<f32>().unwrap_or(0.0);
+                            let t = if max - min != 0.0 {
+                                ((val - min) / (max - min)).clamp(0.0, 1.0)
+                            } else {
+                                0.0
+                            };
+                            s.set_value(t);
+                        }
                     } else if let Some(ref mut f) = self.float3s[i] {
                         let (min, max) = parse_slider_range(&p_new.2);
-                        let vals = parse_float3_value(&p_new.1, min, max);
-                        f.set_values(vals);
+                        // Same round-trip guard as the slider row.
+                        let cur_str = format!(
+                            "{:.2}:{:.2}:{:.2}",
+                            min + f.values[0] * (max - min),
+                            min + f.values[1] * (max - min),
+                            min + f.values[2] * (max - min)
+                        );
+                        if cur_str != p_new.1 {
+                            let vals = parse_float3_value(&p_new.1, min, max);
+                            f.set_values(vals);
+                        }
                     } else if let Some(ref mut sb) = self.spinboxes[i] {
                         if !sb.editing {
                             let (min, _max, _step) = parse_spinbox_range(&p_new.2);
diff --git a/src/widget/input/slider.rs b/src/widget/input/slider.rs
index 3a597ff..b8e905e 100644
--- a/src/widget/input/slider.rs
+++ b/src/widget/input/slider.rs
@@ -650,6 +650,12 @@ impl Input for Slider {
                     // initiated a gesture keeps it.
                     let band = crate::layout::slider_band();
                     if !band && !ui.scroll_gesture_new && ui.scroll_initiate_widget_id != Some(ectx.id) {
+                        if crate::scroll_debug() {
+                            eprintln!(
+                                "[scroll] slider {:?}: GATE reject (gesture_new=false, initiator={:?}, me={:?})",
+                                self.label, ui.scroll_initiate_widget_id, ectx.id
+                            );
+                        }
                         return false;
                     }
                     let r = ectx.rect;
@@ -685,8 +691,20 @@ impl Input for Slider {
                         } else {
                             self.scroll_vel * 0.65 + (applied / idt) * 0.35
                         };
+                        if crate::scroll_debug() {
+                            eprintln!(
+                                "[scroll] slider {:?}: APPLY notches={scroll_amount:.3} applied={applied:.4} value={new_val:.4} idt={idt:.3} vel={:.3}",
+                                self.label, self.scroll_vel
+                            );
+                        }
                         return true;
                     }
+                    if crate::scroll_debug() {
+                        eprintln!(
+                            "[scroll] slider {:?}: MISS scroll_hit at ({px:.0},{py:.0}) rect={:?}",
+                            self.label, ectx.rect
+                        );
+                    }
                 }
                 false
             }
@@ -753,6 +771,12 @@ impl Input for Slider {
         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 crate::scroll_debug() {
+                eprintln!(
+                    "[scroll] slider {:?}: GLIDE dt={dt:.3} vel={:.3} value={new_val:.4}",
+                    self.label, self.scroll_vel
+                );
+            }
             if new_val == 0.0 || new_val == 1.0 {
                 self.scroll_vel = 0.0;
                 self.last_wheel = None;
diff --git a/src/widget/mod.rs b/src/widget/mod.rs
index ba01b73..0dbbc76 100644
--- a/src/widget/mod.rs
+++ b/src/widget/mod.rs
@@ -27,15 +27,20 @@ pub enum MouseScrollDelta {
 }
 
 impl MouseScrollDelta {
-    /// Vertical scroll in wheel-notch equivalents. Pixel (trackpad) deltas
-    /// convert at the DE's 15-axis-units-per-notch convention (see `ccectl
-    /// pointer-scroll`: "15 = one notch") — NOT the 120-unit wheel standard,
-    /// which makes value widgets feel dead under trackpad swipes (a full
-    /// slider sweep would take ~6000px of finger travel).
+    /// Vertical scroll in wheel-notch equivalents for VALUE widgets (sliders,
+    /// float3 rows). The pixel divisor is calibrated against a measured
+    /// trackpad stream, not a notch convention: a real two-finger swipe
+    /// delivers 10–20 axis units per event at 6–8ms intervals (~2000
+    /// units/sec sustained). At 60 units per notch-equivalent (0.02 of the
+    /// range each), that sustains ~0.6 range/sec — a full sweep is a couple
+    /// of committed swipes, while slow fine-tuning events (2–5 units) move
+    /// well under one readout tick. 15 (the DE's hardware-notch unit) slams
+    /// bound-to-bound in ~150ms; 120 (the wheel standard) needs ~6000px of
+    /// finger travel per sweep.
     pub fn notches_y(&self) -> f32 {
         match self {
             MouseScrollDelta::LineDelta(_x, y) => *y,
-            MouseScrollDelta::PixelDelta(pos) => (pos.y as f32) / 15.0,
+            MouseScrollDelta::PixelDelta(pos) => (pos.y as f32) / 60.0,
         }
     }
 }