git.lucas.co / cce-compositor
Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git

commit4dc634a5a6c1328586f1f0221550963edff2593c
parentffb2074103
authorLucas Galante <[email protected]>
date2026-09-03 22:40
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
 }