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

src/bindings.rs (14.5K)

  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     DefaultBinding { mods: mods::SUPER, key: "Left", action: Action::OverlayLeft },
218     DefaultBinding { mods: mods::SUPER, key: "Right", action: Action::OverlayRight },
219     DefaultBinding { mods: 0, key: "Print", action: Action::Screenshot },
220     DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Up", action: Action::PanUp },
221     DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Down", action: Action::PanDown },
222     DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Left", action: Action::PanLeft },
223     DefaultBinding { mods: mods::SUPER | mods::CTRL, key: "Right", action: Action::PanRight },
224     // Zoom chords ship UNBOUND by default: they live in the user's
225     // input.kdl (cce-window-manager domain: zoom_in / zoom_out /
226     // zoom_reset) rather than in this table.
227     DefaultBinding { mods: mods::SUPER | mods::SHIFT, key: "r", action: Action::Reload },
228     // Reverse companion to the (user-configured) super+tab window switcher.
229     DefaultBinding { mods: mods::SUPER | mods::SHIFT, key: "Tab", action: Action::WindowSwitcherPrev },
230     // Media keys. Each action spawns a stock wpctl/brightnessctl command
231     // (see `actions::media_command`); an input.kdl binding can rebind the
232     // chord and/or override the command with a `command="..."` property.
233     DefaultBinding { mods: 0, key: "XF86AudioRaiseVolume", action: Action::VolumeUp },
234     DefaultBinding { mods: 0, key: "XF86AudioLowerVolume", action: Action::VolumeDown },
235     DefaultBinding { mods: 0, key: "XF86AudioMute", action: Action::VolumeMute },
236     DefaultBinding { mods: 0, key: "XF86AudioMicMute", action: Action::MicMute },
237     DefaultBinding { mods: 0, key: "XF86MonBrightnessUp", action: Action::BrightnessUp },
238     DefaultBinding { mods: 0, key: "XF86MonBrightnessDown", action: Action::BrightnessDown },
239 ];
240 
241 #[cfg(test)]
242 mod tests {
243     use super::*;
244 
245     #[test]
246     fn parse_chord_splits_mods_and_key() {
247         assert_eq!(
248             parse_chord("super+shift+h"),
249             Some(Chord { mods: mods::SUPER | mods::SHIFT, key: "h".into() })
250         );
251         assert_eq!(parse_chord("escape"), Some(Chord { mods: 0, key: "escape".into() }));
252         // Keysym names keep their case; modifiers are case-insensitive.
253         assert_eq!(
254             parse_chord("Super+Ctrl+Left"),
255             Some(Chord { mods: mods::SUPER | mods::CTRL, key: "Left".into() })
256         );
257     }
258 
259     #[test]
260     fn parse_chord_rejects_invalid() {
261         assert_eq!(parse_chord(""), None);
262         assert_eq!(parse_chord("super+"), None); // empty key
263         assert_eq!(parse_chord("hyper+x"), None); // unknown modifier
264     }
265 
266     #[test]
267     fn parse_gesture_reads_kind_fingers_and_direction() {
268         assert_eq!(
269             parse_gesture("swipe3_left"),
270             Some(GestureChord { mods: 0, kind: GestureKind::Swipe, fingers: Some(3), direction: "left".into() })
271         );
272         assert_eq!(
273             parse_gesture("Super+Swipe4-Down"),
274             Some(GestureChord { mods: mods::SUPER, kind: GestureKind::Swipe, fingers: Some(4), direction: "down".into() })
275         );
276         // The fingerless legacy spelling means "three or four fingers".
277         assert_eq!(
278             parse_gesture("swipe_down"),
279             Some(GestureChord { mods: 0, kind: GestureKind::Swipe, fingers: None, direction: "down".into() })
280         );
281         assert_eq!(
282             parse_gesture("pinch_out"),
283             Some(GestureChord { mods: 0, kind: GestureKind::Pinch, fingers: None, direction: "out".into() })
284         );
285         assert_eq!(GestureKind::Swipe.as_str(), "swipe");
286     }
287 
288     #[test]
289     fn parse_gesture_rejects_keys_and_nonsense() {
290         // Plain key chords are not gestures — they fall through to parse_chord.
291         assert_eq!(parse_gesture("super+shift+h"), None);
292         assert_eq!(parse_gesture("s"), None);
293         assert_eq!(parse_gesture("swipe"), None); // no direction
294         assert_eq!(parse_gesture("swipe3"), None);
295         assert_eq!(parse_gesture("swipe3_in"), None); // pinch direction on a swipe
296         assert_eq!(parse_gesture("pinch_left"), None);
297         assert_eq!(parse_gesture("swipe5_left"), None); // libinput reports 2–4
298         assert_eq!(parse_gesture("swipe0_left"), None);
299         assert_eq!(parse_gesture("hyper+swipe3_left"), None); // unknown modifier
300     }
301 
302     #[test]
303     fn action_names_round_trip() {
304         // Every variant's canonical name resolves back to the variant.
305         for action in [
306             Action::None, Action::Spawn, Action::Toggle, Action::Close,
307             Action::FocusNext, Action::FocusPrev, Action::FocusUp,
308             Action::FocusDown, Action::FocusLeft, Action::FocusRight,
309             Action::WindowSwitcher, Action::WindowSwitcherPrev,
310             Action::Move, Action::Resize,
311             Action::MoveWindowLeft, Action::MoveWindowRight,
312             Action::MoveWindowUp, Action::MoveWindowDown,
313             Action::Exit, Action::Reload,
314             Action::Fullscreen, Action::ModeNext,
315             Action::ModeNextShared,
316             Action::Overview, Action::OverviewEnter, Action::OverviewExit,
317             Action::Minimize, Action::OverlayLeft,
318             Action::OverlayRight, Action::ZoomIn, Action::ZoomOut,
319             Action::ZoomReset, Action::PanLeft, Action::PanRight,
320             Action::PanUp, Action::PanDown, Action::VolumeUp,
321             Action::VolumeDown, Action::VolumeMute, Action::MicMute,
322             Action::BrightnessUp, Action::BrightnessDown,
323         ] {
324             assert_eq!(Action::from_name(action.name()), Some(action), "{}", action.name());
325         }
326         // Legacy aliases from the old config.kdl vocabulary.
327         assert_eq!(Action::from_name("close"), Some(Action::Close));
328         assert_eq!(Action::from_name("fullscreen"), Some(Action::Fullscreen));
329         assert_eq!(Action::from_name("toggle_overview"), Some(Action::Overview));
330         assert_eq!(Action::from_name("expose"), Some(Action::Overview));
331         assert_eq!(Action::from_name("no_such_action"), None);
332     }
333 
334     #[test]
335     fn table_priority_is_insertion_order() {
336         let mut t = BindingTable::new();
337         let close = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Close, command: None };
338         let min = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Minimize, command: None };
339         assert!(!t.add(close.clone())); // first claim: not shadowed
340         assert!(t.add(min)); // same chord: shadowed
341         assert_eq!(t.resolve(mods::SUPER, 0x71), Some(&close));
342         assert_eq!(t.resolve(mods::SUPER, 0x72), None);
343     }
344 
345     #[test]
346     fn add_default_never_shadows() {
347         let mut t = BindingTable::new();
348         let user = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Close, command: None };
349         t.add(user.clone());
350         let stock = Binding { mods: mods::SUPER, keysym: 0x71, action: Action::Fullscreen, command: None };
351         assert!(!t.add_default(stock));
352         assert_eq!(t.len(), 1);
353         assert_eq!(t.resolve(mods::SUPER, 0x71), Some(&user));
354         let free = Binding { mods: mods::SUPER, keysym: 0x72, action: Action::Fullscreen, command: None };
355         assert!(t.add_default(free));
356         assert_eq!(t.len(), 2);
357     }
358 }