Wayland compositor (wlroots)
git clone https://git.lucas.co/cce-compositor.git
src/server/global_shortcuts.rs (9.4K)
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 let keysym: u32 = xkbcommon::xkb::keysym_from_name(key, xkbcommon::xkb::KEYSYM_CASE_INSENSITIVE).into();
97 if keysym == 0 {
98 return Err(format!("unknown key {key:?}"));
99 }
100 if unsafe { crate::keyboard::keysym_is_modifier(keysym) } {
101 return Err(format!("{key:?} is a modifier, not a key"));
102 }
103 Ok(Trigger { mods, keysym, description: describe(mods, keysym) })
104 }
105
106 /// Human form for the app to render: `Ctrl+Shift+Space`. Modifier order is
107 /// fixed regardless of how the trigger was written.
108 fn describe(mods: u32, keysym: u32) -> String {
109 let mut out = Vec::new();
110 if mods & MOD_CTRL != 0 {
111 out.push("Ctrl".to_string());
112 }
113 if mods & MOD_ALT != 0 {
114 out.push("Alt".to_string());
115 }
116 if mods & MOD_SHIFT != 0 {
117 out.push("Shift".to_string());
118 }
119 if mods & MOD_LOGO != 0 {
120 out.push("Super".to_string());
121 }
122 let name = xkbcommon::xkb::keysym_get_name(xkbcommon::xkb::Keysym::new(keysym));
123 // Single letters read better upper-case; multi-letter names (`space`,
124 // `Return`, `F5`) get an initial capital and are otherwise left alone.
125 let mut chars = name.chars();
126 let pretty = match chars.next() {
127 Some(c) if name.chars().count() == 1 => c.to_uppercase().collect::<String>(),
128 Some(c) => c.to_uppercase().collect::<String>() + chars.as_str(),
129 None => name.clone(),
130 };
131 out.push(pretty);
132 out.join("+")
133 }
134
135 /// The `shortcut …` control-socket command. `args` is everything after the
136 /// word `shortcut`.
137 pub fn ipc(wm: &mut WindowManager, args: &[&str]) -> String {
138 match args.first().copied() {
139 Some("bind") => {
140 let [_, session, id, trigger] = args else {
141 return "error: usage: shortcut bind <session> <id> <trigger>\n".to_string();
142 };
143 let t = match parse_trigger(trigger) {
144 Ok(t) => t,
145 Err(e) => return format!("error: {e}\n"),
146 };
147 // The user's config owns its chords: a portal bind never
148 // shadows one, and the app hears that it was refused.
149 if wm.keybinds.iter().any(|kb| kb.mods == t.mods && kb.keysym == t.keysym) {
150 return format!("error: {} is a compositor keybind\n", t.description);
151 }
152 if let Some(other) = wm
153 .portal_shortcuts
154 .iter()
155 .find(|s| s.mods == t.mods && s.keysym == t.keysym && !(s.session == *session && s.id == *id))
156 {
157 return format!("error: {} is already bound by {} {}\n", t.description, other.session, other.id);
158 }
159 // Re-binding the same (session, id) replaces its chord.
160 wm.portal_shortcuts.retain(|s| !(s.session == *session && s.id == *id));
161 log::info!("[shortcut] bind {} {} -> {}", session, id, t.description);
162 wm.portal_shortcuts.push(PortalShortcut {
163 session: session.to_string(),
164 id: id.to_string(),
165 mods: t.mods,
166 keysym: t.keysym,
167 description: t.description.clone(),
168 });
169 format!("ok {}\n", t.description)
170 }
171 Some("unbind") => {
172 let (session, id) = match args {
173 [_, session] => (*session, None),
174 [_, session, id] => (*session, Some(*id)),
175 _ => return "error: usage: shortcut unbind <session> [<id>]\n".to_string(),
176 };
177 let before = wm.portal_shortcuts.len();
178 wm.portal_shortcuts.retain(|s| !(s.session == session && id.map_or(true, |id| s.id == id)));
179 log::info!("[shortcut] unbind {} {}: {} removed", session, id.unwrap_or("*"), before - wm.portal_shortcuts.len());
180 "ok\n".to_string()
181 }
182 Some("clear") => {
183 log::info!("[shortcut] clear: {} removed", wm.portal_shortcuts.len());
184 wm.portal_shortcuts.clear();
185 "ok\n".to_string()
186 }
187 Some("list") => {
188 let mut out = String::new();
189 for s in &wm.portal_shortcuts {
190 out.push_str(&format!("{} {} {}\n", s.session, s.id, s.description));
191 }
192 out
193 }
194 _ => "error: usage: shortcut bind|unbind|clear|list\n".to_string(),
195 }
196 }
197
198 #[cfg(test)]
199 mod tests {
200 use super::*;
201
202 #[test]
203 fn parses_spec_triggers() {
204 let t = parse_trigger("CTRL+SHIFT+space").unwrap();
205 assert_eq!(t.mods, MOD_CTRL | MOD_SHIFT);
206 assert_eq!(t.keysym, u32::from(xkbcommon::xkb::keysyms::KEY_space));
207 assert_eq!(t.description, "Ctrl+Shift+Space");
208 }
209
210 #[test]
211 fn modifier_spelling_is_lenient_and_order_fixed() {
212 let a = parse_trigger("shift+logo+q").unwrap();
213 let b = parse_trigger("SUPER+SHIFT+Q").unwrap();
214 assert_eq!(a.mods, b.mods);
215 assert_eq!(a.keysym, b.keysym, "keysym lookup is case-insensitive");
216 assert_eq!(a.description, "Shift+Super+Q");
217 }
218
219 #[test]
220 fn plus_key_and_bare_key() {
221 assert_eq!(parse_trigger("CTRL++").unwrap().description, "Ctrl+Plus");
222 let f5 = parse_trigger("F5").unwrap();
223 assert_eq!(f5.mods, 0);
224 assert_eq!(f5.description, "F5");
225 }
226
227 #[test]
228 fn rejects_garbage() {
229 assert!(parse_trigger("").is_err());
230 assert!(parse_trigger("HYPER+a").is_err());
231 assert!(parse_trigger("CTRL+nosuchkey").is_err());
232 assert!(parse_trigger("CTRL+Shift_L").is_err(), "a lone modifier key is not a chord");
233 }
234 }