Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
feat(camera): smooth desktop scrolling — dt-correct glide, kinetic finger coast, anchored wheel zoom
The camera animation tick eased pan/zoom by a fixed 0.15 per 16ms timer
fire, so a late tick under load stuttered; it now measures the elapsed time
since the last step (anim_last_tick) and applies 1 - exp(-rate*dt), the
same frame-rate-independent approach cce-ui's ScrollMotion uses, at the
same default rate (12/s) so a wheel notch over the desktop reads like one
over a list.
Finger pans still track 1:1, but the lift (libinput's zero-delta finger
event) now flings: the cursor keeps a per-axis velocity estimate from the
hardware timestamps, and on the lift — unless the finger came to rest
first — the tick coasts desk_pan under friction until it drops below a
screen pixel per frame. A wheel click or a new gesture cancels the coast.
ctrl+super wheel zoom no longer jumps per notch: each notch advances a
zoom target that the tick eases to in log space, and a stored output-local
anchor re-derives the pan every step so the point under the cursor stays
put throughout the glide, not just at its end.
Tunables in the input block, mirroring cce-ui's keys: scroll_ease,
kinetic_scroll, scroll_friction.
Verified in a headless shadow: one injected notch over the background eases
the window y 100→95→88→86→85 across ~280ms; three quick notches accumulate
into one run; the reverse returns it to 100. (ccectl pointer-scroll
hard-codes the wheel source, so the finger coast is live-session only.)
Co-Authored-By: Claude Fable 5.1 <[email protected]>
src/server/config.rs | 22 +++++++++
src/server/cursor.rs | 103 +++++++++++++++++++++++++++++--------------
src/server/window_manager.rs | 94 +++++++++++++++++++++++++++++++++++++--
3 files changed, 181 insertions(+), 38 deletions(-)
diff --git a/src/server/config.rs b/src/server/config.rs
index c3ce9ea..386dd75 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -436,6 +436,16 @@ pub struct InputConfig {
pub accel_speed: Option<f64>,
pub accel_profile: Option<String>,
pub scroll_factor: Option<f64>,
+ /// Desktop-pan smooth scrolling (the compositor's own consumption of the
+ /// wheel: background/overview/super pans and ctrl+super zoom). Same keys
+ /// and defaults as cce-ui's `widget::scroll_motion`, so a wheel notch
+ /// glides the same way over a list and over the desktop.
+ /// `scroll_ease`: wheel-glide rate, 1/s (default 12).
+ pub scroll_ease: Option<f64>,
+ /// `kinetic_scroll`: a trackpad flick keeps panning after the lift (default true).
+ pub kinetic_scroll: Option<bool>,
+ /// `scroll_friction`: coast decay, 1/s (default 6).
+ pub scroll_friction: Option<f64>,
pub mouse: Option<MouseConfig>,
pub touchpad: Option<TouchpadConfig>,
pub trackpoint: Option<TrackpointConfig>,
@@ -1780,6 +1790,9 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
let accel_speed = get_child_arg_f64_opt(node, "accel_speed");
let accel_profile = get_child_arg_string_opt(node, "accel_profile");
let scroll_factor = get_child_arg_f64_opt(node, "scroll_factor");
+ let scroll_ease = get_child_arg_f64_opt(node, "scroll_ease");
+ let kinetic_scroll = get_child_arg_bool_opt(node, "kinetic_scroll");
+ let scroll_friction = get_child_arg_f64_opt(node, "scroll_friction");
let mut touchpad = None;
if let Some(children) = node.children() {
@@ -1844,6 +1857,9 @@ fn parse_kdl_config(content: &str) -> Result<Config, String> {
accel_speed,
accel_profile,
scroll_factor,
+ scroll_ease,
+ kinetic_scroll,
+ scroll_friction,
mouse,
touchpad,
trackpoint,
@@ -2857,6 +2873,9 @@ style {
accel_profile "flat"
accel_speed (f64)1.0
scroll_factor (f64)1.0
+ scroll_ease (f64)9.5
+ kinetic_scroll (bool)false
+ scroll_friction (f64)4.0
mouse {
accel_speed (f64)0.5
scroll_factor (f64)2.0
@@ -2879,6 +2898,9 @@ style {
assert_eq!(input.accel_profile, Some("flat".to_string()));
assert_eq!(input.accel_speed, Some(1.0));
assert_eq!(input.scroll_factor, Some(1.0));
+ assert_eq!(input.scroll_ease, Some(9.5));
+ assert_eq!(input.kinetic_scroll, Some(false));
+ assert_eq!(input.scroll_friction, Some(4.0));
let mouse = input.mouse.unwrap();
assert_eq!(mouse.accel_speed, Some(0.5));
assert_eq!(mouse.scroll_factor, Some(2.0));
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index f1347a7..9193770 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -74,6 +74,11 @@ pub struct Cursor {
pub gesture_scale: f64,
pub gesture_triggered: bool,
pub panning_gesture_active: bool,
+ /// Finger-pan velocity estimate per axis (`[x, y]`, virtual units/s) and
+ /// the hardware timestamp of each axis's last finger event, for the
+ /// kinetic desktop coast on the lift.
+ pub pan_vel: [f64; 2],
+ pub pan_last_msec: [u32; 2],
/// A pinch that began over the desktop background zooms the camera
/// continuously instead of forwarding to a client or matching gesture
/// binds. Decided once at pinch begin; update/end must follow the same
@@ -150,6 +155,8 @@ impl Default for Cursor {
gesture_scale: 1.0,
gesture_triggered: false,
panning_gesture_active: false,
+ pan_vel: [0.0, 0.0],
+ pan_last_msec: [0, 0],
pinch_zoom_active: false,
pinch_start_zoom: 1.0,
last_click_time: 0,
@@ -1977,10 +1984,13 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
if (*event).orientation == ffi::wl_pointer_axis_WL_POINTER_AXIS_VERTICAL_SCROLL {
if delta != 0.0 {
let wm = &mut (*seat.server).wm;
- wm.stop_panning_animation();
- let old_zoom = wm.desk_zoom;
- let new_zoom = crate::policy::camera::wheel_zoom(old_zoom, delta);
- if new_zoom != old_zoom {
+ // Each notch advances the zoom TARGET (successive notches
+ // accumulate into one glide); the animation tick eases the
+ // zoom there in log space, pivoting about the cursor every
+ // step. A pan glide or coast in flight yields to the zoom.
+ let base = wm.target_desk_zoom.unwrap_or(wm.desk_zoom);
+ let new_zoom = crate::policy::camera::wheel_zoom(base, delta);
+ if new_zoom != base {
let cx = cursor.x();
let cy = cursor.y();
let wlr_output = (*(*seat).server).om.output_at(cx, cy);
@@ -1991,23 +2001,11 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
} else {
(0.0, 0.0)
};
- // Wheel zoom pivots about the cursor: the virtual point
- // under it stays put on screen.
- let cam = crate::policy::camera::zoom_about_anchor(
- wm.camera(),
- cx - phys_x,
- cy - phys_y,
- new_zoom,
- );
- wm.desk_pan_x = cam.pan_x;
- wm.desk_pan_y = cam.pan_y;
- wm.desk_zoom = cam.zoom;
- wm.set_mode(if crate::policy::camera::is_overview(cam.zoom) { crate::window_manager::WindowManagerMode::Overview } else { crate::window_manager::WindowManagerMode::Normal });
- if matches!(wm.state, crate::window_manager::WindowManagerState::Idle) {
- wm.update_viewport_local();
- } else {
- wm.dirty_windowing();
- }
+ wm.stop_panning_animation();
+ wm.target_desk_zoom = Some(new_zoom);
+ wm.zoom_anchor = Some((cx - phys_x, cy - phys_y));
+ wm.set_mode(if crate::policy::camera::is_overview(new_zoom) { crate::window_manager::WindowManagerMode::Overview } else { crate::window_manager::WindowManagerMode::Normal });
+ wm.start_panning_animation();
}
}
}
@@ -2050,23 +2048,60 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
let vertical =
(*event).orientation == ffi::wl_pointer_axis_WL_POINTER_AXIS_VERTICAL_SCROLL;
if is_finger {
- // Finger/continuous scroll tracks 1:1 — the surface follows the
- // gesture directly, no easing between the two.
- wm.stop_panning_animation();
- if vertical {
- wm.desk_pan_y += step;
- } else {
- wm.desk_pan_x += step;
- }
- if matches!(wm.state, crate::window_manager::WindowManagerState::Idle) {
- wm.update_viewport_local();
- } else {
- wm.dirty_windowing();
+ let axis = if vertical { 1 } else { 0 };
+ let now_ms = (*event).time_msec;
+ if delta != 0.0 {
+ // Finger/continuous scroll tracks 1:1 — the surface follows
+ // the gesture directly, no easing between the two — while a
+ // velocity estimate is kept for the coast on the lift.
+ wm.stop_panning_animation();
+ if vertical {
+ wm.desk_pan_y += step;
+ } else {
+ wm.desk_pan_x += step;
+ }
+ let dt_ms = now_ms.wrapping_sub(cursor.pan_last_msec[axis]).clamp(4, 100) as f64;
+ let sample = step / (dt_ms / 1000.0);
+ cursor.pan_vel[axis] = if cursor.pan_last_msec[axis] == 0 {
+ sample
+ } else {
+ cursor.pan_vel[axis] * 0.65 + sample * 0.35
+ };
+ cursor.pan_last_msec[axis] = now_ms;
+ if matches!(wm.state, crate::window_manager::WindowManagerState::Idle) {
+ wm.update_viewport_local();
+ } else {
+ wm.dirty_windowing();
+ }
+ } else if was_panning {
+ // The lift (libinput's zero-delta finger event): fling on the
+ // estimated velocity unless the finger had come to rest first
+ // or kinetic scrolling is off. Both axes launch together on
+ // the first lift event; the second axis's lift finds them
+ // already cleared.
+ let mut vx = cursor.pan_vel[0];
+ let mut vy = cursor.pan_vel[1];
+ for (a, v) in [(0usize, &mut vx), (1usize, &mut vy)] {
+ let rest_ms = now_ms.wrapping_sub(cursor.pan_last_msec[a]);
+ if cursor.pan_last_msec[a] == 0 || rest_ms > 80 {
+ *v = 0.0;
+ }
+ }
+ cursor.pan_vel = [0.0, 0.0];
+ cursor.pan_last_msec = [0, 0];
+ if wm.kinetic_scroll() && (vx != 0.0 || vy != 0.0) {
+ wm.pan_coast_vx = vx;
+ wm.pan_coast_vy = vy;
+ wm.start_panning_animation();
+ }
}
} else {
// Discrete wheel clicks glide: each click advances the pan
// animation target, so successive clicks accumulate into one
- // smooth run instead of a stutter of jumps.
+ // smooth run instead of a stutter of jumps. A coast in flight
+ // yields to the click.
+ wm.pan_coast_vx = 0.0;
+ wm.pan_coast_vy = 0.0;
if vertical {
let base = wm.target_desk_pan_y.unwrap_or(wm.desk_pan_y);
wm.target_desk_pan_y = Some(base + step);
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index e9bd5b2..ab9ac1e 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -160,6 +160,19 @@ pub struct WindowManager {
/// `desktop { overview_ramp= overview_ms= }` speed profile. When active
/// it owns the camera; the exponential targets above stay clear.
pub camera_ramp_anim: Option<CameraRampAnim>,
+ /// When the camera animation last stepped — the exponential eases below
+ /// are frame-rate independent, so a late timer tick takes a
+ /// proportionally larger step instead of a stutter. `None` while the
+ /// timer is idle, so the first step after arming measures from the arm.
+ pub anim_last_tick: Option<std::time::Instant>,
+ /// Kinetic desktop pan after a trackpad flick: virtual units/s, decayed
+ /// by `input.scroll_friction` each tick until it stalls. Zero = no coast.
+ pub pan_coast_vx: f64,
+ pub pan_coast_vy: f64,
+ /// Output-local point a `target_desk_zoom` glide pivots on (ctrl+super
+ /// wheel zoom): each step re-derives the pan so the virtual point under
+ /// the cursor stays put throughout, not just at the end.
+ pub zoom_anchor: Option<(f64, f64)>,
pub animation_timer: *mut ffi::wl_event_source,
/// Edge auto-pan velocity during an interactive move/resize, in SCREEN
/// px/s (the tick divides by zoom). Written by `Seat::update_edge_pan`
@@ -342,6 +355,10 @@ impl WindowManager {
self.target_desk_pan_y = None;
self.target_desk_zoom = None;
self.camera_ramp_anim = None;
+ self.anim_last_tick = None;
+ self.pan_coast_vx = 0.0;
+ self.pan_coast_vy = 0.0;
+ self.zoom_anchor = None;
self.animation_timer = std::ptr::null_mut();
self.edge_pan_vx = 0.0;
self.edge_pan_vy = 0.0;
@@ -1234,6 +1251,30 @@ impl WindowManager {
self.target_desk_pan_y = None;
self.target_desk_zoom = None;
self.camera_ramp_anim = None;
+ self.pan_coast_vx = 0.0;
+ self.pan_coast_vy = 0.0;
+ self.zoom_anchor = None;
+ }
+
+ /// Wheel-glide rate for the desktop camera, 1/s (`input { scroll_ease }`).
+ pub fn scroll_ease_rate(&self) -> f64 {
+ self.input_config
+ .scroll_ease
+ .filter(|v| v.is_finite() && *v > 0.0)
+ .unwrap_or(12.0)
+ }
+
+ /// Whether a trackpad flick coasts the desktop (`input { kinetic_scroll }`).
+ pub fn kinetic_scroll(&self) -> bool {
+ self.input_config.kinetic_scroll.unwrap_or(true)
+ }
+
+ /// Coast decay, 1/s (`input { scroll_friction }`).
+ pub fn scroll_friction(&self) -> f64 {
+ self.input_config
+ .scroll_friction
+ .filter(|v| v.is_finite() && *v > 0.0)
+ .unwrap_or(6.0)
}
/// The current camera as the policy crate's plain-data snapshot.
@@ -1423,6 +1464,9 @@ impl WindowManager {
);
}
if !self.animation_timer.is_null() {
+ if self.anim_last_tick.is_none() {
+ self.anim_last_tick = Some(std::time::Instant::now());
+ }
ffi::wl_event_source_timer_update(self.animation_timer, 16);
}
}
@@ -5586,7 +5630,17 @@ pub(crate) unsafe extern "C" fn handle_panning_animation_tick(data: *mut std::ff
}
let mut done = true;
- let factor = 0.15;
+ // Frame-rate independent exponential approach: the same fraction of the
+ // remaining distance per unit time whatever the timer's actual cadence
+ // (a 16ms timer under load fires late; the old fixed 0.15/tick then
+ // stuttered). dt is clamped so a stalled loop cannot leap.
+ let now = std::time::Instant::now();
+ let dt = (*wm)
+ .anim_last_tick
+ .map_or(0.016, |t| now.duration_since(t).as_secs_f64())
+ .clamp(0.0, 0.1);
+ (*wm).anim_last_tick = Some(now);
+ let factor = 1.0 - (-(*wm).scroll_ease_rate() * dt).exp();
// Ramp-driven transition: position is a pure function of elapsed time,
// so a stalled tick (busy frame) never changes where the camera lands.
@@ -5641,12 +5695,42 @@ pub(crate) unsafe extern "C" fn handle_panning_animation_tick(data: *mut std::ff
if let Some(target_zoom) = (*wm).target_desk_zoom {
let cur = (*wm).desk_zoom.max(1e-6);
let log_delta = (target_zoom / cur).ln();
- if log_delta.abs() > 0.001 {
- (*wm).desk_zoom = cur * (log_delta * factor).exp();
+ let new_zoom = if log_delta.abs() > 0.001 {
done = false;
+ cur * (log_delta * factor).exp()
} else {
- (*wm).desk_zoom = target_zoom;
(*wm).target_desk_zoom = None;
+ target_zoom
+ };
+ // An anchored zoom (wheel zoom about the cursor) re-derives the pan
+ // from the anchor every step, so the pivot never wanders mid-glide.
+ if let Some((ax, ay)) = (*wm).zoom_anchor {
+ let cam = crate::policy::camera::zoom_about_anchor((*wm).camera(), ax, ay, new_zoom);
+ (*wm).desk_pan_x = cam.pan_x;
+ (*wm).desk_pan_y = cam.pan_y;
+ (*wm).desk_zoom = cam.zoom;
+ if (*wm).target_desk_zoom.is_none() {
+ (*wm).zoom_anchor = None;
+ }
+ } else {
+ (*wm).desk_zoom = new_zoom;
+ }
+ }
+
+ // Kinetic pan: a trackpad flick's velocity carries the desktop on,
+ // decaying under friction; it stalls below one screen pixel per frame.
+ if (*wm).pan_coast_vx != 0.0 || (*wm).pan_coast_vy != 0.0 {
+ (*wm).desk_pan_x += (*wm).pan_coast_vx * dt;
+ (*wm).desk_pan_y += (*wm).pan_coast_vy * dt;
+ let decay = (-(*wm).scroll_friction() * dt).exp();
+ (*wm).pan_coast_vx *= decay;
+ (*wm).pan_coast_vy *= decay;
+ let screen_speed = ((*wm).pan_coast_vx.hypot((*wm).pan_coast_vy)) * (*wm).desk_zoom;
+ if screen_speed < 5.0 {
+ (*wm).pan_coast_vx = 0.0;
+ (*wm).pan_coast_vy = 0.0;
+ } else {
+ done = false;
}
}
@@ -5660,6 +5744,8 @@ pub(crate) unsafe extern "C" fn handle_panning_animation_tick(data: *mut std::ff
if !(*wm).animation_timer.is_null() {
ffi::wl_event_source_timer_update((*wm).animation_timer, 16);
}
+ } else {
+ (*wm).anim_last_tick = None;
}
0
}