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 }