Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
src/server/global_shortcuts.rs (9.9K)
1 // SPDX-License-Identifier: GPL-3.0-only
2 //! Portal global shortcuts — chords the compositor eats on behalf of the
3 //! `org.freedesktop.impl.portal.GlobalShortcuts` backend (`cce-shortcuts-portal`).
4 //!
5 //! A native Wayland client cannot grab keys; what it can do is ask the
6 //! desktop portal to bind a trigger for it (1Password's Quick Access does
7 //! exactly this). The portal frontend forwards that to a backend, and the
8 //! backend forwards it here over the control socket:
9 //!
10 //! ```text
11 //! shortcut bind <session> <id> <trigger> -> ok <trigger_description> | error: …
12 //! shortcut unbind <session> [<id>] -> ok
13 //! shortcut clear -> ok
14 //! shortcut list -> <session> <id> <trigger_description> per line
15 //! ```
16 //!
17 //! `<session>` is the portal's session object path (no whitespace, so it is
18 //! one token) and `<trigger>` is the shortcuts-spec string the app supplied
19 //! (`CTRL+SHIFT+space`). A bound chord is matched in `handle_group_key`
20 //! AFTER the builtins and the user's own keybinds — the user's config always
21 //! wins, and a bind for a chord the config already uses is refused rather
22 //! than silently shadowed, so the app is told it did not get it. Press and
23 //! release are reported as one-shot lines on the status socket's
24 //! `shortcuts` topic (`activated|deactivated <session> <id> <time_msec>`),
25 //! which is where the backend turns them into the portal's `Activated` /
26 //! `Deactivated` signals. The compositor never learns which app asked; the
27 //! session path is the only identity it carries.
28 //!
29 //! The table is process state, not config: nothing here is persisted, and a
30 //! backend that starts fresh sends `clear` first so a bind left by a dead
31 //! predecessor cannot keep eating a chord nobody listens for.
32
33 use crate::window_manager::WindowManager;
34
35 /// One bound chord. `mods` is the wlr modifier mask (`config::parse_modifiers`
36 /// values) and `keysym` an xkb keysym, exactly what `Keybind` carries, so the
37 /// same matcher serves both tables.
38 #[derive(Clone, Debug, PartialEq, Eq)]
39 pub struct PortalShortcut {
40 pub session: String,
41 pub id: String,
42 pub mods: u32,
43 pub keysym: u32,
44 /// The `trigger_description` handed back to the app: `Ctrl+Shift+Space`.
45 pub description: String,
46 }
47
48 /// A parsed shortcuts-spec trigger.
49 #[derive(Clone, Debug, PartialEq, Eq)]
50 pub struct Trigger {
51 pub mods: u32,
52 pub keysym: u32,
53 pub description: String,
54 }
55
56 const MOD_SHIFT: u32 = 0x01;
57 const MOD_CTRL: u32 = 0x04;
58 const MOD_ALT: u32 = 0x08;
59 const MOD_LOGO: u32 = 0x40;
60
61 /// Parse a shortcuts-spec trigger: modifiers and one key joined by `+`, the
62 /// modifiers being `CTRL`, `ALT`, `SHIFT` and `LOGO` (case-insensitive;
63 /// `SUPER` and `META` are taken as `LOGO` since apps do write them) and the
64 /// key an xkb keysym name (`space`, `F5`, `q`). A trailing `+` is the plus
65 /// key itself, as in `CTRL++`.
66 pub fn parse_trigger(s: &str) -> Result<Trigger, String> {
67 let s = s.trim();
68 if s.is_empty() {
69 return Err("empty trigger".into());
70 }
71 // `CTRL++` splits as ["CTRL", "", ""]: an empty last part after a `+`
72 // means the key is `+` itself.
73 let mut parts: Vec<&str> = s.split('+').collect();
74 let key = match parts.pop() {
75 Some("") if s.ends_with('+') => {
76 // Drop the empty part before it too (the one between the two
77 // plus signs), leaving just the modifiers.
78 parts.pop();
79 "plus"
80 }
81 Some(k) => k,
82 None => return Err("empty trigger".into()),
83 };
84 let mut mods = 0u32;
85 for m in parts {
86 let bit = match m.to_ascii_uppercase().as_str() {
87 "CTRL" | "CONTROL" => MOD_CTRL,
88 "ALT" => MOD_ALT,
89 "SHIFT" => MOD_SHIFT,
90 "LOGO" | "SUPER" | "META" => MOD_LOGO,
91 "" => return Err(format!("empty modifier in {s:?}")),
92 other => return Err(format!("unknown modifier {other:?}")),
93 };
94 mods |= bit;
95 }
96 // xkbcommon builds a CString from the name and unwraps it, so a NUL
97 // panics. The control socket refuses NUL already; this keeps the parser
98 // safe on its own.
99 if key.contains('\0') {
100 return Err(format!("unknown key {key:?}"));
101 }
102 let keysym: u32 = xkbcommon::xkb::keysym_from_name(key, xkbcommon::xkb::KEYSYM_CASE_INSENSITIVE).into();
103 if keysym == 0 {
104 return Err(format!("unknown key {key:?}"));
105 }
106 if unsafe { crate::keyboard::keysym_is_modifier(keysym) } {
107 return Err(format!("{key:?} is a modifier, not a key"));
108 }
109 Ok(Trigger { mods, keysym, description: describe(mods, keysym) })
110 }
111
112 /// Human form for the app to render: `Ctrl+Shift+Space`. Modifier order is
113 /// fixed regardless of how the trigger was written.
114 fn describe(mods: u32, keysym: u32) -> String {
115 let mut out = Vec::new();
116 if mods & MOD_CTRL != 0 {
117 out.push("Ctrl".to_string());
118 }
119 if mods & MOD_ALT != 0 {
120 out.push("Alt".to_string());
121 }
122 if mods & MOD_SHIFT != 0 {
123 out.push("Shift".to_string());
124 }
125 if mods & MOD_LOGO != 0 {
126 out.push("Super".to_string());
127 }
128 let name = xkbcommon::xkb::keysym_get_name(xkbcommon::xkb::Keysym::new(keysym));
129 // Single letters read better upper-case; multi-letter names (`space`,
130 // `Return`, `F5`) get an initial capital and are otherwise left alone.
131 let mut chars = name.chars();
132 let pretty = match chars.next() {
133 Some(c) if name.chars().count() == 1 => c.to_uppercase().collect::<String>(),
134 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
135 None => name.clone(),
136 };
137 out.push(pretty);
138 out.join("+")
139 }
140
141 /// The `shortcut …` control-socket command. `args` is everything after the
142 /// word `shortcut`.
143 pub fn ipc(wm: &mut WindowManager, args: &[&str]) -> String {
144 match args.first().copied() {
145 Some("bind") => {
146 let [_, session, id, trigger] = args else {
147 return "error: usage: shortcut bind <session> <id> <trigger>\n".to_string();
148 };
149 let t = match parse_trigger(trigger) {
150 Ok(t) => t,
151 Err(e) => return format!("error: {e}\n"),
152 };
153 // The user's config owns its chords: a portal bind never
154 // shadows one, and the app hears that it was refused.
155 if wm.keybinds.iter().any(|kb| kb.mods == t.mods && kb.keysym == t.keysym) {
156 return format!("error: {} is a compositor keybind\n", t.description);
157 }
158 if let Some(other) = wm
159 .portal_shortcuts
160 .iter()
161 .find(|s| s.mods == t.mods && s.keysym == t.keysym && !(s.session == *session && s.id == *id))
162 {
163 return format!("error: {} is already bound by {} {}\n", t.description, other.session, other.id);
164 }
165 // Re-binding the same (session, id) replaces its chord.
166 wm.portal_shortcuts.retain(|s| !(s.session == *session && s.id == *id));
167 log::info!("[shortcut] bind {} {} -> {}", session, id, t.description);
168 wm.portal_shortcuts.push(PortalShortcut {
169 session: session.to_string(),
170 id: id.to_string(),
171 mods: t.mods,
172 keysym: t.keysym,
173 description: t.description.clone(),
174 });
175 format!("ok {}\n", t.description)
176 }
177 Some("unbind") => {
178 let (session, id) = match args {
179 [_, session] => (*session, None),
180 [_, session, id] => (*session, Some(*id)),
181 _ => return "error: usage: shortcut unbind <session> [<id>]\n".to_string(),
182 };
183 let before = wm.portal_shortcuts.len();
184 wm.portal_shortcuts.retain(|s| !(s.session == session && id.map_or(true, |id| s.id == id)));
185 log::info!("[shortcut] unbind {} {}: {} removed", session, id.unwrap_or("*"), before - wm.portal_shortcuts.len());
186 "ok\n".to_string()
187 }
188 Some("clear") => {
189 log::info!("[shortcut] clear: {} removed", wm.portal_shortcuts.len());
190 wm.portal_shortcuts.clear();
191 "ok\n".to_string()
192 }
193 Some("list") => {
194 let mut out = String::new();
195 for s in &wm.portal_shortcuts {
196 out.push_str(&format!("{} {} {}\n", s.session, s.id, s.description));
197 }
198 out
199 }
200 _ => "error: usage: shortcut bind|unbind|clear|list\n".to_string(),
201 }
202 }
203
204 #[cfg(test)]
205 mod tests {
206 use super::*;
207
208 #[test]
209 fn a_nul_in_the_key_name_is_an_error_not_a_panic() {
210 // xkbcommon's keysym_from_name unwraps a CString of the name; this
211 // used to abort the compositor from `shortcut bind`.
212 assert!(parse_trigger("CTRL+a\0b").is_err());
213 }
214
215 #[test]
216 fn parses_spec_triggers() {
217 let t = parse_trigger("CTRL+SHIFT+space").unwrap();
218 assert_eq!(t.mods, MOD_CTRL | MOD_SHIFT);
219 assert_eq!(t.keysym, u32::from(xkbcommon::xkb::keysyms::KEY_space));
220 assert_eq!(t.description, "Ctrl+Shift+Space");
221 }
222
223 #[test]
224 fn modifier_spelling_is_lenient_and_order_fixed() {
225 let a = parse_trigger("shift+logo+q").unwrap();
226 let b = parse_trigger("SUPER+SHIFT+Q").unwrap();
227 assert_eq!(a.mods, b.mods);
228 assert_eq!(a.keysym, b.keysym, "keysym lookup is case-insensitive");
229 assert_eq!(a.description, "Shift+Super+Q");
230 }
231
232 #[test]
233 fn plus_key_and_bare_key() {
234 assert_eq!(parse_trigger("CTRL++").unwrap().description, "Ctrl+Plus");
235 let f5 = parse_trigger("F5").unwrap();
236 assert_eq!(f5.mods, 0);
237 assert_eq!(f5.description, "F5");
238 }
239
240 #[test]
241 fn rejects_garbage() {
242 assert!(parse_trigger("").is_err());
243 assert!(parse_trigger("HYPER+a").is_err());
244 assert!(parse_trigger("CTRL+nosuchkey").is_err());
245 assert!(parse_trigger("CTRL+Shift_L").is_err(), "a lone modifier key is not a chord");
246 }
247 }