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