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