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

commit30cafa76dc367b8b3a1ed1ab626eba71d5ba16eb
parent5165d55a8b
authorLucas Galante <[email protected]>
date2026-09-14 13:02
fix(cursor): the touchpad view drag follows the fingers under natural scrolling

libinput flips a finger delta's sign for a natural-scrolling touchpad
before the compositor sees it. That is right for a scroll the client
receives (content follows the fingers) and wrong for the emulated view
drag, which replays the delta as pointer motion: the view follows the
pointer, so with natural scrolling on it ran against the fingers in
Houdini's viewport and network editor while the parameter pane, a plain
scroll, felt right.

view_drag_axis now asks the source device
(libinput_device_config_scroll_get_natural_scroll_enabled) and undoes the
flip; touchpad_view_invert stays a deliberate reversal on top.
pointer-scroll gains a natural token so an injected swipe can stand in
for such a device, which is how this was verified in a cce-shadow session:
fingers-down moves the synthetic pointer down under both settings.

 src/cce_ctl.rs               |  7 +++++--
 src/server/config.rs         |  3 ++-
 src/server/cursor.rs         | 33 +++++++++++++++++++++++++++++++++
 src/server/window_manager.rs | 14 +++++++++++---
 4 files changed, 51 insertions(+), 6 deletions(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index e0ecd43..fe160d2 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -86,8 +86,11 @@ fn usage(name: &str, to_stderr: bool) {
     print("  pointer-location");
     print("  pointer-move-to <x> <y>            (layout pixels)");
     print("  pointer-move-by <dx> <dy>");
-    print("  pointer-scroll <dy> [dx] [finger]  (positive dy scrolls down; 15 = one notch;");
-    print("  pointer-scroll finger-stop          finger = a two-finger swipe, ended by finger-stop)");
+    print("  pointer-scroll <dy> [dx] [finger] [natural]");
+    print("                                     (positive dy scrolls down; 15 = one notch; finger = a");
+    print("                                      two-finger swipe, ended by finger-stop; natural = from");
+    print("                                      a natural-scrolling touchpad, deltas already flipped)");
+    print("  pointer-scroll finger-stop");
     print("  pointer-pinch <scale> [rotation] [steps] | begin | update <scale> [rotation] | end");
     print("  touchpad-view-regions <x11:ID|id|app_id> clear | <x,y,w,h> ...");
     print("                                     (limit a touchpad_view_apps drag to these window-local");
diff --git a/src/server/config.rs b/src/server/config.rs
index ee9b77f..f070484 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -418,7 +418,8 @@ pub struct WindowManagerConfig {
     pub touchpad_view_swipe: Option<String>,
     /// Finger-to-pointer distance factor for the emulated drag (default 1).
     pub touchpad_view_sensitivity: Option<f64>,
-    /// Reverse the drag direction. KDL: `touchpad_view_invert (bool)true`.
+    /// Reverse the drag direction, on top of the natural-scroll correction
+    /// the drag already makes. KDL: `touchpad_view_invert (bool)true`.
     pub touchpad_view_invert: Option<bool>,
 }
 
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index 6f0aec9..a835820 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -94,6 +94,10 @@ pub struct Cursor {
     pub gesture_scale: f64,
     pub gesture_triggered: bool,
     pub panning_gesture_active: bool,
+    /// What `pointer-scroll ... natural` sets: an injected finger scroll
+    /// (no device behind it) reads as coming from a natural-scrolling
+    /// touchpad, so the view drag's un-inversion can be exercised headlessly.
+    pub inject_natural: 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.
@@ -176,6 +180,7 @@ impl Default for Cursor {
             gesture_scale: 1.0,
             gesture_triggered: false,
             panning_gesture_active: false,
+            inject_natural: false,
             pan_vel: [0.0, 0.0],
             pan_last_msec: [0, 0],
             pinch_zoom_active: false,
@@ -2353,6 +2358,13 @@ unsafe extern "C" fn handle_axis(listener: *mut ffi::wl_listener, data: *mut std
 /// dollies (right button, distance from the log of the scale), and Ctrl
 /// + swipe passes through as a plain scroll — the same modifier Houdini
 /// itself assigns to "simulate the mouse wheel" in gesture mode.
+///
+/// Natural scrolling is undone here. libinput flips the sign of a finger
+/// delta before the compositor sees it, which is right for a scroll (the
+/// content follows the fingers) and wrong for a drag replayed as pointer
+/// motion: the view follows the pointer, so the pointer has to go where
+/// the fingers went. `axis_event_is_natural` asks the source device, and
+/// `touchpad_view_invert` then means "backwards" on top of that either way.
 pub struct ViewDrag {
     pub button: u32,
     pub surface: *mut ffi::wlr_surface,
@@ -2436,6 +2448,24 @@ impl Cursor {
         Some((window, result.surface, result.sx, result.sy, ratio))
     }
 
+    /// Whether the touchpad behind an axis event has natural scrolling on,
+    /// i.e. its deltas arrive sign-flipped. An injected event has no device
+    /// and answers with `inject_natural` (see `pointer-scroll ... natural`).
+    unsafe fn axis_event_is_natural(&self, event: *const ffi::wlr_pointer_axis_event) -> bool {
+        let pointer = (*event).pointer;
+        if pointer.is_null() {
+            return self.inject_natural;
+        }
+        let dev = &mut (*pointer).base as *mut ffi::wlr_input_device;
+        if !ffi::wlr_input_device_is_libinput(dev) {
+            return false;
+        }
+        let handle = ffi::wlr_libinput_get_device_handle(dev);
+        !handle.is_null()
+            && ffi::libinput_device_config_scroll_has_natural_scroll(handle) != 0
+            && ffi::libinput_device_config_scroll_get_natural_scroll_enabled(handle) != 0
+    }
+
     unsafe fn begin_view_drag(&mut self, button: u32, from_pinch: bool) -> bool {
         let Some((window, surface, sx, sy, ratio)) = self.view_drag_target() else { return false };
         let seat = &mut *self.seat;
@@ -2544,6 +2574,9 @@ impl Cursor {
         }
         let wm = &(*(*self.seat).server).wm;
         let mut step = delta * wm.touchpad_view_sensitivity;
+        if self.axis_event_is_natural(event) {
+            step = -step;
+        }
         if wm.touchpad_view_invert {
             step = -step;
         }
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 0469aac..2be9c84 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -5381,11 +5381,19 @@ impl WindowManager {
                     self.for_each_cursor(|cursor| cursor.inject_finger_stop());
                     return "ok\n".to_string();
                 }
-                let finger = parts.last().map_or(false, |p| *p == "finger");
+                // `natural` marks the swipe as coming from a natural-scrolling
+                // touchpad: the deltas are given as libinput would deliver
+                // them (already sign-flipped), and the view drag undoes that.
+                let finger = parts.iter().any(|p| *p == "finger");
+                let natural = parts.iter().any(|p| *p == "natural");
                 let dy = parts[1].parse::<f64>();
-                let dx = parts.get(2).filter(|p| **p != "finger").map(|v| v.parse::<f64>()).unwrap_or(Ok(0.0));
+                let dx = parts.get(2).filter(|p| **p != "finger" && **p != "natural").map(|v| v.parse::<f64>()).unwrap_or(Ok(0.0));
                 if let (Ok(dy), Ok(dx)) = (dy, dx) {
-                    self.for_each_cursor(|cursor| cursor.inject_scroll(dy, dx, finger));
+                    self.for_each_cursor(|cursor| {
+                        cursor.inject_natural = natural;
+                        cursor.inject_scroll(dy, dx, finger);
+                        cursor.inject_natural = false;
+                    });
                     "ok\n".to_string()
                 } else {
                     "error: invalid dy or dx\n".to_string()