Skip to main content

scrybe_widgets/
hotkey.rs

1// Copyright 2026 Mathews Tom
2// Licensed under the Apache License, Version 2.0 (the "License");
3// you may not use this file except in compliance with the License.
4// You may obtain a copy of the License at
5//     https://www.apache.org/licenses/LICENSE-2.0
6
7//! Global hotkey listener for one-shot recording stop requests.
8//!
9//! Compiled only with the `cli-shell` cargo feature. Wraps
10//! `global_hotkey::GlobalHotKeyManager` (`!Send` on macOS — the
11//! Carbon event handler is keyed to the thread that created it) and
12//! exposes hotkey events as a `crossbeam_channel::Receiver` so the
13//! main-thread shell driver (`scrybe-cli::shell`) can poll without
14//! holding the manager across `await` points.
15//!
16//! Accelerator grammar follows the tao/tauri convention used by both
17//! `tray-icon` and `global-hotkey`: e.g. `CmdOrCtrl+Shift+R`,
18//! `Alt+F4`, `Super+Space`.
19
20use std::str::FromStr;
21
22use anyhow::{anyhow, Context, Result};
23use crossbeam_channel::Receiver;
24use global_hotkey::{hotkey::HotKey, GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
25
26/// Default stop accelerator when the user has not customised
27/// `[capture] hotkey` in `config.toml`. Modifiers map to the platform
28/// primary metakey: `Cmd` on macOS, `Ctrl` on Linux/Windows.
29pub const DEFAULT_STOP_ACCELERATOR: &str = "CmdOrCtrl+Shift+R";
30
31/// Events surfaced by the global hotkey to the recording loop.
32#[derive(Copy, Clone, Debug, Eq, PartialEq)]
33pub enum HotkeyEvent {
34    /// User pressed the configured stop accelerator.
35    StopRequested,
36}
37
38/// Global hotkey listener. Constructed on the thread that owns the
39/// platform run loop; never moved across threads.
40pub struct HotkeyListener {
41    // Held for its `Drop` impl, which unregisters the hotkey from the
42    // OS. The field is never read directly — the global
43    // `GlobalHotKeyEvent::receiver()` queue is what surfaces events.
44    #[allow(dead_code)]
45    manager: GlobalHotKeyManager,
46    hotkey_id: u32,
47    events: Receiver<GlobalHotKeyEvent>,
48}
49
50impl HotkeyListener {
51    /// Validate the accelerator string and register it with the OS.
52    ///
53    /// # Errors
54    ///
55    /// Returns an error when the accelerator string is malformed or
56    /// when the OS rejects the registration (e.g. the combination is
57    /// already claimed by another app).
58    pub fn start(accelerator: &str) -> Result<Self> {
59        validate_accelerator_syntax(accelerator)?;
60
61        let manager = GlobalHotKeyManager::new().context("creating global hotkey manager")?;
62        let hotkey = HotKey::from_str(accelerator)
63            .with_context(|| format!("parsing hotkey accelerator '{accelerator}'"))?;
64        let hotkey_id = hotkey.id();
65        manager
66            .register(hotkey)
67            .with_context(|| format!("registering hotkey '{accelerator}'"))?;
68
69        let events = GlobalHotKeyEvent::receiver().clone();
70        Ok(Self {
71            manager,
72            hotkey_id,
73            events,
74        })
75    }
76
77    /// Drain pending hotkey events without blocking, returning the
78    /// first stop request that matches the registered ID and press
79    /// half-cycle. Returns `None` when the queue is empty.
80    #[must_use]
81    pub fn poll(&self) -> Option<HotkeyEvent> {
82        self.presses().poll()
83    }
84
85    /// A handle to this hotkey's presses that can leave this thread.
86    ///
87    /// The listener cannot: `GlobalHotKeyManager` is `!Send` on macOS,
88    /// because its Carbon event handler is keyed to the thread that
89    /// created it. The press queue is a different thing — an ordinary
90    /// `crossbeam` receiver — and a host whose event loop is not its
91    /// own needs to read it from somewhere other than the thread the
92    /// listener is pinned to. Registration stays where it must be;
93    /// reading moves.
94    #[must_use]
95    pub fn presses(&self) -> Presses {
96        Presses {
97            hotkey_id: self.hotkey_id,
98            events: self.events.clone(),
99        }
100    }
101}
102
103/// The press queue of one registered accelerator, readable from any
104/// thread.
105#[derive(Clone, Debug)]
106pub struct Presses {
107    hotkey_id: u32,
108    events: Receiver<GlobalHotKeyEvent>,
109}
110
111impl Presses {
112    /// The next stop request, or `None` when the queue is empty.
113    ///
114    /// Only the press half-cycle counts. Acting on the release as well
115    /// would make one keystroke two stop requests — harmless against
116    /// the controller, which accepts one, but it would put a spurious
117    /// "already stopping" in the record of every hotkey stop.
118    #[must_use]
119    pub fn poll(&self) -> Option<HotkeyEvent> {
120        while let Ok(event) = self.events.try_recv() {
121            if event.id == self.hotkey_id && event.state == HotKeyState::Pressed {
122                return Some(HotkeyEvent::StopRequested);
123            }
124        }
125        None
126    }
127
128    /// The next stop request, waiting up to `timeout` for one.
129    ///
130    /// What a host with its own event loop drains on: it parks the
131    /// thread between presses instead of spinning, and the timeout is
132    /// only what lets it notice the process going away.
133    #[must_use]
134    pub fn poll_for(&self, timeout: std::time::Duration) -> Option<HotkeyEvent> {
135        let deadline = std::time::Instant::now() + timeout;
136        while let Ok(event) = self.events.recv_deadline(deadline) {
137            if event.id == self.hotkey_id && event.state == HotKeyState::Pressed {
138                return Some(HotkeyEvent::StopRequested);
139            }
140        }
141        None
142    }
143}
144
145/// Validate accelerator syntax without registering. Used both as a
146/// pre-flight check before the OS registration call and so a config
147/// error surfaces with a meaningful message rather than the
148/// `global-hotkey` crate's lower-level parse failure.
149///
150/// The grammar is `<modifier>+<modifier>+...+<key>` with at least one
151/// non-modifier key. Empty strings, segments, or pure-modifier
152/// accelerators are rejected.
153fn validate_accelerator_syntax(accelerator: &str) -> Result<()> {
154    let trimmed = accelerator.trim();
155    if trimmed.is_empty() {
156        return Err(anyhow!("hotkey accelerator must not be empty"));
157    }
158    let segments: Vec<&str> = trimmed.split('+').map(str::trim).collect();
159    if segments.iter().any(|s| s.is_empty()) {
160        return Err(anyhow!(
161            "hotkey accelerator '{accelerator}' contains an empty segment"
162        ));
163    }
164    let last = segments
165        .last()
166        .copied()
167        .ok_or_else(|| anyhow!("hotkey accelerator '{accelerator}' has no key segment"))?;
168    if is_modifier_token(last) {
169        return Err(anyhow!(
170            "hotkey accelerator '{accelerator}' must terminate in a non-modifier key"
171        ));
172    }
173    Ok(())
174}
175
176fn is_modifier_token(token: &str) -> bool {
177    matches!(
178        token.to_ascii_lowercase().as_str(),
179        "shift"
180            | "ctrl"
181            | "control"
182            | "alt"
183            | "option"
184            | "super"
185            | "meta"
186            | "cmd"
187            | "command"
188            | "cmdorctrl"
189            | "commandorcontrol"
190    )
191}
192
193#[cfg(test)]
194#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)]
195mod tests {
196    use super::*;
197
198    #[test]
199    fn test_validate_accelerator_syntax_accepts_default_stop() {
200        validate_accelerator_syntax(DEFAULT_STOP_ACCELERATOR).unwrap();
201    }
202
203    #[test]
204    fn test_validate_accelerator_syntax_accepts_alt_function_key() {
205        validate_accelerator_syntax("Alt+F4").unwrap();
206    }
207
208    #[test]
209    fn test_validate_accelerator_syntax_accepts_super_space() {
210        validate_accelerator_syntax("Super+Space").unwrap();
211    }
212
213    #[test]
214    fn test_validate_accelerator_syntax_rejects_empty_string() {
215        let err = validate_accelerator_syntax("").unwrap_err();
216
217        assert!(err.to_string().contains("must not be empty"));
218    }
219
220    #[test]
221    fn test_validate_accelerator_syntax_rejects_pure_modifier_chord() {
222        let err = validate_accelerator_syntax("Ctrl+Shift").unwrap_err();
223
224        assert!(err.to_string().contains("non-modifier"));
225    }
226
227    #[test]
228    fn test_validate_accelerator_syntax_rejects_empty_segment() {
229        let err = validate_accelerator_syntax("Ctrl++R").unwrap_err();
230
231        assert!(err.to_string().contains("empty segment"));
232    }
233
234    #[test]
235    fn test_is_modifier_token_recognises_cmdorctrl_alias() {
236        assert!(is_modifier_token("CmdOrCtrl"));
237    }
238
239    #[test]
240    fn test_is_modifier_token_rejects_letter_key() {
241        assert!(!is_modifier_token("R"));
242    }
243
244    #[test]
245    fn test_default_stop_accelerator_constant_validates_as_a_well_formed_accelerator() {
246        validate_accelerator_syntax(DEFAULT_STOP_ACCELERATOR).unwrap();
247        assert!(DEFAULT_STOP_ACCELERATOR.contains('+'));
248    }
249}