Skip to main content

ai_usagebar/tray/
hotkey.rs

1//! Global toggle shortcut for the tray popover.
2//!
3//! [`normalize`] is pure and compiles everywhere so Linux CI can test it: it
4//! turns whatever the user typed ("shift + ctrl + u") into the one canonical
5//! spelling the Settings page echoes back ("Ctrl+Shift+U") and the dialect
6//! `global_hotkey::hotkey::HotKey::from_str` parses ("control+shift+KeyU").
7//! [`HotkeyBinding`] is the Windows-only registration on top of it.
8
9use std::fmt::Write as _;
10
11/// Canonical spelling shown to the user ("Ctrl+Shift+U") plus the dialect
12/// `global_hotkey::hotkey::HotKey::from_str` parses ("control+shift+KeyU").
13#[derive(Debug, Clone, PartialEq, Eq)]
14pub struct Normalized {
15    pub canonical: String,
16    pub crate_form: String,
17}
18
19/// Longest slice of an unrecognised token echoed back in an error. The text
20/// came from a config file or a text box, so it is data, not a message.
21const MAX_ECHOED_TOKEN_CHARS: usize = 16;
22
23/// Parse a user-typed shortcut into its canonical and crate spellings.
24///
25/// Tokens are split on `+`, trimmed, and matched case-insensitively. At least
26/// one of Ctrl/Alt/Win is required (Shift alone would shadow typing) and
27/// exactly one non-modifier key. Escape is refused because it already closes
28/// the popover. Errors are user-facing sentences.
29pub fn normalize(text: &str) -> Result<Normalized, String> {
30    if text.trim().is_empty() {
31        return Err("Enter a shortcut".to_string());
32    }
33
34    let mut ctrl = false;
35    let mut alt = false;
36    let mut shift = false;
37    let mut win = false;
38    let mut key: Option<Key> = None;
39
40    for raw in text.split('+') {
41        let token = raw.trim();
42        if token.is_empty() {
43            return Err("Put a key between each '+'".to_string());
44        }
45        match token.to_ascii_lowercase().as_str() {
46            "ctrl" | "control" => ctrl = true,
47            "alt" | "option" => alt = true,
48            "shift" => shift = true,
49            "win" | "super" | "meta" | "cmd" | "command" => win = true,
50            _ => {
51                if key.is_some() {
52                    return Err("Use exactly one key, for example Ctrl+Shift+U".to_string());
53                }
54                key = Some(parse_key(token)?);
55            }
56        }
57    }
58
59    let Some(key) = key else {
60        return Err("Add a key, for example Ctrl+Shift+U".to_string());
61    };
62    if !(ctrl || alt || win) {
63        return Err("Add Ctrl, Alt or Win".to_string());
64    }
65
66    let mut canonical = String::new();
67    let mut crate_form = String::new();
68    for (on, shown, parsed) in [
69        (ctrl, "Ctrl", "control"),
70        (alt, "Alt", "alt"),
71        (shift, "Shift", "shift"),
72        (win, "Win", "super"),
73    ] {
74        if on {
75            let _ = write!(canonical, "{shown}+");
76            let _ = write!(crate_form, "{parsed}+");
77        }
78    }
79    canonical.push_str(&key.canonical);
80    crate_form.push_str(&key.crate_form);
81
82    Ok(Normalized {
83        canonical,
84        crate_form,
85    })
86}
87
88struct Key {
89    canonical: String,
90    crate_form: String,
91}
92
93impl Key {
94    fn fixed(canonical: &str, crate_form: &str) -> Self {
95        Self {
96            canonical: canonical.to_string(),
97            crate_form: crate_form.to_string(),
98        }
99    }
100}
101
102fn parse_key(token: &str) -> Result<Key, String> {
103    let mut chars = token.chars();
104    if let (Some(ch), None) = (chars.next(), chars.next()) {
105        if ch.is_ascii_alphabetic() {
106            let upper = ch.to_ascii_uppercase();
107            return Ok(Key {
108                canonical: upper.to_string(),
109                crate_form: format!("Key{upper}"),
110            });
111        }
112        if ch.is_ascii_digit() {
113            return Ok(Key {
114                canonical: ch.to_string(),
115                crate_form: format!("Digit{ch}"),
116            });
117        }
118        let punctuation = match ch {
119            '-' => Some("Minus"),
120            '=' => Some("Equal"),
121            '[' => Some("BracketLeft"),
122            ']' => Some("BracketRight"),
123            '\\' => Some("Backslash"),
124            ';' => Some("Semicolon"),
125            '\'' => Some("Quote"),
126            ',' => Some("Comma"),
127            '.' => Some("Period"),
128            '/' => Some("Slash"),
129            '`' => Some("Backquote"),
130            _ => None,
131        };
132        if let Some(crate_form) = punctuation {
133            return Ok(Key::fixed(&ch.to_string(), crate_form));
134        }
135    }
136
137    let lower = token.to_ascii_lowercase();
138    if let Some(number) = lower.strip_prefix('f')
139        && number.bytes().all(|b| b.is_ascii_digit())
140        && let Ok(n) = number.parse::<u8>()
141        && (1..=24).contains(&n)
142    {
143        let name = format!("F{n}");
144        return Ok(Key::fixed(&name, &name));
145    }
146
147    let named = match lower.as_str() {
148        "space" => Some(("Space", "Space")),
149        "enter" | "return" => Some(("Enter", "Enter")),
150        "tab" => Some(("Tab", "Tab")),
151        "backspace" => Some(("Backspace", "Backspace")),
152        "delete" | "del" => Some(("Delete", "Delete")),
153        "insert" | "ins" => Some(("Insert", "Insert")),
154        "home" => Some(("Home", "Home")),
155        "end" => Some(("End", "End")),
156        "pageup" | "pgup" => Some(("PageUp", "PageUp")),
157        "pagedown" | "pgdn" => Some(("PageDown", "PageDown")),
158        "up" | "arrowup" => Some(("Up", "ArrowUp")),
159        "down" | "arrowdown" => Some(("Down", "ArrowDown")),
160        "left" | "arrowleft" => Some(("Left", "ArrowLeft")),
161        "right" | "arrowright" => Some(("Right", "ArrowRight")),
162        "escape" | "esc" => return Err("Escape closes the popover".to_string()),
163        _ => None,
164    };
165    if let Some((canonical, crate_form)) = named {
166        return Ok(Key::fixed(canonical, crate_form));
167    }
168
169    Err(format!("Unsupported key: {}", echo_token(token)))
170}
171
172/// Bound and ASCII-only: the token is echoed into a sentence a UI renders.
173fn echo_token(token: &str) -> String {
174    let echoed: String = token
175        .chars()
176        .filter(char::is_ascii_graphic)
177        .take(MAX_ECHOED_TOKEN_CHARS)
178        .collect();
179    if echoed.is_empty() {
180        "?".to_string()
181    } else {
182        echoed
183    }
184}
185
186#[cfg(windows)]
187mod binding {
188    use global_hotkey::hotkey::HotKey;
189    use global_hotkey::{GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
190
191    use crate::display::sanitize_untrusted_line;
192
193    use super::normalize;
194
195    /// The one registered global shortcut, or none.
196    ///
197    /// The manager owns a message-only window that receives `WM_HOTKEY`, so
198    /// the binding lives on the thread that pumps messages: the tao
199    /// event-loop thread, the same one the tray icon lives on.
200    pub struct HotkeyBinding {
201        manager: GlobalHotKeyManager,
202        current: Option<HotKey>,
203    }
204
205    impl HotkeyBinding {
206        /// Must be created on the thread that runs the win32 message loop
207        /// (the tao event-loop thread).
208        pub fn new() -> Result<Self, String> {
209            let manager = GlobalHotKeyManager::new().map_err(|err| {
210                format!(
211                    "Could not set up the global shortcut: {}",
212                    sanitize_untrusted_line(&err.to_string())
213                )
214            })?;
215            Ok(Self {
216                manager,
217                current: None,
218            })
219        }
220
221        /// Replace the registered shortcut. `None` unregisters. On failure
222        /// nothing stays registered and the error is a user-facing sentence
223        /// ("Ctrl+Shift+U is already used by another app").
224        pub fn apply(&mut self, canonical: Option<&str>) -> Result<(), String> {
225            let wanted = match canonical {
226                None => None,
227                Some(text) => {
228                    let normalized = normalize(text)?;
229                    let hotkey: HotKey = normalized.crate_form.parse().map_err(|err| {
230                        format!(
231                            "{} is not a shortcut this build can register: {}",
232                            normalized.canonical,
233                            sanitize_untrusted_line(&format!("{err}"))
234                        )
235                    })?;
236                    Some((normalized.canonical, hotkey))
237                }
238            };
239
240            if let (Some(current), Some((_, hotkey))) = (self.current, &wanted)
241                && current == *hotkey
242            {
243                return Ok(());
244            }
245
246            if let Some(previous) = self.current.take() {
247                self.manager.unregister(previous).map_err(|err| {
248                    format!(
249                        "Could not release the previous shortcut: {}",
250                        sanitize_untrusted_line(&err.to_string())
251                    )
252                })?;
253            }
254
255            let Some((canonical, hotkey)) = wanted else {
256                return Ok(());
257            };
258            self.manager
259                .register(hotkey)
260                .map_err(|err| register_failure(&canonical, &err))?;
261            self.current = Some(hotkey);
262            Ok(())
263        }
264
265        /// Id the crate reports in press events for the registered shortcut.
266        pub fn current_id(&self) -> Option<u32> {
267            self.current.map(|hotkey| hotkey.id())
268        }
269    }
270
271    /// Turn a registration failure into the sentence the Settings page shows.
272    fn register_failure(canonical: &str, err: &global_hotkey::Error) -> String {
273        match err {
274            global_hotkey::Error::AlreadyRegistered(_) => {
275                format!("{canonical} is already used by another app")
276            }
277            other => format!(
278                "Could not register {canonical}: {}",
279                sanitize_untrusted_line(&other.to_string())
280            ),
281        }
282    }
283
284    /// Route presses (`HotKeyState::Pressed` only) to the caller; replaces the
285    /// crate's channel receiver. The crate keeps the first handler installed
286    /// for the life of the process, so call this once.
287    pub fn install_press_handler<F: Fn(u32) + Send + Sync + 'static>(handler: F) {
288        GlobalHotKeyEvent::set_event_handler(Some(move |event: GlobalHotKeyEvent| {
289            if event.state() == HotKeyState::Pressed {
290                handler(event.id());
291            }
292        }));
293    }
294
295    #[cfg(test)]
296    mod tests {
297        use std::str::FromStr;
298
299        use global_hotkey::hotkey::{Code, HotKey, Modifiers};
300
301        use super::super::normalize;
302        use super::register_failure;
303
304        #[test]
305        fn crate_parses_the_crate_form_we_emit() {
306            let normalized = normalize("Ctrl+Shift+U").expect("valid shortcut");
307            let hotkey = HotKey::from_str(&normalized.crate_form).expect("crate accepts it");
308            assert_eq!(hotkey.mods, Modifiers::CONTROL | Modifiers::SHIFT);
309            assert_eq!(hotkey.key, Code::KeyU);
310        }
311
312        #[test]
313        fn crate_parses_every_key_family_we_emit() {
314            for text in [
315                "Win+Alt+F12",
316                "Ctrl+1",
317                "Alt+Up",
318                "Ctrl+Alt+Space",
319                "Ctrl+`",
320                "Ctrl+Shift+\\",
321            ] {
322                let normalized = normalize(text).expect(text);
323                HotKey::from_str(&normalized.crate_form).expect(text);
324            }
325        }
326
327        #[test]
328        fn already_registered_becomes_a_friendly_sentence() {
329            let hotkey = HotKey::new(Some(Modifiers::CONTROL), Code::KeyU);
330            let message =
331                register_failure("Ctrl+U", &global_hotkey::Error::AlreadyRegistered(hotkey));
332            assert_eq!(message, "Ctrl+U is already used by another app");
333        }
334
335        #[test]
336        fn other_failures_name_the_shortcut_and_strip_controls() {
337            let err = global_hotkey::Error::FailedToRegister("bad\u{1b}[31m vk".to_string());
338            let message = register_failure("Ctrl+U", &err);
339            assert!(
340                message.starts_with("Could not register Ctrl+U: "),
341                "{message}"
342            );
343            assert!(!message.contains('\u{1b}'), "{message}");
344        }
345    }
346}
347
348#[cfg(windows)]
349pub use binding::{HotkeyBinding, install_press_handler};
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354
355    fn canonical(text: &str) -> String {
356        normalize(text)
357            .unwrap_or_else(|err| panic!("{text}: {err}"))
358            .canonical
359    }
360
361    fn crate_form(text: &str) -> String {
362        normalize(text)
363            .unwrap_or_else(|err| panic!("{text}: {err}"))
364            .crate_form
365    }
366
367    fn refusal(text: &str) -> String {
368        match normalize(text) {
369            Ok(normalized) => panic!("{text} was accepted as {normalized:?}"),
370            Err(err) => err,
371        }
372    }
373
374    #[test]
375    fn modifiers_come_out_in_canonical_order() {
376        assert_eq!(canonical("shift+ctrl+u"), "Ctrl+Shift+U");
377        assert_eq!(canonical("win+shift+alt+ctrl+k"), "Ctrl+Alt+Shift+Win+K");
378    }
379
380    #[test]
381    fn whitespace_and_case_are_forgiven() {
382        assert_eq!(canonical("  CTRL +  Shift + u "), "Ctrl+Shift+U");
383    }
384
385    #[test]
386    fn modifier_aliases_collapse() {
387        assert_eq!(canonical("control+u"), "Ctrl+U");
388        assert_eq!(canonical("option+u"), "Alt+U");
389        for win in ["win", "super", "meta", "cmd", "command"] {
390            assert_eq!(canonical(&format!("{win}+u")), "Win+U", "{win}");
391        }
392    }
393
394    #[test]
395    fn repeated_modifiers_are_harmless() {
396        assert_eq!(canonical("ctrl+control+u"), "Ctrl+U");
397    }
398
399    #[test]
400    fn function_keys() {
401        assert_eq!(canonical("alt+f1"), "Alt+F1");
402        assert_eq!(canonical("ctrl+F24"), "Ctrl+F24");
403        assert_eq!(crate_form("ctrl+F24"), "control+F24");
404    }
405
406    #[test]
407    fn arrows_use_short_canonical_and_crate_names() {
408        assert_eq!(canonical("ctrl+arrowup"), "Ctrl+Up");
409        assert_eq!(crate_form("ctrl+up"), "control+ArrowUp");
410        assert_eq!(crate_form("alt+left"), "alt+ArrowLeft");
411    }
412
413    #[test]
414    fn punctuation_keys() {
415        assert_eq!(canonical("ctrl+-"), "Ctrl+-");
416        assert_eq!(crate_form("ctrl+-"), "control+Minus");
417        assert_eq!(crate_form("ctrl+="), "control+Equal");
418        assert_eq!(crate_form("ctrl+["), "control+BracketLeft");
419        assert_eq!(crate_form("ctrl+]"), "control+BracketRight");
420        assert_eq!(crate_form("ctrl+\\"), "control+Backslash");
421        assert_eq!(crate_form("ctrl+;"), "control+Semicolon");
422        assert_eq!(crate_form("ctrl+'"), "control+Quote");
423        assert_eq!(crate_form("ctrl+,"), "control+Comma");
424        assert_eq!(crate_form("ctrl+."), "control+Period");
425        assert_eq!(crate_form("ctrl+/"), "control+Slash");
426        assert_eq!(crate_form("ctrl+`"), "control+Backquote");
427    }
428
429    #[test]
430    fn digits_and_named_keys() {
431        assert_eq!(canonical("ctrl+1"), "Ctrl+1");
432        assert_eq!(crate_form("ctrl+1"), "control+Digit1");
433        assert_eq!(canonical("ctrl+space"), "Ctrl+Space");
434        assert_eq!(canonical("ctrl+pgup"), "Ctrl+PageUp");
435        assert_eq!(crate_form("win+enter"), "super+Enter");
436    }
437
438    #[test]
439    fn crate_form_spells_letters_as_key_codes() {
440        assert_eq!(crate_form("Ctrl+Shift+U"), "control+shift+KeyU");
441        assert_eq!(crate_form("win+alt+z"), "alt+super+KeyZ");
442    }
443
444    #[test]
445    fn shift_alone_is_not_enough() {
446        assert_eq!(refusal("Shift+U"), "Add Ctrl, Alt or Win");
447    }
448
449    #[test]
450    fn a_bare_key_is_refused() {
451        assert_eq!(refusal("u"), "Add Ctrl, Alt or Win");
452    }
453
454    #[test]
455    fn two_keys_are_refused() {
456        assert_eq!(
457            refusal("ctrl+u+i"),
458            "Use exactly one key, for example Ctrl+Shift+U"
459        );
460    }
461
462    #[test]
463    fn only_modifiers_are_refused() {
464        assert_eq!(refusal("ctrl+shift"), "Add a key, for example Ctrl+Shift+U");
465    }
466
467    #[test]
468    fn escape_is_refused() {
469        assert_eq!(refusal("ctrl+escape"), "Escape closes the popover");
470        assert_eq!(refusal("ctrl+esc"), "Escape closes the popover");
471    }
472
473    #[test]
474    fn blank_is_refused() {
475        assert_eq!(refusal(""), "Enter a shortcut");
476        assert_eq!(refusal("   "), "Enter a shortcut");
477    }
478
479    #[test]
480    fn an_empty_token_is_refused() {
481        assert_eq!(refusal("ctrl++u"), "Put a key between each '+'");
482    }
483
484    #[test]
485    fn unknown_tokens_are_echoed() {
486        assert_eq!(refusal("ctrl+numpad5"), "Unsupported key: numpad5");
487        assert_eq!(refusal("ctrl+f25"), "Unsupported key: f25");
488        assert_eq!(refusal("ctrl+f0"), "Unsupported key: f0");
489        assert_eq!(refusal("ctrl+f1x"), "Unsupported key: f1x");
490    }
491
492    #[test]
493    fn overlong_garbage_is_clipped_and_stripped() {
494        let garbage = format!("ctrl+\u{1b}[31m{}\u{e9}", "x".repeat(40));
495        let message = refusal(&garbage);
496        assert_eq!(message, format!("Unsupported key: [31m{}", "x".repeat(12)));
497        assert!(message.len() <= "Unsupported key: ".len() + MAX_ECHOED_TOKEN_CHARS);
498    }
499
500    #[test]
501    fn a_token_with_nothing_printable_echoes_a_placeholder() {
502        assert_eq!(refusal("ctrl+\u{1b}"), "Unsupported key: ?");
503    }
504}