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

commit1cacffb5de36d1b0798827a134c91071a4a57d87
parenta15e994ae0
authorLucas Galante <[email protected]>
date2026-09-15 21:10
feat(input): bind touchpad gestures from input.kdl; ccectl pointer-swipe

The gesture table (matched in the cursor's swipe/pinch handlers) was
fed only by config.kdl `gesture_bind` nodes and the legacy
`window_manager { toggle_overview "swipe_down" }`. Now a
cce-window-manager entry in input.kdl whose chord parses as a gesture
(`focus_left "swipe3_left"`, `super+pinch_out`) lands there too, ahead
of both legacy sources, so the file that holds every other binding can
hold these. A fingerless gesture binds three and four fingers; a repeat
warns like a repeated key chord.

`ccectl pointer-swipe <fingers> <dx> <dy> [steps]` injects a whole swipe
the way pointer-pinch does, so the table can be driven in a headless
shadow: verified swipe3 left/right stepping focus, swipe4 staying
unbound, and the legacy swipe_down overview still firing.

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

 src/cce_ctl.rs               |  3 +++
 src/server/config.rs         | 38 ++++++++++++++++++++++++++++++++++----
 src/server/cursor.rs         | 43 +++++++++++++++++++++++++++++++++++++++++++
 src/server/window_manager.rs | 14 ++++++++++++++
 4 files changed, 94 insertions(+), 4 deletions(-)

diff --git a/src/cce_ctl.rs b/src/cce_ctl.rs
index fe160d2..b86e860 100644
--- a/src/cce_ctl.rs
+++ b/src/cce_ctl.rs
@@ -91,6 +91,9 @@ fn usage(name: &str, to_stderr: bool) {
     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-swipe <fingers> <dx> <dy> [steps]");
+    print("                                     (a 3/4-finger touchpad swipe; drives input.kdl gesture");
+    print("                                      chords such as focus_left \"swipe3_left\")");
     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 f070484..4253084 100644
--- a/src/server/config.rs
+++ b/src/server/config.rs
@@ -2578,13 +2578,12 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
 
     state.keybinds.clear();
     let mut table = cce_window_manager::bindings::BindingTable::new();
+    // Gesture entries from the same domain (`focus_left "swipe3_left"`);
+    // they go ahead of config.kdl's `gesture_bind` nodes below.
+    let mut input_gesture_binds: Vec<GestureBind> = Vec::new();
 
     // Primary source: the `cce-window-manager` domain of input.kdl.
     for entry in &wm_domain_entries {
-        let Some(chord) = cce_window_manager::bindings::parse_chord(&entry.chord) else {
-            eprintln!("[WARNING] input.kdl: invalid chord {:?} for {}", entry.chord, entry.name);
-            continue;
-        };
         let Some(action) = Action::from_name(&entry.name) else {
             eprintln!("[WARNING] input.kdl: unknown window-manager action {:?}", entry.name);
             continue;
@@ -2603,6 +2602,33 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
             }
             _ => None,
         };
+        // A touchpad gesture rides in the chord slot: `swipe3_left`,
+        // `super+pinch_out`. The fingerless spelling binds three AND four
+        // fingers, as `toggle_overview "swipe_down"` always has.
+        if let Some(g) = cce_window_manager::bindings::parse_gesture(&entry.chord) {
+            let fingers: Vec<u32> = g.fingers.map(|n| vec![n]).unwrap_or_else(|| vec![3, 4]);
+            for fingers in fingers {
+                let dup = input_gesture_binds.iter().any(|b| {
+                    b.mods == g.mods && b.gesture_type == g.kind.as_str() && b.fingers == fingers && b.direction == g.direction
+                });
+                if dup {
+                    eprintln!("[WARNING] input.kdl: {:?} is bound more than once", entry.chord);
+                }
+                input_gesture_binds.push(GestureBind {
+                    mods: g.mods,
+                    gesture_type: g.kind.as_str().to_string(),
+                    fingers,
+                    direction: g.direction.clone(),
+                    action,
+                    command: command.clone(),
+                });
+            }
+            continue;
+        }
+        let Some(chord) = cce_window_manager::bindings::parse_chord(&entry.chord) else {
+            eprintln!("[WARNING] input.kdl: invalid chord {:?} for {}", entry.chord, entry.name);
+            continue;
+        };
         let keysym = parse_keysym(&chord.key);
         if keysym == 0 {
             eprintln!("[WARNING] input.kdl: unknown key {:?} in chord {:?}", chord.key, entry.chord);
@@ -2680,7 +2706,11 @@ pub fn parse_config(path: &str, state: &mut crate::window_manager::WindowManager
         });
     }
 
+    // Gesture table, first match wins in the cursor's swipe/pinch handlers:
+    // input.kdl entries, then config.kdl `gesture_bind` nodes, then the
+    // legacy `window_manager { toggle_overview "swipe_down" }`.
     state.gesture_binds.clear();
+    state.gesture_binds.extend(input_gesture_binds);
     for gb in &config.gesture_bind {
         let mods = gb.mods.as_ref().map(|m| parse_modifiers(m)).unwrap_or(0);
         let action = parse_action(&gb.action);
diff --git a/src/server/cursor.rs b/src/server/cursor.rs
index a835820..5ee1246 100644
--- a/src/server/cursor.rs
+++ b/src/server/cursor.rs
@@ -1095,6 +1095,49 @@ impl Cursor {
         ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
     }
 
+    /// Inject a whole touchpad swipe: begin with `fingers`, `steps` updates
+    /// that together move the gesture centre by (`dx`, `dy`), end. Drives
+    /// the gesture-bind table (`swipe3_left` in input.kdl) headlessly;
+    /// the pointer-gestures forward to clients runs too.
+    pub unsafe fn inject_swipe(&mut self, fingers: u32, dx: f64, dy: f64, steps: u32) {
+        let time = crate::util::msec_timestamp();
+        let mut begin = ffi::wlr_pointer_swipe_begin_event {
+            pointer: std::ptr::null_mut(),
+            time_msec: time,
+            fingers,
+        };
+        handle_swipe_begin(
+            &mut self.swipe_begin_listener as *mut ffi::wl_listener,
+            &mut begin as *mut ffi::wlr_pointer_swipe_begin_event as *mut std::ffi::c_void,
+        );
+        ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
+        let steps = steps.max(1);
+        for i in 1..=steps {
+            let mut update = ffi::wlr_pointer_swipe_update_event {
+                pointer: std::ptr::null_mut(),
+                time_msec: time + i,
+                fingers,
+                dx: dx / steps as f64,
+                dy: dy / steps as f64,
+            };
+            handle_swipe_update(
+                &mut self.swipe_update_listener as *mut ffi::wl_listener,
+                &mut update as *mut ffi::wlr_pointer_swipe_update_event as *mut std::ffi::c_void,
+            );
+            ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
+        }
+        let mut end = ffi::wlr_pointer_swipe_end_event {
+            pointer: std::ptr::null_mut(),
+            time_msec: time + steps + 1,
+            cancelled: false,
+        };
+        handle_swipe_end(
+            &mut self.swipe_end_listener as *mut ffi::wl_listener,
+            &mut end as *mut ffi::wlr_pointer_swipe_end_event as *mut std::ffi::c_void,
+        );
+        ffi::wlr_seat_pointer_notify_frame((*self.seat).wlr_seat);
+    }
+
     pub unsafe fn inject_pinch(&mut self, scale: f64, rotation: f64, steps: u32) {
         let time = crate::util::msec_timestamp();
         let mut begin = ffi::wlr_pointer_pinch_begin_event {
diff --git a/src/server/window_manager.rs b/src/server/window_manager.rs
index 886ca43..6f45fac 100644
--- a/src/server/window_manager.rs
+++ b/src/server/window_manager.rs
@@ -5444,6 +5444,20 @@ impl WindowManager {
                     "error: invalid dy or dx\n".to_string()
                 }
             }
+            "pointer-swipe" => {
+                // pointer-swipe <fingers> <dx> <dy> [steps]
+                if parts.len() < 4 { return "error: usage: pointer-swipe <fingers> <dx> <dy> [steps]\n".to_string(); }
+                let fingers = parts[1].parse::<u32>();
+                let dx = parts[2].parse::<f64>();
+                let dy = parts[3].parse::<f64>();
+                let steps = parts.get(4).map(|v| v.parse::<u32>()).unwrap_or(Ok(10));
+                if let (Ok(fingers), Ok(dx), Ok(dy), Ok(steps)) = (fingers, dx, dy, steps) {
+                    self.for_each_cursor(|cursor| cursor.inject_swipe(fingers, dx, dy, steps));
+                    "ok\n".to_string()
+                } else {
+                    "error: invalid swipe arguments\n".to_string()
+                }
+            }
             "pointer-pinch" => {
                 // pointer-pinch <scale> [rotation-degrees] [steps]
                 // pointer-pinch begin | update <scale> [rotation] | end   (paced by the caller)