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

src/bindings.rs (14.8K)

  1 // Keybinding vocabulary and resolution for the window-manager domain.
  2 //
  3 // This crate owns what a binding MEANS: the action names users write in
  4 // `input.kdl` (`Action::from_name`), the chord grammar ("super+shift+h"),
  5 // the resolved table, and the stock defaults. The mechanism side owns the
  6 // physical half: reading the file, XKB keysym lookup, and key delivery.
  7 //
  8 // A `Chord` keeps its key as an XKB keysym NAME — resolving names to keysym
  9 // codes needs xkbcommon, so the compositor does that and this crate only
 10 // ever sees the resulting `u32`.
 11 //
 12 // Touchpad gestures share the domain: an entry whose "key" is a gesture name
 13 // (`swipe3_left`, `pinch_out`) is a `GestureChord`, not a `Chord`, and the
 14 // compositor matches it against libinput swipe/pinch events instead of key
 15 // presses. `parse_gesture` is tried first so the two grammars never collide.
 16 
 17 use super::api::Action;
 18 
 19 /// Modifier bitmask values (river seat conventions — the same values the
 20 /// compositor has always packed into its keybind masks).
 21 pub mod mods {
 22     pub const SHIFT: u32 = 0x01;
 23     pub const CTRL: u32 = 0x04;
 24     pub const ALT: u32 = 0x08;
 25     pub const SUPER: u32 = 0x40;
 26 }
 27 
 28 fn mod_from_name(name: &str) -> Option<u32> {
 29     match name {
 30         "shift" => Some(mods::SHIFT),
 31         "ctrl" | "control" => Some(mods::CTRL),
 32         "alt" | "mod1" | "meta" => Some(mods::ALT),
 33         "super" | "mod4" | "logo" | "win" => Some(mods::SUPER),
 34         _ => None,
 35     }
 36 }
 37 
 38 /// A parsed key chord: modifier mask plus the key's XKB keysym name.
 39 #[derive(Debug, Clone, PartialEq, Eq)]
 40 pub struct Chord {
 41     pub mods: u32,
 42     pub key: String,
 43 }
 44 
 45 /// Parse `"super+shift+h"` → mods SUPER|SHIFT, key `"h"`. The last segment
 46 /// is the key (an XKB keysym name, e.g. `slash`, `equal`, `Left`); every
 47 /// segment before it must be a known modifier. Strict on purpose: a typo'd
 48 /// modifier returns `None` so the loader can warn, instead of silently
 49 /// binding the wrong chord.
 50 pub fn parse_chord(s: &str) -> Option<Chord> {
 51     let mut mods = 0u32;
 52     let mut segments = s.split('+').map(str::trim);
 53     let key = segments.next_back()?;
 54     if key.is_empty() {
 55         return None;
 56     }
 57     for seg in segments {
 58         mods |= mod_from_name(&seg.to_lowercase())?;
 59     }
 60     Some(Chord { mods, key: key.to_string() })
 61 }
 62 
 63 /// Which libinput gesture a `GestureChord` names.
 64 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
 65 pub enum GestureKind {
 66     Swipe,
 67     Pinch,
 68 }
 69 
 70 impl GestureKind {
 71     /// The name the compositor's gesture table keys on.
 72     pub fn as_str(self) -> &'static str {
 73         match self {
 74             GestureKind::Swipe => "swipe",
 75             GestureKind::Pinch => "pinch",
 76         }
 77     }
 78 }
 79 
 80 /// A parsed gesture chord: modifier mask plus the gesture. `fingers` is
 81 /// `None` for the fingerless spelling (`"swipe_down"`), which the
 82 /// compositor binds to both three- and four-finger gestures — the legacy
 83 /// `window_manager { toggle_overview "swipe_down" }` meaning.
 84 #[derive(Debug, Clone, PartialEq, Eq)]
 85 pub struct GestureChord {
 86     pub mods: u32,
 87     pub kind: GestureKind,
 88     pub fingers: Option<u32>,
 89     /// `left` / `right` / `up` / `down` for a swipe, `in` / `out` for a pinch.
 90     pub direction: String,
 91 }
 92 
 93 /// Parse `"super+swipe3_left"` → mods SUPER, three-finger swipe left. The
 94 /// last `+` segment is the gesture — `swipe` or `pinch`, an optional finger
 95 /// count (2–4, what libinput reports), `_` or `-`, then the direction —
 96 /// and every segment before it must be a known modifier. Case-insensitive.
 97 /// Returns `None` for anything else, including a plain key chord, so
 98 /// callers try this before `parse_chord`.
 99 pub fn parse_gesture(s: &str) -> Option<GestureChord> {
100     let mut mods = 0u32;
101     let mut segments = s.split('+').map(str::trim);
102     let gesture = segments.next_back()?.to_lowercase().replace('-', "_");
103     for seg in segments {
104         mods |= mod_from_name(&seg.to_lowercase())?;
105     }
106     let (kind, rest) = if let Some(rest) = gesture.strip_prefix("swipe") {
107         (GestureKind::Swipe, rest)
108     } else if let Some(rest) = gesture.strip_prefix("pinch") {
109         (GestureKind::Pinch, rest)
110     } else {
111         return None;
112     };
113     let (count, direction) = rest.split_once('_')?;
114     let fingers = if count.is_empty() {
115         None
116     } else {
117         let n: u32 = count.parse().ok()?;
118         if !(2..=4).contains(&n) {
119             return None;
120         }
121         Some(n)
122     };
123     let valid = match kind {
124         GestureKind::Swipe => matches!(direction, "left" | "right" | "up" | "down"),
125         GestureKind::Pinch => matches!(direction, "in" | "out"),
126     };
127     if !valid {
128         return None;
129     }
130     Some(GestureChord { mods, kind, fingers, direction: direction.to_string() })
131 }
132 
133 /// One resolved binding: chord (mods + keysym code) → action, with the
134 /// command argument for `Spawn`/`Toggle` (required) and the media-key
135 /// actions (optional override of their stock command).
136 #[derive(Debug, Clone, PartialEq, Eq)]
137 pub struct Binding {
138     pub mods: u32,
139     pub keysym: u32,
140     pub action: Action,
141     pub command: Option<String>,
142 }
143 
144 /// The resolved binding table. Insertion order is priority order: `resolve`
145 /// returns the first match, so load primary sources before fallbacks and
146 /// use `add_default` for anything that must not shadow what's already there.
147 #[derive(Debug, Clone, Default)]
148 pub struct BindingTable {
149     bindings: Vec<Binding>,
150 }
151 
152 impl BindingTable {
153     pub fn new() -> Self {
154         Self::default()
155     }
156 
157     pub fn contains(&self, mods: u32, keysym: u32) -> bool {
158         self.bindings.iter().any(|b| b.mods == mods && b.keysym == keysym)
159     }
160 
161     /// Push unconditionally. Returns `true` if an earlier binding already
162     /// claims the chord (the new one is shadowed) so the caller can warn.
163     pub fn add(&mut self, binding: Binding) -> bool {
164         let shadowed = self.contains(binding.mods, binding.keysym);
165         self.bindings.push(binding);
166         shadowed
167     }
168 
169     /// Push only if the chord is still free. Returns whether it was added.
170     pub fn add_default(&mut self, binding: Binding) -> bool {
171         if self.contains(binding.mods, binding.keysym) {
172             false
173         } else {
174             self.bindings.push(binding);
175             true
176         }
177     }
178 
179     /// First match wins, mirroring the compositor's dispatch loop.
180     pub fn resolve(&self, mods: u32, keysym: u32) -> Option<&Binding> {
181         self.bindings.iter().find(|b| b.mods == mods && b.keysym == keysym)
182     }
183 
184     pub fn iter(&self) -> impl Iterator<Item = &Binding> {
185         self.bindings.iter()
186     }
187 
188     pub fn len(&self) -> usize {
189         self.bindings.len()
190     }
191 
192     pub fn is_empty(&self) -> bool {
193         self.bindings.is_empty()
194     }
195 
196     pub fn into_bindings(self) -> Vec<Binding> {
197         self.bindings
198     }
199 }
200 
201 /// A stock binding: chord with the key still as a keysym name.
202 #[derive(Debug, Clone, Copy)]
203 pub struct DefaultBinding {
204     pub mods: u32,
205     pub key: &'static str,
206     pub action: Action,
207 }
208 
209 /// The built-in fallback set (previously hardcoded in the compositor's
210 /// config loader). Applied with `add_default` after every configured source,
211 /// so any of these chords can be rebound in `input.kdl`.
212 pub const DEFAULT_BINDINGS: &[DefaultBinding] = &[
213     DefaultBinding { mods: mods::SUPER, key: "k", action: Action::FocusUp },
214     DefaultBinding { mods: mods::SUPER, key: "j", action: Action::FocusDown },
215     DefaultBinding { mods: mods::SUPER, key: "h", action: Action::FocusLeft },
216     DefaultBinding { mods: mods::SUPER, key: "l", action: Action::FocusRight },
217     // The arrow keys focus exactly as hjkl do. Super+Left/Right used to
218     // move the overlay (overlay_left/right, still reachable over IPC as
219     // `ccectl overlay-left|right` or bindable in input.kdl).
220     DefaultBinding { mods: mods::SUPER, key: "Up", action: Action::FocusUp },
221     DefaultBinding { mods: mods::SUPER, key: "Down", action: Action::FocusDown },
222     DefaultBinding { mods: mods::SUPER, key: "Left", action: Action::FocusLeft },
223     DefaultBinding { mods: mods::SUPER, key: "Right", action: Action::FocusRight },
224     DefaultBinding { mods: 0, key: "Print", action: Action::Screenshot },
225     DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Up", action: Action::PanUp },
226     DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Down", action: Action::PanDown },
227     DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Left", action: Action::PanLeft },
228     DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Right", action: Action::PanRight },
229     // Zoom chords ship UNBOUND by default: they live in the user's
230     // input.kdl (cce-window-manager domain: zoom_in / zoom_out /
231     // zoom_reset) rather than in this table.
232     DefaultBinding { mods: mods::SUPER | mods::SHIFT, key: "r", action: Action::Reload },
233     // Reverse companion to the (user-configured) super+tab window switcher.
234     DefaultBinding { mods: mods::SUPER | mods::SHIFT, key: "Tab", action: Action::WindowSwitcherPrev },
235     // Media keys. Each action spawns a stock wpctl/brightnessctl command
236     // (see `actions::media_command`); an input.kdl binding can rebind the
237     // chord and/or override the command with a `command="..."` property.
238     DefaultBinding { mods: 0, key: "XF86AudioRaiseVolume", action: Action::VolumeUp },
239     DefaultBinding { mods: 0, key: "XF86AudioLowerVolume", action: Action::VolumeDown },
240     DefaultBinding { mods: 0, key: "XF86AudioMute", action: Action::VolumeMute },
241     DefaultBinding { mods: 0, key: "XF86AudioMicMute", action: Action::MicMute },
242     DefaultBinding { mods: 0, key: "XF86MonBrightnessUp", action: Action::BrightnessUp },
243     DefaultBinding { mods: 0, key: "XF86MonBrightnessDown", action: Action::BrightnessDown },
244 ];
245 
246 #[cfg(test)]
247 mod tests {
248     use super::*;
249 
250     #[test]
251     fn parse_chord_splits_mods_and_key() {
252         assert_eq!(
253             parse_chord("super+shift+h"),
254             Some(Chord { mods: mods::SUPER | mods::SHIFT, key: "h".into() })
255         );
256         assert_eq!(parse_chord("escape"), Some(Chord { mods: 0, key: "escape".into() }));
257         // Keysym names keep their case; modifiers are case-insensitive.
258         assert_eq!(
259             parse_chord("Super+Ctrl+Left"),
260             Some(Chord { mods: mods::SUPER | mods::CTRL, key: "Left".into() })
261         );
262     }
263 
264     #[test]
265     fn parse_chord_rejects_invalid() {
266         assert_eq!(parse_chord(""), None);
267         assert_eq!(parse_chord("super+"), None); // empty key
268         assert_eq!(parse_chord("hyper+x"), None); // unknown modifier
269     }
270 
271     #[test]
272     fn parse_gesture_reads_kind_fingers_and_direction() {
273         assert_eq!(
274             parse_gesture("swipe3_left"),
275             Some(GestureChord { mods: 0, kind: GestureKind::Swipe, fingers: Some(3), direction: "left".into() })
276         );
277         assert_eq!(
278             parse_gesture("Super+Swipe4-Down"),
279             Some(GestureChord { mods: mods::SUPER, kind: GestureKind::Swipe, fingers: Some(4), direction: "down".into() })
280         );
281         // The fingerless legacy spelling means "three or four fingers".
282         assert_eq!(
283             parse_gesture("swipe_down"),
284             Some(GestureChord { mods: 0, kind: GestureKind::Swipe, fingers: None, direction: "down".into() })
285         );
286         assert_eq!(
287             parse_gesture("pinch_out"),
288             Some(GestureChord { mods: 0, kind: GestureKind::Pinch, fingers: None, direction: "out".into() })
289         );
290         assert_eq!(GestureKind::Swipe.as_str(), "swipe");
291     }
292 
293     #[test]
294     fn parse_gesture_rejects_keys_and_nonsense() {
295         // Plain key chords are not gestures — they fall through to parse_chord.
296         assert_eq!(parse_gesture("super+shift+h"), None);
297         assert_eq!(parse_gesture("s"), None);
298         assert_eq!(parse_gesture("swipe"), None); // no direction
299         assert_eq!(parse_gesture("swipe3"), None);
300         assert_eq!(parse_gesture("swipe3_in"), None); // pinch direction on a swipe
301         assert_eq!(parse_gesture("pinch_left"), None);
302         assert_eq!(parse_gesture("swipe5_left"), None); // libinput reports 2–4
303         assert_eq!(parse_gesture("swipe0_left"), None);
304         assert_eq!(parse_gesture("hyper+swipe3_left"), None); // unknown modifier
305     }
306 
307     #[test]
308     fn action_names_round_trip() {
309         // Every variant's canonical name resolves back to the variant.
310         for action in [
311             Action::None, Action::Spawn, Action::Toggle, Action::Close,
312             Action::FocusNext, Action::FocusPrev, Action::FocusUp,
313             Action::FocusDown, Action::FocusLeft, Action::FocusRight,
314             Action::WindowSwitcher, Action::WindowSwitcherPrev,
315             Action::Move, Action::Resize,
316             Action::MoveWindowLeft, Action::MoveWindowRight,
317             Action::MoveWindowUp, Action::MoveWindowDown,
318             Action::Exit, Action::Reload,
319             Action::Fullscreen, Action::ModeNext,
320             Action::ModeNextShared,
321             Action::Overview, Action::OverviewEnter, Action::OverviewExit,
322             Action::Minimize, Action::OverlayLeft,
323             Action::OverlayRight, Action::ZoomIn, Action::ZoomOut,
324             Action::ZoomReset, Action::PanLeft, Action::PanRight,
325             Action::PanUp, Action::PanDown, Action::VolumeUp,
326             Action::VolumeDown, Action::VolumeMute, Action::MicMute,
327             Action::BrightnessUp, Action::BrightnessDown,
328         ] {
329             assert_eq!(Action::from_name(action.name()), Some(action), "{}", action.name());
330         }
331         // Legacy aliases from the old config.kdl vocabulary.
332         assert_eq!(Action::from_name("close"), Some(Action::Close));
333         assert_eq!(Action::from_name("fullscreen"), Some(Action::Fullscreen));
334         assert_eq!(Action::from_name("toggle_overview"), Some(Action::Overview));
335         assert_eq!(Action::from_name("expose"), Some(Action::Overview));
336         assert_eq!(Action::from_name("no_such_action"), None);
337     }
338 
339     #[test]
340     fn table_priority_is_insertion_order() {
341         let mut t = BindingTable::new();
342         let close = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Close, command: None };
343         let min = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Minimize, command: None };
344         assert!(!t.add(close.clone())); // first claim: not shadowed
345         assert!(t.add(min)); // same chord: shadowed
346         assert_eq!(t.resolve(mods::SUPER, 0x71), Some(&close));
347         assert_eq!(t.resolve(mods::SUPER, 0x72), None);
348     }
349 
350     #[test]
351     fn add_default_never_shadows() {
352         let mut t = BindingTable::new();
353         let user = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Close, command: None };
354         t.add(user.clone());
355         let stock = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Fullscreen, command: None };
356         assert!(!t.add_default(stock));
357         assert_eq!(t.len(), 1);
358         assert_eq!(t.resolve(mods::SUPER, 0x71), Some(&user));
359         let free = Binding { mods: mods::SUPER, keysym: 0x72, action: Action::Fullscreen, command: None };
360         assert!(t.add_default(free));
361         assert_eq!(t.len(), 2);
362     }
363 }