git.lucas.co / cce-window-manager
window management library
git clone https://git.lucas.co/cce-window-manager.git

commit9f5cd7e0b56d0ab83d6fa8dd7f9959d55e6a0c60
parentece79a0705
authorLucas Galante <[email protected]>
date2026-09-15 21:10
feat(bindings): parse touchpad gesture chords (swipe3_left, pinch_out)

`parse_gesture` reads the gesture grammar an input.kdl window-manager
entry may carry in its chord slot: optional modifiers, `swipe` or
`pinch`, an optional finger count (2–4, what libinput reports), and a
direction. The fingerless spelling (`swipe_down`) keeps the legacy
`toggle_overview` meaning of three or four fingers. It rejects plain key
chords so the compositor can try it before `parse_chord` without the two
grammars colliding.

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

 src/bindings.rs | 111 ++++++++++++++++++++++++++++++++++++++++++++++++++++++++
 1 file changed, 111 insertions(+)

diff --git a/src/bindings.rs b/src/bindings.rs
index b34093e..d96a7e3 100644
--- a/src/bindings.rs
+++ b/src/bindings.rs
@@ -8,6 +8,11 @@
 // A `Chord` keeps its key as an XKB keysym NAME — resolving names to keysym
 // codes needs xkbcommon, so the compositor does that and this crate only
 // ever sees the resulting `u32`.
+//
+// Touchpad gestures share the domain: an entry whose "key" is a gesture name
+// (`swipe3_left`, `pinch_out`) is a `GestureChord`, not a `Chord`, and the
+// compositor matches it against libinput swipe/pinch events instead of key
+// presses. `parse_gesture` is tried first so the two grammars never collide.
 
 use super::api::Action;
 
@@ -55,6 +60,76 @@ pub fn parse_chord(s: &str) -> Option<Chord> {
     Some(Chord { mods, key: key.to_string() })
 }
 
+/// Which libinput gesture a `GestureChord` names.
+#[derive(Debug, Clone, Copy, PartialEq, Eq)]
+pub enum GestureKind {
+    Swipe,
+    Pinch,
+}
+
+impl GestureKind {
+    /// The name the compositor's gesture table keys on.
+    pub fn as_str(self) -> &'static str {
+        match self {
+            GestureKind::Swipe => "swipe",
+            GestureKind::Pinch => "pinch",
+        }
+    }
+}
+
+/// A parsed gesture chord: modifier mask plus the gesture. `fingers` is
+/// `None` for the fingerless spelling (`"swipe_down"`), which the
+/// compositor binds to both three- and four-finger gestures — the legacy
+/// `window_manager { toggle_overview "swipe_down" }` meaning.
+#[derive(Debug, Clone, PartialEq, Eq)]
+pub struct GestureChord {
+    pub mods: u32,
+    pub kind: GestureKind,
+    pub fingers: Option<u32>,
+    /// `left` / `right` / `up` / `down` for a swipe, `in` / `out` for a pinch.
+    pub direction: String,
+}
+
+/// Parse `"super+swipe3_left"` → mods SUPER, three-finger swipe left. The
+/// last `+` segment is the gesture — `swipe` or `pinch`, an optional finger
+/// count (2–4, what libinput reports), `_` or `-`, then the direction —
+/// and every segment before it must be a known modifier. Case-insensitive.
+/// Returns `None` for anything else, including a plain key chord, so
+/// callers try this before `parse_chord`.
+pub fn parse_gesture(s: &str) -> Option<GestureChord> {
+    let mut mods = 0u32;
+    let mut segments = s.split('+').map(str::trim);
+    let gesture = segments.next_back()?.to_lowercase().replace('-', "_");
+    for seg in segments {
+        mods |= mod_from_name(&seg.to_lowercase())?;
+    }
+    let (kind, rest) = if let Some(rest) = gesture.strip_prefix("swipe") {
+        (GestureKind::Swipe, rest)
+    } else if let Some(rest) = gesture.strip_prefix("pinch") {
+        (GestureKind::Pinch, rest)
+    } else {
+        return None;
+    };
+    let (count, direction) = rest.split_once('_')?;
+    let fingers = if count.is_empty() {
+        None
+    } else {
+        let n: u32 = count.parse().ok()?;
+        if !(2..=4).contains(&n) {
+            return None;
+        }
+        Some(n)
+    };
+    let valid = match kind {
+        GestureKind::Swipe => matches!(direction, "left" | "right" | "up" | "down"),
+        GestureKind::Pinch => matches!(direction, "in" | "out"),
+    };
+    if !valid {
+        return None;
+    }
+    Some(GestureChord { mods, kind, fingers, direction: direction.to_string() })
+}
+
 /// One resolved binding: chord (mods + keysym code) → action, with the
 /// command argument for `Spawn`/`Toggle` (required) and the media-key
 /// actions (optional override of their stock command).
@@ -188,6 +263,42 @@ mod tests {
         assert_eq!(parse_chord("hyper+x"), None); // unknown modifier
     }
 
+    #[test]
+    fn parse_gesture_reads_kind_fingers_and_direction() {
+        assert_eq!(
+            parse_gesture("swipe3_left"),
+            Some(GestureChord { mods: 0, kind: GestureKind::Swipe, fingers: Some(3), direction: "left".into() })
+        );
+        assert_eq!(
+            parse_gesture("Super+Swipe4-Down"),
+            Some(GestureChord { mods: mods::SUPER, kind: GestureKind::Swipe, fingers: Some(4), direction: "down".into() })
+        );
+        // The fingerless legacy spelling means "three or four fingers".
+        assert_eq!(
+            parse_gesture("swipe_down"),
+            Some(GestureChord { mods: 0, kind: GestureKind::Swipe, fingers: None, direction: "down".into() })
+        );
+        assert_eq!(
+            parse_gesture("pinch_out"),
+            Some(GestureChord { mods: 0, kind: GestureKind::Pinch, fingers: None, direction: "out".into() })
+        );
+        assert_eq!(GestureKind::Swipe.as_str(), "swipe");
+    }
+
+    #[test]
+    fn parse_gesture_rejects_keys_and_nonsense() {
+        // Plain key chords are not gestures — they fall through to parse_chord.
+        assert_eq!(parse_gesture("super+shift+h"), None);
+        assert_eq!(parse_gesture("s"), None);
+        assert_eq!(parse_gesture("swipe"), None); // no direction
+        assert_eq!(parse_gesture("swipe3"), None);
+        assert_eq!(parse_gesture("swipe3_in"), None); // pinch direction on a swipe
+        assert_eq!(parse_gesture("pinch_left"), None);
+        assert_eq!(parse_gesture("swipe5_left"), None); // libinput reports 2–4
+        assert_eq!(parse_gesture("swipe0_left"), None);
+        assert_eq!(parse_gesture("hyper+swipe3_left"), None); // unknown modifier
+    }
+
     #[test]
     fn action_names_round_trip() {
         // Every variant's canonical name resolves back to the variant.