Skip to main content

plat/
hotkey.rs

1// hotkey -- global keyboard shortcuts: chords that reach the app while another program has the
2// keyboard
3//
4//   Chord::parse("Ctrl+Alt+K") -> Option<Chord>   modifiers ctrl, alt, shift, super (win, meta,
5//                                     logo), one key: a-z 0-9 f1-f24, space enter tab esc
6//                                     backspace delete insert home end pageup pagedown left up
7//                                     right down print pause num0-num9 numadd numsub nummul
8//                                     numdiv numdec minus plus comma period (case ignored)
9//   chord.text() -> "Ctrl+Alt+K"      chord.trigger() -> "CTRL+ALT+k" (the XDG shortcuts
10//                                     notation of the GlobalShortcuts portal)
11//   hotkey_available() -> bool        global shortcuts work here
12//   hotkey_register(id, chord, description) -> Result<(), HotkeyError>   id != 0; the same id
13//                                     again replaces its chord; needs `input.global` in the
14//                                     manifest (else Denied, "PERMDENY input.global")
15//   hotkey_unregister(id) -> bool     hotkey_clear()   hotkey_list() -> [(id, chord)]
16//   hotkey_take() -> Vec<HotkeyEvent> Pressed(id) when the chord was pressed anywhere (held keys
17//                                     do not repeat), Refused(id, why) when the desktop refused
18//                                     a registration later (the portal); each one calls wake()
19//   set_dry_run(on)                   only record registrations (the UI's headless runs);
20//                                     hotkey_test_press(id) delivers a press (test aid)
21//
22// Call register / unregister from the UI thread: the presses arrive through its event loop.
23// Windows (win_hotkey.rs): RegisterHotKey (MOD_NOREPEAT) on a message-only window of the UI
24// thread, WM_HOTKEY; a chord another program holds is Conflict.  X11 (linux_x11_screen.rs):
25// XGrabKey on the root window for every keycode of the key and every combination of Caps Lock
26// and Num Lock, KeyPress on the root window; BadAccess (another client grabbed it) is
27// Conflict.  Wayland and Flatpak (linux_hotkey.rs): the GlobalShortcuts portal on a thread of
28// its own: a session, BindShortcuts with the description and the preferred trigger -- the
29// desktop asks the user, who may choose another chord or none; what is not bound comes as
30// Refused(id, Dismissed); Activated -> Pressed.  Browser and Android: Unavailable.
31
32use alloc::string::String;
33use alloc::vec::Vec;
34use core::sync::atomic::{AtomicBool, Ordering};
35
36use rt::sync::SpinLock;
37
38use crate::{MOD_ALT, MOD_CTRL, MOD_SHIFT, MOD_SUPER};
39
40/// a key with modifiers: VK code (the platform's E_KEY_DOWN codes) and MOD_SHIFT | MOD_CTRL |
41/// MOD_ALT | MOD_SUPER
42#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
43pub struct Chord {
44    pub vk: u32,
45    pub mods: u8,
46}
47
48/// why a shortcut was not registered
49#[derive(Clone, Debug, PartialEq, Eq)]
50#[non_exhaustive]
51pub enum HotkeyError {
52    /// no global shortcuts here (browser, Android, no X server / portal)
53    Unavailable,
54    /// the app's permissions lack `input.global`
55    Denied,
56    /// id 0, a key without a name, a description with NUL
57    Invalid,
58    /// another program (or another id of this app) holds the chord
59    Conflict,
60    /// the user or the desktop did not bind it (the portal's dialog)
61    Dismissed,
62    /// the system failed: what it said
63    Failed(String),
64}
65
66impl core::fmt::Display for HotkeyError {
67    fn fmt(&self, f: &mut core::fmt::Formatter) -> core::fmt::Result {
68        match self {
69            HotkeyError::Unavailable => f.write_str("global shortcuts are not available here"),
70            HotkeyError::Denied => f.write_str("the app's permissions do not allow global shortcuts"),
71            HotkeyError::Invalid => f.write_str("invalid shortcut"),
72            HotkeyError::Conflict => f.write_str("the shortcut is taken"),
73            HotkeyError::Dismissed => f.write_str("the shortcut was not bound"),
74            HotkeyError::Failed(s) => write!(f, "global shortcut: {s}"),
75        }
76    }
77}
78
79/// what happened to a registered shortcut
80#[derive(Clone, Debug, PartialEq, Eq)]
81pub enum HotkeyEvent {
82    /// its chord was pressed
83    Pressed(u32),
84    /// the desktop did not bind it after all (the portal answers later)
85    Refused(u32, HotkeyError),
86}
87
88// key names: (name, VK, XDG keysym name)
89static KEYS: &[(&str, u32, &str)] = &[
90    ("space", 0x20, "space"),
91    ("enter", 0x0D, "Return"),
92    ("return", 0x0D, "Return"),
93    ("tab", 0x09, "Tab"),
94    ("esc", 0x1B, "Escape"),
95    ("escape", 0x1B, "Escape"),
96    ("backspace", 0x08, "BackSpace"),
97    ("delete", 0x2E, "Delete"),
98    ("del", 0x2E, "Delete"),
99    ("insert", 0x2D, "Insert"),
100    ("ins", 0x2D, "Insert"),
101    ("home", 0x24, "Home"),
102    ("end", 0x23, "End"),
103    ("pageup", 0x21, "Page_Up"),
104    ("pgup", 0x21, "Page_Up"),
105    ("pagedown", 0x22, "Page_Down"),
106    ("pgdn", 0x22, "Page_Down"),
107    ("left", 0x25, "Left"),
108    ("up", 0x26, "Up"),
109    ("right", 0x27, "Right"),
110    ("down", 0x28, "Down"),
111    ("print", 0x2C, "Print"),
112    ("pause", 0x13, "Pause"),
113    ("numadd", 0x6B, "KP_Add"),
114    ("numsub", 0x6D, "KP_Subtract"),
115    ("nummul", 0x6A, "KP_Multiply"),
116    ("numdiv", 0x6F, "KP_Divide"),
117    ("numdec", 0x6E, "KP_Decimal"),
118    ("minus", 0xBD, "minus"),
119    ("plus", 0xBB, "plus"),
120    ("comma", 0xBC, "comma"),
121    ("period", 0xBE, "period"),
122];
123
124// the display name of a named key (Chord::text)
125fn key_display(vk: u32) -> Option<String> {
126    let s = match vk {
127        0x41..=0x5A | 0x30..=0x39 => return Some(String::from(char::from(vk as u8))),
128        0x70..=0x87 => return Some(alloc::format!("F{}", vk - 0x6F)),
129        0x60..=0x69 => return Some(alloc::format!("Num{}", vk - 0x60)),
130        0x20 => "Space",
131        0x0D => "Enter",
132        0x09 => "Tab",
133        0x1B => "Esc",
134        0x08 => "Backspace",
135        0x2E => "Delete",
136        0x2D => "Insert",
137        0x24 => "Home",
138        0x23 => "End",
139        0x21 => "PageUp",
140        0x22 => "PageDown",
141        0x25 => "Left",
142        0x26 => "Up",
143        0x27 => "Right",
144        0x28 => "Down",
145        0x2C => "Print",
146        0x13 => "Pause",
147        0x6B => "NumAdd",
148        0x6D => "NumSub",
149        0x6A => "NumMul",
150        0x6F => "NumDiv",
151        0x6E => "NumDec",
152        0xBD => "Minus",
153        0xBB => "Plus",
154        0xBC => "Comma",
155        0xBE => "Period",
156        _ => return None,
157    };
158    Some(String::from(s))
159}
160
161impl Chord {
162    /// "Ctrl+Alt+K", "super+f12", "Shift+NumAdd" -> the chord (None: no key, an unknown name,
163    /// two keys)
164    pub fn parse(s: &str) -> Option<Chord> {
165        let mut c = Chord::default();
166        for part in s.split('+') {
167            let p = part.trim().to_ascii_lowercase();
168            if p.is_empty() {
169                return None;
170            }
171            let m = match p.as_str() {
172                "ctrl" | "control" | "strg" => MOD_CTRL,
173                "alt" => MOD_ALT,
174                "shift" => MOD_SHIFT,
175                "super" | "win" | "meta" | "logo" | "cmd" => MOD_SUPER,
176                _ => 0,
177            };
178            if m != 0 {
179                c.mods |= m;
180                continue;
181            }
182            if c.vk != 0 {
183                return None; // a second key
184            }
185            let b = p.as_bytes();
186            c.vk = if b.len() == 1 && b[0].is_ascii_alphanumeric() {
187                b[0].to_ascii_uppercase() as u32
188            } else if let Some(n) = p.strip_prefix('f').and_then(|n| n.parse::<u32>().ok()).filter(|n| (1..=24).contains(n)) {
189                0x6F + n
190            } else if let Some(n) = p.strip_prefix("num").and_then(|n| n.parse::<u32>().ok()).filter(|n| *n <= 9) {
191                0x60 + n
192            } else {
193                KEYS.iter().find(|k| k.0 == p).map(|k| k.1)?
194            };
195        }
196        (c.vk != 0).then_some(c)
197    }
198
199    /// "Ctrl+Alt+K" (the modifiers in the order Ctrl, Alt, Shift, Super)
200    pub fn text(&self) -> String {
201        let mut s = String::new();
202        for (m, n) in [(MOD_CTRL, "Ctrl+"), (MOD_ALT, "Alt+"), (MOD_SHIFT, "Shift+"), (MOD_SUPER, "Super+")] {
203            if self.mods & m != 0 {
204                s.push_str(n);
205            }
206        }
207        s.push_str(&key_display(self.vk).unwrap_or_else(|| alloc::format!("0x{:02X}", self.vk)));
208        s
209    }
210
211    /// the XDG shortcuts notation ("CTRL+ALT+k", "LOGO+F12"): modifiers CTRL ALT SHIFT LOGO,
212    /// the key as its xkb keysym name; None for a key without one
213    pub fn trigger(&self) -> Option<String> {
214        let key = match self.vk {
215            0x41..=0x5A => String::from(char::from(self.vk as u8 + 32)),
216            0x30..=0x39 => String::from(char::from(self.vk as u8)),
217            0x70..=0x87 => alloc::format!("F{}", self.vk - 0x6F),
218            0x60..=0x69 => alloc::format!("KP_{}", self.vk - 0x60),
219            v => String::from(KEYS.iter().find(|k| k.1 == v)?.2),
220        };
221        let mut s = String::new();
222        for (m, n) in [(MOD_CTRL, "CTRL+"), (MOD_ALT, "ALT+"), (MOD_SHIFT, "SHIFT+"), (MOD_SUPER, "LOGO+")] {
223            if self.mods & m != 0 {
224                s.push_str(n);
225            }
226        }
227        s.push_str(&key);
228        Some(s)
229    }
230}
231
232// the registrations: id, chord, description
233static REG: SpinLock<Vec<(u32, Chord, String)>> = SpinLock::new(Vec::new());
234static EVENTS: SpinLock<Vec<HotkeyEvent>> = SpinLock::new(Vec::new());
235static DRY: AtomicBool = AtomicBool::new(false);
236const EVENTS_MAX: usize = 64;
237
238/// (platform) queue an event and wake the UI thread
239#[allow(dead_code)]
240pub(crate) fn push(e: HotkeyEvent) {
241    {
242        let mut q = EVENTS.lock();
243        if q.len() == EVENTS_MAX {
244            q.remove(0);
245        }
246        q.push(e);
247    }
248    crate::wake();
249}
250
251/// (platform) a registration the desktop dropped later: forgotten, Refused queued
252#[allow(dead_code)]
253pub(crate) fn refused(id: u32, why: HotkeyError) {
254    REG.lock().retain(|r| r.0 != id);
255    push(HotkeyEvent::Refused(id, why));
256}
257
258/// (platform) whether id is registered
259#[allow(dead_code)]
260pub(crate) fn registered(id: u32) -> bool {
261    REG.lock().iter().any(|r| r.0 == id)
262}
263
264/// what happened since the last call, oldest first
265pub fn hotkey_take() -> Vec<HotkeyEvent> {
266    core::mem::take(&mut *EVENTS.lock())
267}
268
269/// only record registrations from now on (the UI's headless runs: scripts, --shot)
270pub fn set_dry_run(on: bool) {
271    DRY.store(on, Ordering::Relaxed);
272}
273
274fn dry_run() -> bool {
275    DRY.load(Ordering::Relaxed)
276}
277
278/// whether global shortcuts work here
279pub fn hotkey_available() -> bool {
280    dry_run() || native::available()
281}
282
283/// register chord as the global shortcut id (the same id again replaces it); description says
284/// what it does (the portal's dialog shows it)
285pub fn hotkey_register(id: u32, chord: Chord, description: &str) -> Result<(), HotkeyError> {
286    if id == 0 || chord.vk == 0 || chord.vk > 0xFF || description.contains('\0') || chord.trigger().is_none() && !cfg!(windows) {
287        return Err(HotkeyError::Invalid);
288    }
289    if !crate::policy::input_global_allowed() {
290        return Err(HotkeyError::Denied);
291    }
292    if REG.lock().iter().any(|r| r.0 != id && r.1 == chord) {
293        return Err(HotkeyError::Conflict); // another id of this app has it
294    }
295    if REG.lock().iter().any(|r| r.0 == id) {
296        hotkey_unregister(id);
297    }
298    // recorded first: the portal's thread binds the set it reads
299    REG.lock().push((id, chord, String::from(description)));
300    if !dry_run() {
301        if let Err(e) = native::register(id, chord, description) {
302            REG.lock().retain(|r| r.0 != id);
303            return Err(e);
304        }
305    }
306    Ok(())
307}
308
309/// remove the shortcut id -> false when it was not registered
310pub fn hotkey_unregister(id: u32) -> bool {
311    let had = {
312        let mut r = REG.lock();
313        let n = r.len();
314        r.retain(|e| e.0 != id);
315        r.len() != n
316    };
317    if had && !dry_run() {
318        native::unregister(id);
319    }
320    had
321}
322
323/// remove every shortcut of the app
324pub fn hotkey_clear() {
325    let ids: Vec<u32> = REG.lock().iter().map(|r| r.0).collect();
326    for id in ids {
327        hotkey_unregister(id);
328    }
329}
330
331/// the registered shortcuts: (id, chord)
332pub fn hotkey_list() -> Vec<(u32, Chord)> {
333    REG.lock().iter().map(|r| (r.0, r.1)).collect()
334}
335
336/// (platform) the registrations with their descriptions
337#[allow(dead_code)]
338pub(crate) fn hotkey_entries() -> Vec<(u32, Chord, String)> {
339    REG.lock().clone()
340}
341
342/// (test aid) a press of the registered shortcut id, as the system would report it -> false
343/// when id is not registered
344pub fn hotkey_test_press(id: u32) -> bool {
345    if !registered(id) {
346        return false;
347    }
348    push(HotkeyEvent::Pressed(id));
349    true
350}
351
352#[cfg(windows)]
353use crate::win_hotkey as native;
354
355#[cfg(target_os = "linux")]
356use crate::linux_hotkey as native;
357
358#[cfg(not(any(windows, target_os = "linux")))]
359mod native {
360    use super::{Chord, HotkeyError};
361    pub fn available() -> bool {
362        false
363    }
364    pub fn register(_id: u32, _c: Chord, _d: &str) -> Result<(), HotkeyError> {
365        Err(HotkeyError::Unavailable)
366    }
367    pub fn unregister(_id: u32) {}
368}