git.lucas.co / cce-compositor
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 }