Skip to main content

soma_tauri_shell/
shortcut.rs

1//! Global shortcut parsing, registration, rebind, and active-shortcut
2//! tracking.
3//!
4//! This module owns generic `"Modifier+Modifier+Key"` label parsing and the
5//! register/unregister mechanics of [`tauri_plugin_global_shortcut`]. It has
6//! no opinion on which shortcuts a product allows or defaults to — that
7//! policy (e.g. restricting users to a fixed allow-list of labels) belongs to
8//! the app.
9
10use std::sync::Mutex;
11
12use tauri::AppHandle;
13use tauri_plugin_global_shortcut::{Code, GlobalShortcutExt, Modifiers, Shortcut};
14
15use crate::command::CommandResult;
16
17/// Tracks the shortcut label currently registered, so callers can unregister
18/// only that specific shortcut (rather than every shortcut in the process)
19/// when the user rebinds their hotkey.
20#[derive(Default)]
21pub struct ActiveShortcutState(Mutex<Option<String>>);
22
23impl ActiveShortcutState {
24    #[must_use]
25    pub fn new() -> Self {
26        Self(Mutex::new(None))
27    }
28
29    /// The currently-registered label, if any and if the lock isn't
30    /// poisoned.
31    pub fn current(&self) -> Option<String> {
32        self.0.lock().ok().and_then(|guard| guard.clone())
33    }
34}
35
36/// Parse a shortcut label of the form `"Modifier+Modifier+Key"` (e.g.
37/// `"Ctrl+Shift+Space"`, `"Alt+F1"`, `"Cmd+K"`) into a [`Shortcut`].
38///
39/// Recognized modifiers (case-insensitive): `ctrl`/`control`,
40/// `alt`/`option`, `shift`, `cmd`/`command`/`super`/`meta`. Recognized keys:
41/// single ASCII letters and digits, `space`, `enter`/`return`, `tab`,
42/// `escape`/`esc`, arrow keys (`up`/`down`/`left`/`right`), and `f1`-`f12`.
43/// Returns `None` for an empty label, a label with no key segment, or an
44/// unrecognized modifier/key token.
45pub fn parse_shortcut(label: &str) -> Option<Shortcut> {
46    let mut segments: Vec<&str> = label
47        .split('+')
48        .map(str::trim)
49        .filter(|s| !s.is_empty())
50        .collect();
51    let key_token = segments.pop()?;
52    let code = parse_code(key_token)?;
53
54    let mut modifiers = Modifiers::empty();
55    for token in segments {
56        modifiers |= parse_modifier(token)?;
57    }
58    let modifiers = if modifiers.is_empty() {
59        None
60    } else {
61        Some(modifiers)
62    };
63    Some(Shortcut::new(modifiers, code))
64}
65
66fn parse_modifier(token: &str) -> Option<Modifiers> {
67    match token.to_ascii_lowercase().as_str() {
68        "ctrl" | "control" => Some(Modifiers::CONTROL),
69        "alt" | "option" => Some(Modifiers::ALT),
70        "shift" => Some(Modifiers::SHIFT),
71        "cmd" | "command" | "super" | "meta" => Some(Modifiers::SUPER),
72        _ => None,
73    }
74}
75
76fn parse_code(token: &str) -> Option<Code> {
77    let lower = token.to_ascii_lowercase();
78    let code = match lower.as_str() {
79        "space" => Code::Space,
80        "enter" | "return" => Code::Enter,
81        "tab" => Code::Tab,
82        "escape" | "esc" => Code::Escape,
83        "up" => Code::ArrowUp,
84        "down" => Code::ArrowDown,
85        "left" => Code::ArrowLeft,
86        "right" => Code::ArrowRight,
87        "f1" => Code::F1,
88        "f2" => Code::F2,
89        "f3" => Code::F3,
90        "f4" => Code::F4,
91        "f5" => Code::F5,
92        "f6" => Code::F6,
93        "f7" => Code::F7,
94        "f8" => Code::F8,
95        "f9" => Code::F9,
96        "f10" => Code::F10,
97        "f11" => Code::F11,
98        "f12" => Code::F12,
99        _ if lower.len() == 1 => single_char_code(lower.chars().next()?)?,
100        _ => return None,
101    };
102    Some(code)
103}
104
105fn single_char_code(ch: char) -> Option<Code> {
106    match ch {
107        'a' => Some(Code::KeyA),
108        'b' => Some(Code::KeyB),
109        'c' => Some(Code::KeyC),
110        'd' => Some(Code::KeyD),
111        'e' => Some(Code::KeyE),
112        'f' => Some(Code::KeyF),
113        'g' => Some(Code::KeyG),
114        'h' => Some(Code::KeyH),
115        'i' => Some(Code::KeyI),
116        'j' => Some(Code::KeyJ),
117        'k' => Some(Code::KeyK),
118        'l' => Some(Code::KeyL),
119        'm' => Some(Code::KeyM),
120        'n' => Some(Code::KeyN),
121        'o' => Some(Code::KeyO),
122        'p' => Some(Code::KeyP),
123        'q' => Some(Code::KeyQ),
124        'r' => Some(Code::KeyR),
125        's' => Some(Code::KeyS),
126        't' => Some(Code::KeyT),
127        'u' => Some(Code::KeyU),
128        'v' => Some(Code::KeyV),
129        'w' => Some(Code::KeyW),
130        'x' => Some(Code::KeyX),
131        'y' => Some(Code::KeyY),
132        'z' => Some(Code::KeyZ),
133        '0' => Some(Code::Digit0),
134        '1' => Some(Code::Digit1),
135        '2' => Some(Code::Digit2),
136        '3' => Some(Code::Digit3),
137        '4' => Some(Code::Digit4),
138        '5' => Some(Code::Digit5),
139        '6' => Some(Code::Digit6),
140        '7' => Some(Code::Digit7),
141        '8' => Some(Code::Digit8),
142        '9' => Some(Code::Digit9),
143        _ => None,
144    }
145}
146
147/// Register `new_label`'s shortcut, first unregistering whatever shortcut is
148/// currently tracked in `state` if it differs. No-ops if `new_label` is
149/// already the active shortcut (re-registering an already-registered hotkey
150/// errors upstream).
151pub fn register_shortcut(
152    app: &AppHandle,
153    state: &ActiveShortcutState,
154    new_label: &str,
155) -> CommandResult<()> {
156    let new_shortcut = parse_shortcut(new_label)
157        .ok_or_else(|| format!("shortcut label `{new_label}` could not be parsed"))?;
158
159    let Ok(mut guard) = state.0.lock() else {
160        // Mutex poisoned (some other code path panicked while holding the
161        // lock) — fall back to unregister_all for safety. Log at `error`
162        // so poisoning has a trail; this clears every global shortcut in
163        // the process, not just this app's own, which is worth flagging
164        // loudly rather than silently.
165        tracing::error!(
166            "ActiveShortcutState mutex poisoned; falling back to unregister_all before registering '{new_label}'"
167        );
168        app.global_shortcut()
169            .unregister_all()
170            .map_err(|err| err.to_string())?;
171        app.global_shortcut()
172            .register(new_shortcut)
173            .map_err(|err| err.to_string())?;
174        return Ok(());
175    };
176
177    if guard.as_deref() == Some(new_label) {
178        return Ok(());
179    }
180    if let Some(old_label) = guard.take() {
181        if let Some(old_shortcut) = parse_shortcut(&old_label) {
182            if let Err(err) = app.global_shortcut().unregister(old_shortcut) {
183                tracing::warn!("failed to unregister old shortcut '{old_label}': {err}");
184            }
185        }
186    }
187    app.global_shortcut()
188        .register(new_shortcut)
189        .map_err(|err| err.to_string())?;
190    *guard = Some(new_label.to_string());
191    Ok(())
192}
193
194#[cfg(test)]
195#[path = "shortcut_tests.rs"]
196mod tests;