Skip to main content

frus_shell/
shortcuts.rs

1//! **Global shortcuts** (milestone 709, part of #219): a key combination that reaches the
2//! application whatever window has the keyboard — the clipboard manager's, the quick-add of a
3//! task list, the push-to-talk of a call.
4//!
5//! Declared from the state, like the tray: [`Application::global_shortcuts`](crate::Application::global_shortcuts),
6//! or `FrusApp::global_shortcuts` for an application of components. The shell registers the
7//! ones declared and gives back the ones withdrawn.
8//!
9//! ```
10//! # use frus_shell::GlobalShortcut;
11//! # #[derive(Clone)] enum Msg { QuickAdd }
12//! let shortcuts: Vec<GlobalShortcut<Msg>> = vec![
13//!     GlobalShortcut::new("Primary+Shift+Space", Msg::QuickAdd),
14//!     GlobalShortcut::show_window("Primary+Alt+T"),
15//! ];
16//! ```
17//!
18//! A combination is written as its modifiers and one key, joined by `+`: `Ctrl`, `Alt`
19//! (`Option`), `Shift`, `Super` (`Cmd`, `Meta`), and `Primary` for the platform's own — `Cmd`
20//! on macOS, `Ctrl` elsewhere; the key a letter, a figure, `F1`…`F24`, `Space`, `Enter`, `Tab`,
21//! the arrows (`ArrowUp`…), and the names of the others (`Comma`, `Slash`, `Backquote`…).
22//!
23//! **What a global shortcut can and cannot do** — the position #219 asked to be written down:
24//!
25//! - It is **one combination, registered with the system**, and the application is told when
26//!   that combination is pressed and of nothing else. It reads no other key: it is not a hook on
27//!   the keyboard, and on macOS needs none of the permissions a keyboard hook does.
28//! - While registered, the combination is **taken from every other application**, which no
29//!   longer receives it. So a combination must have a modifier other than `Shift`: a bare key,
30//!   or `Shift` and a key, would take typing away from the whole system, and is refused (with a
31//!   message in the log).
32//! - A combination another application holds **cannot be taken**: the system refuses it, and
33//!   the log says which. The application keeps its other shortcuts.
34//! - It is given back when the description no longer has it, and when the application ends.
35//!
36//! Windows, macOS, and Linux under **X11**. Wayland lets no application grab a combination for
37//! itself; its desktop portal asks the person instead, which is not done here. Behind the
38//! `global-shortcuts` feature, on the desktop.
39
40// Without the system's shortcuts, the description is read by nobody; it is tested either way.
41#![cfg_attr(not(all(desktop, feature = "global-shortcuts")), allow(dead_code))]
42
43/// What pressing a global shortcut does.
44pub(crate) enum ShortcutAction<Msg> {
45    /// Sends a message to the application.
46    Message(Msg),
47    /// Brings the window forward, showing it if it was hidden.
48    ShowWindow,
49}
50
51/// A key combination that reaches the application from anywhere, and what it does.
52pub struct GlobalShortcut<Msg> {
53    /// As written by the application.
54    pub(crate) keys: String,
55    pub(crate) action: ShortcutAction<Msg>,
56}
57
58impl<Msg> GlobalShortcut<Msg> {
59    /// `keys` — `"Primary+Shift+Space"` — sending `message` when pressed.
60    pub fn new(keys: impl Into<String>, message: Msg) -> Self {
61        Self {
62            keys: keys.into(),
63            action: ShortcutAction::Message(message),
64        }
65    }
66
67    /// `keys` bringing the application's window forward, showing it if it was hidden — what a
68    /// shortcut to an application most often is.
69    pub fn show_window(keys: impl Into<String>) -> Self {
70        Self {
71            keys: keys.into(),
72            action: ShortcutAction::ShowWindow,
73        }
74    }
75}
76
77/// `keys` as one canonical spelling — the modifiers in a fixed order, then the key, upper case
78/// — so that `"shift+ctrl+k"` and `"Ctrl+Shift+K"` are one shortcut; or why it is refused.
79/// `Primary` becomes `macos`'s `Cmd` and everyone else's `Ctrl`.
80pub(crate) fn canonical(keys: &str, macos: bool) -> Result<String, String> {
81    let (mut ctrl, mut alt, mut shift, mut sup) = (false, false, false, false);
82    let mut key: Option<String> = None;
83    for token in keys.split('+').map(str::trim) {
84        if token.is_empty() {
85            return Err(format!("{keys:?}: an empty part"));
86        }
87        match token.to_ascii_uppercase().as_str() {
88            "CTRL" | "CONTROL" => ctrl = true,
89            "ALT" | "OPTION" => alt = true,
90            "SHIFT" => shift = true,
91            "SUPER" | "CMD" | "COMMAND" | "META" => sup = true,
92            "PRIMARY" | "CMDORCTRL" | "COMMANDORCONTROL" => {
93                if macos {
94                    sup = true;
95                } else {
96                    ctrl = true;
97                }
98            }
99            other => {
100                if key.is_some() {
101                    return Err(format!("{keys:?}: more than one key"));
102                }
103                key = Some(other.to_string());
104            }
105        }
106    }
107    let Some(key) = key else {
108        return Err(format!("{keys:?}: no key"));
109    };
110    if !(ctrl || alt || sup) {
111        return Err(format!(
112            "{keys:?}: a global shortcut needs Ctrl, Alt or Super, or it takes typing from every \
113             other application"
114        ));
115    }
116    let mut parts = Vec::new();
117    for (on, name) in [
118        (ctrl, "CONTROL"),
119        (alt, "ALT"),
120        (shift, "SHIFT"),
121        (sup, "SUPER"),
122    ] {
123        if on {
124            parts.push(name.to_string());
125        }
126    }
127    parts.push(key);
128    Ok(parts.join("+"))
129}
130
131/// The global shortcuts as the system has them (desktop, `global-shortcuts` feature).
132#[cfg(all(desktop, feature = "global-shortcuts"))]
133pub(crate) mod native {
134    use std::collections::HashMap;
135    use std::str::FromStr;
136    use std::sync::{Arc, Mutex, Once};
137
138    use global_hotkey::hotkey::HotKey;
139    use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
140    use winit::window::Window;
141
142    /// What the system holds for the application: the manager, and each registered
143    /// combination by the id its presses carry.
144    #[derive(Default)]
145    pub(crate) struct Registered {
146        manager: Option<GlobalHotKeyManager>,
147        /// Id → (canonical keys, the system's hot key).
148        held: HashMap<u32, (String, HotKey)>,
149        /// Combinations already refused, so a refusal is told once, not at every change.
150        refused: Vec<String>,
151    }
152
153    /// The ids of the combinations pressed, waiting for the event loop.
154    static PRESSED: Mutex<Vec<u32>> = Mutex::new(Vec::new());
155    /// The window a press wakes, as the tray's events do.
156    static WINDOW: Mutex<Option<Arc<Window>>> = Mutex::new(None);
157
158    fn listen() {
159        static ONCE: Once = Once::new();
160        ONCE.call_once(|| {
161            GlobalHotKeyEvent::set_event_handler(Some(|event: GlobalHotKeyEvent| {
162                if event.state() != HotKeyState::Pressed {
163                    return;
164                }
165                if let Ok(mut pressed) = PRESSED.lock() {
166                    pressed.push(event.id());
167                }
168                if let Some(window) = WINDOW.lock().ok().and_then(|window| window.clone()) {
169                    window.request_redraw();
170                }
171            }));
172        });
173    }
174
175    /// The canonical keys of the combinations pressed since the last call.
176    pub(crate) fn take_pressed(registered: &Registered) -> Vec<String> {
177        let ids = PRESSED
178            .lock()
179            .map(|mut pressed| std::mem::take(&mut *pressed))
180            .unwrap_or_default();
181        ids.into_iter()
182            .filter_map(|id| registered.held.get(&id).map(|(keys, _)| keys.clone()))
183            .collect()
184    }
185
186    /// Makes the system hold exactly `wanted` (canonical keys): registers what is new, gives
187    /// back what was withdrawn.
188    pub(crate) fn sync(registered: &mut Registered, wanted: &[String], window: &Arc<Window>) {
189        if let Ok(mut slot) = WINDOW.lock() {
190            *slot = Some(window.clone());
191        }
192        if wanted.is_empty() && registered.held.is_empty() {
193            return;
194        }
195        if registered.manager.is_none() {
196            match GlobalHotKeyManager::new() {
197                Ok(manager) => registered.manager = Some(manager),
198                Err(err) => {
199                    log::warn!("global shortcuts: unavailable ({err})");
200                    return;
201                }
202            }
203            listen();
204        }
205        let Some(manager) = registered.manager.as_ref() else {
206            return;
207        };
208        registered.held.retain(|_, (keys, hotkey)| {
209            let keep = wanted.contains(keys);
210            if !keep {
211                let _ = manager.unregister(*hotkey);
212            }
213            keep
214        });
215        for keys in wanted {
216            if registered.held.values().any(|(held, _)| held == keys)
217                || registered.refused.contains(keys)
218            {
219                continue;
220            }
221            let hotkey = match HotKey::from_str(keys) {
222                Ok(hotkey) => hotkey,
223                Err(err) => {
224                    log::warn!("global shortcuts: {keys} is not a combination ({err})");
225                    registered.refused.push(keys.clone());
226                    continue;
227                }
228            };
229            match manager.register(hotkey) {
230                Ok(()) => {
231                    log::debug!("global shortcuts: {keys} registered");
232                    registered.held.insert(hotkey.id(), (keys.clone(), hotkey));
233                }
234                Err(err) => {
235                    log::warn!(
236                        "global shortcuts: {keys} could not be registered — another \
237                         application may hold it ({err})"
238                    );
239                    registered.refused.push(keys.clone());
240                }
241            }
242        }
243    }
244}
245
246#[cfg(test)]
247mod tests {
248    use super::*;
249
250    /// **One combination, one spelling**: the modifiers in a fixed order and the key upper case,
251    /// whatever order and case they were written in.
252    #[test]
253    fn a_combination_has_one_spelling() {
254        assert_eq!(
255            canonical("Ctrl+Shift+K", false).as_deref(),
256            Ok("CONTROL+SHIFT+K")
257        );
258        assert_eq!(
259            canonical("shift + ctrl + k", false).as_deref(),
260            Ok("CONTROL+SHIFT+K")
261        );
262        assert_eq!(canonical("Alt+Option+F5", false).as_deref(), Ok("ALT+F5"));
263        assert_eq!(canonical("Cmd+Space", true).as_deref(), Ok("SUPER+SPACE"));
264    }
265
266    /// **`Primary` is the platform's own**: `Cmd` on macOS, `Ctrl` elsewhere.
267    #[test]
268    fn primary_is_the_platforms_own() {
269        assert_eq!(
270            canonical("Primary+Shift+Space", true).as_deref(),
271            Ok("SHIFT+SUPER+SPACE")
272        );
273        assert_eq!(
274            canonical("Primary+Shift+Space", false).as_deref(),
275            Ok("CONTROL+SHIFT+SPACE")
276        );
277    }
278
279    /// **A combination that would take typing is refused**: a bare key, `Shift` and a key —
280    /// and so is one that is not a combination.
281    #[test]
282    fn what_would_take_typing_is_refused() {
283        assert!(canonical("K", false).is_err());
284        assert!(canonical("Shift+K", false).is_err());
285        assert!(canonical("Ctrl+Shift", false).is_err(), "no key");
286        assert!(canonical("Ctrl+K+L", false).is_err(), "two keys");
287        assert!(canonical("Ctrl++K", false).is_err(), "an empty part");
288        assert!(canonical("Ctrl+", false).is_err(), "an empty key");
289        assert!(canonical("Alt+K", false).is_ok());
290        assert!(canonical("Super+K", false).is_ok());
291    }
292
293    /// **The spelling is the system's**: every canonical spelling of a supported combination
294    /// parses as the library's hot key.
295    #[cfg(all(desktop, feature = "global-shortcuts"))]
296    #[test]
297    fn the_spelling_is_the_systems() {
298        use std::str::FromStr;
299        for keys in [
300            "Ctrl+Shift+K",
301            "Primary+Alt+Space",
302            "Alt+F5",
303            "Ctrl+Alt+Shift+ArrowUp",
304            "Super+1",
305        ] {
306            let spelled = canonical(keys, cfg!(target_os = "macos")).unwrap();
307            assert!(
308                global_hotkey::hotkey::HotKey::from_str(&spelled).is_ok(),
309                "{keys} -> {spelled}"
310            );
311        }
312    }
313}