1use std::str::FromStr;
21
22use anyhow::{anyhow, Context, Result};
23use crossbeam_channel::Receiver;
24use global_hotkey::{hotkey::HotKey, GlobalHotKeyEvent, GlobalHotKeyManager, HotKeyState};
25
26pub const DEFAULT_STOP_ACCELERATOR: &str = "CmdOrCtrl+Shift+R";
30
31#[derive(Copy, Clone, Debug, Eq, PartialEq)]
33pub enum HotkeyEvent {
34 StopRequested,
36}
37
38pub struct HotkeyListener {
41 #[allow(dead_code)]
45 manager: GlobalHotKeyManager,
46 hotkey_id: u32,
47 events: Receiver<GlobalHotKeyEvent>,
48}
49
50impl HotkeyListener {
51 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 #[must_use]
81 pub fn poll(&self) -> Option<HotkeyEvent> {
82 self.presses().poll()
83 }
84
85 #[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#[derive(Clone, Debug)]
106pub struct Presses {
107 hotkey_id: u32,
108 events: Receiver<GlobalHotKeyEvent>,
109}
110
111impl Presses {
112 #[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 #[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
145fn 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}