Skip to main content

opencode_voice/input/
hotkey.rs

1//! Global hotkey monitoring via rdev (no Accessibility permission dance required for listen-only).
2
3use anyhow::{Context, Result};
4use rdev::{Event, EventType, Key};
5use std::collections::HashMap;
6use std::sync::{Arc, Mutex, OnceLock};
7use tokio_util::sync::CancellationToken;
8
9use crate::state::InputEvent;
10
11/// Returns the KEY_MAP mapping key names to rdev::Key variants.
12///
13/// Most keys use cross-platform `rdev::Key` variants. Keys where rdev's mapping
14/// differs between macOS and Linux (numpad, F13-F20, section sign) use
15/// `#[cfg(target_os)]` blocks with the correct platform-specific values.
16fn build_key_map() -> HashMap<&'static str, Key> {
17    let mut m = HashMap::new();
18
19    // Numpad keys — platform-specific because rdev maps these differently per OS.
20    //
21    // macOS: rdev does NOT map numpad CGKeyCodes to Key::Kp* variants; they all
22    //        come through as Key::Unknown(CGKeyCode).
23    // Linux: rdev correctly maps numpad keys to native Key::Kp* variants via X11.
24    #[cfg(target_os = "macos")]
25    {
26        m.insert("numpad_0", Key::Unknown(82)); // CGKeyCode 0x52
27        m.insert("numpad_1", Key::Unknown(83)); // CGKeyCode 0x53
28        m.insert("numpad_2", Key::Unknown(84)); // CGKeyCode 0x54
29        m.insert("numpad_3", Key::Unknown(85)); // CGKeyCode 0x55
30        m.insert("numpad_4", Key::Unknown(86)); // CGKeyCode 0x56
31        m.insert("numpad_5", Key::Unknown(87)); // CGKeyCode 0x57
32        m.insert("numpad_6", Key::Unknown(88)); // CGKeyCode 0x58
33        m.insert("numpad_7", Key::Unknown(89)); // CGKeyCode 0x59
34        m.insert("numpad_8", Key::Unknown(91)); // CGKeyCode 0x5B
35        m.insert("numpad_9", Key::Unknown(92)); // CGKeyCode 0x5C
36        m.insert("numpad_enter", Key::Unknown(76)); // CGKeyCode 0x4C
37        m.insert("numpad_decimal", Key::Unknown(65)); // CGKeyCode 0x41
38        m.insert("numpad_dot", Key::Unknown(65)); // CGKeyCode 0x41
39        m.insert("numpad_plus", Key::Unknown(69)); // CGKeyCode 0x45
40        m.insert("numpad_add", Key::Unknown(69)); // CGKeyCode 0x45
41        m.insert("numpad_minus", Key::Unknown(78)); // CGKeyCode 0x4E
42        m.insert("numpad_subtract", Key::Unknown(78)); // CGKeyCode 0x4E
43        m.insert("numpad_multiply", Key::Unknown(67)); // CGKeyCode 0x43
44        m.insert("numpad_divide", Key::Unknown(75)); // CGKeyCode 0x4B
45        m.insert("numpad_clear", Key::Unknown(71)); // CGKeyCode 0x47
46        m.insert("numpad_equals", Key::Unknown(81)); // CGKeyCode 0x51
47    }
48    #[cfg(target_os = "linux")]
49    {
50        m.insert("numpad_0", Key::Kp0);
51        m.insert("numpad_1", Key::Kp1);
52        m.insert("numpad_2", Key::Kp2);
53        m.insert("numpad_3", Key::Kp3);
54        m.insert("numpad_4", Key::Kp4);
55        m.insert("numpad_5", Key::Kp5);
56        m.insert("numpad_6", Key::Kp6);
57        m.insert("numpad_7", Key::Kp7);
58        m.insert("numpad_8", Key::Kp8);
59        m.insert("numpad_9", Key::Kp9);
60        m.insert("numpad_enter", Key::KpReturn);
61        m.insert("numpad_decimal", Key::KpDelete); // rdev maps KEY_KPDOT to KpDelete
62        m.insert("numpad_dot", Key::KpDelete);
63        m.insert("numpad_plus", Key::KpPlus);
64        m.insert("numpad_add", Key::KpPlus);
65        m.insert("numpad_minus", Key::KpMinus);
66        m.insert("numpad_subtract", Key::KpMinus);
67        m.insert("numpad_multiply", Key::KpMultiply);
68        m.insert("numpad_divide", Key::KpDivide);
69        m.insert("numpad_clear", Key::NumLock); // macOS "Clear" is Num Lock position on PC
70        m.insert("numpad_equals", Key::Unknown(125)); // X11 keycode for KEY_KPEQUAL
71    }
72
73    // Modifier keys
74    m.insert("right_option", Key::AltGr);
75    m.insert("right_alt", Key::AltGr); // alias
76    m.insert("left_option", Key::Alt);
77    m.insert("left_alt", Key::Alt); // alias
78    m.insert("right_command", Key::MetaRight);
79    m.insert("right_cmd", Key::MetaRight); // alias
80    m.insert("left_command", Key::MetaLeft);
81    m.insert("left_cmd", Key::MetaLeft); // alias
82    m.insert("right_shift", Key::ShiftRight);
83    m.insert("left_shift", Key::ShiftLeft);
84    m.insert("right_control", Key::ControlRight);
85    m.insert("right_ctrl", Key::ControlRight); // alias
86    m.insert("left_control", Key::ControlLeft);
87    m.insert("left_ctrl", Key::ControlLeft); // alias
88    m.insert("fn_key", Key::Function);
89    m.insert("fn", Key::Function); // alias
90    m.insert("caps_lock", Key::CapsLock);
91
92    // Function keys F1-F12 (rdev has native variants)
93    m.insert("f1", Key::F1);
94    m.insert("f2", Key::F2);
95    m.insert("f3", Key::F3);
96    m.insert("f4", Key::F4);
97    m.insert("f5", Key::F5);
98    m.insert("f6", Key::F6);
99    m.insert("f7", Key::F7);
100    m.insert("f8", Key::F8);
101    m.insert("f9", Key::F9);
102    m.insert("f10", Key::F10);
103    m.insert("f11", Key::F11);
104    m.insert("f12", Key::F12);
105
106    // Function keys F13-F20 — rdev has no native variants on any platform.
107    //
108    // macOS: Uses CGKeyCodes (Carbon/HIToolbox virtual keycodes).
109    // Linux: Uses X11 keycodes (evdev keycode + 8) via the listen() path.
110    #[cfg(target_os = "macos")]
111    {
112        m.insert("f13", Key::Unknown(105)); // CGKeyCode 0x69
113        m.insert("f14", Key::Unknown(107)); // CGKeyCode 0x6B
114        m.insert("f15", Key::Unknown(113)); // CGKeyCode 0x71
115        m.insert("f16", Key::Unknown(106)); // CGKeyCode 0x6A
116        m.insert("f17", Key::Unknown(64)); // CGKeyCode 0x40
117        m.insert("f18", Key::Unknown(79)); // CGKeyCode 0x4F
118        m.insert("f19", Key::Unknown(80)); // CGKeyCode 0x50
119        m.insert("f20", Key::Unknown(90)); // CGKeyCode 0x5A
120    }
121    #[cfg(target_os = "linux")]
122    {
123        m.insert("f13", Key::Unknown(191)); // X11 keycode (evdev 183 + 8)
124        m.insert("f14", Key::Unknown(192)); // X11 keycode (evdev 184 + 8)
125        m.insert("f15", Key::Unknown(193)); // X11 keycode (evdev 185 + 8)
126        m.insert("f16", Key::Unknown(194)); // X11 keycode (evdev 186 + 8)
127        m.insert("f17", Key::Unknown(195)); // X11 keycode (evdev 187 + 8)
128        m.insert("f18", Key::Unknown(196)); // X11 keycode (evdev 188 + 8)
129        m.insert("f19", Key::Unknown(197)); // X11 keycode (evdev 189 + 8)
130        m.insert("f20", Key::Unknown(198)); // X11 keycode (evdev 190 + 8)
131    }
132
133    // Common keys
134    m.insert("space", Key::Space);
135    m.insert("tab", Key::Tab);
136    m.insert("escape", Key::Escape);
137    // "delete" = backspace on macOS (the key labeled "delete" on Mac keyboards)
138    m.insert("delete", Key::Backspace);
139    // "forward_delete" = the forward-delete key (fn+delete on laptops, separate key on full keyboards)
140    m.insert("forward_delete", Key::Delete);
141    m.insert("return_key", Key::Return);
142    m.insert("return", Key::Return);
143    m.insert("enter", Key::Return);
144    m.insert("home", Key::Home);
145    m.insert("end", Key::End);
146    m.insert("page_up", Key::PageUp);
147    m.insert("page_down", Key::PageDown);
148    m.insert("up_arrow", Key::UpArrow);
149    m.insert("down_arrow", Key::DownArrow);
150    m.insert("left_arrow", Key::LeftArrow);
151    m.insert("right_arrow", Key::RightArrow);
152    m.insert("insert", Key::Insert);
153    m.insert("print_screen", Key::PrintScreen);
154    m.insert("scroll_lock", Key::ScrollLock);
155    m.insert("pause", Key::Pause);
156    m.insert("num_lock", Key::NumLock);
157
158    // Punctuation / symbols
159    // Section sign (§) — the ISO 102nd key (between left-shift and Z on ISO keyboards).
160    // macOS: CGKeyCode 0x0A. Linux: rdev maps it to IntlBackslash via X11.
161    #[cfg(target_os = "macos")]
162    m.insert("section", Key::Unknown(10));
163    #[cfg(target_os = "linux")]
164    m.insert("section", Key::IntlBackslash);
165    m.insert("grave", Key::BackQuote);
166    m.insert("minus", Key::Minus);
167    m.insert("equal", Key::Equal);
168    m.insert("left_bracket", Key::LeftBracket);
169    m.insert("right_bracket", Key::RightBracket);
170    m.insert("backslash", Key::BackSlash);
171    m.insert("semicolon", Key::SemiColon);
172    m.insert("quote", Key::Quote);
173    m.insert("comma", Key::Comma);
174    m.insert("period", Key::Dot);
175    m.insert("slash", Key::Slash);
176
177    m
178}
179
180static KEY_MAP: OnceLock<HashMap<&'static str, Key>> = OnceLock::new();
181
182fn get_key_map() -> &'static HashMap<&'static str, Key> {
183    KEY_MAP.get_or_init(build_key_map)
184}
185
186/// Resolves a key name string to an rdev::Key.
187///
188/// Supports: named keys ("right_option"), decimal numbers ("65"), hex ("0x41").
189pub fn resolve_key(input: &str) -> Result<Key> {
190    let key_map = get_key_map();
191
192    // Named key lookup
193    if let Some(&key) = key_map.get(input) {
194        return Ok(key);
195    }
196
197    // Hex number fallback
198    if let Some(hex) = input
199        .strip_prefix("0x")
200        .or_else(|| input.strip_prefix("0X"))
201    {
202        if let Ok(n) = u32::from_str_radix(hex, 16) {
203            return Ok(Key::Unknown(n));
204        }
205    }
206
207    // Decimal number fallback
208    if let Ok(n) = input.parse::<u32>() {
209        return Ok(Key::Unknown(n));
210    }
211
212    Err(anyhow::anyhow!(
213        "Unknown key name: '{}'. Use 'opencode-voice keys' to list valid names.",
214        input
215    ))
216}
217
218/// Formats a key name for display (e.g., "right_option" → "Right Option").
219pub fn format_key_name(input: &str) -> String {
220    input
221        .split('_')
222        .map(|word| {
223            let mut chars = word.chars();
224            match chars.next() {
225                None => String::new(),
226                Some(c) => c.to_uppercase().to_string() + chars.as_str(),
227            }
228        })
229        .collect::<Vec<_>>()
230        .join(" ")
231}
232
233/// Returns a sorted list of all key names.
234pub fn list_key_names() -> Vec<&'static str> {
235    let mut names: Vec<&'static str> = get_key_map().keys().copied().collect();
236    names.sort();
237    names
238}
239
240/// Global hotkey monitor using rdev.
241pub struct GlobalHotkey {
242    target_key: Key,
243    sender: tokio::sync::mpsc::UnboundedSender<InputEvent>,
244    cancel: CancellationToken,
245}
246
247impl GlobalHotkey {
248    pub fn new(
249        key_name: &str,
250        sender: tokio::sync::mpsc::UnboundedSender<InputEvent>,
251        cancel: CancellationToken,
252    ) -> Result<Self> {
253        let target_key =
254            resolve_key(key_name).with_context(|| format!("Invalid hotkey: {}", key_name))?;
255        Ok(GlobalHotkey {
256            target_key,
257            sender,
258            cancel,
259        })
260    }
261
262    /// Starts the global hotkey listener on a dedicated OS thread.
263    ///
264    /// rdev::listen MUST run on a non-tokio thread.
265    pub fn run(&self) -> Result<()> {
266        let target_key = self.target_key;
267        let sender = self.sender.clone();
268        let cancel = self.cancel.clone();
269        let pressed = Arc::new(Mutex::new(false));
270
271        let (result_tx, result_rx) = std::sync::mpsc::channel::<Result<()>>();
272        let result_tx_clone = result_tx.clone();
273
274        std::thread::spawn(move || {
275            let result = rdev::listen(move |event: Event| {
276                if cancel.is_cancelled() {
277                    return;
278                }
279
280                match &event.event_type {
281                    EventType::KeyPress(key) => {
282                        if *key == target_key {
283                            let mut p = pressed.lock().unwrap();
284                            if !*p {
285                                *p = true;
286                                let _ = sender.send(InputEvent::KeyDown);
287                            }
288                        }
289                    }
290                    EventType::KeyRelease(key) => {
291                        if *key == target_key {
292                            let mut p = pressed.lock().unwrap();
293                            *p = false;
294                            // Send both KeyUp AND Toggle on release (matching TypeScript behavior)
295                            let _ = sender.send(InputEvent::KeyUp);
296                            let _ = sender.send(InputEvent::Toggle);
297                        }
298                    }
299                    _ => {}
300                }
301            });
302
303            match result {
304                Ok(_) => {}
305                Err(e) => {
306                    let msg = format_rdev_error(&e);
307                    let _ = result_tx_clone.send(Err(anyhow::anyhow!("{}", msg)));
308                }
309            }
310        });
311
312        // Wait briefly for immediate errors (e.g., Accessibility permission)
313        std::thread::sleep(std::time::Duration::from_millis(100));
314        if let Ok(Err(e)) = result_rx.try_recv() {
315            return Err(e);
316        }
317
318        Ok(())
319    }
320}
321
322fn format_rdev_error(error: &rdev::ListenError) -> String {
323    let msg = format!("{:?}", error);
324    if msg.contains("FailedToOpenX11")
325        || msg.contains("AccessDenied")
326        || msg.contains("PermissionDenied")
327        || msg.contains("EventTapError")
328    {
329        #[cfg(target_os = "macos")]
330        return "Accessibility permission required for global hotkey.\n  \
331                Go to: System Settings → Privacy & Security → Accessibility\n  \
332                Enable your terminal app (Terminal, iTerm2, etc.)"
333            .to_string();
334        #[cfg(not(target_os = "macos"))]
335        return "Input monitoring permission required.\n  \
336                Add your user to the 'input' group: sudo usermod -a -G input $USER\n  \
337                Or run with appropriate permissions."
338            .to_string();
339    }
340    format!("Global hotkey error: {}", msg)
341}
342
343#[cfg(test)]
344mod tests {
345    use super::*;
346
347    #[test]
348    fn test_resolve_key_right_option() {
349        let result = resolve_key("right_option");
350        assert!(result.is_ok());
351        assert_eq!(result.unwrap(), Key::AltGr);
352    }
353
354    #[test]
355    fn test_resolve_key_alias_right_alt() {
356        // right_alt is an alias for right_option
357        let k1 = resolve_key("right_option").unwrap();
358        let k2 = resolve_key("right_alt").unwrap();
359        assert_eq!(format!("{:?}", k1), format!("{:?}", k2));
360    }
361
362    #[test]
363    fn test_resolve_key_decimal_number() {
364        let result = resolve_key("65");
365        assert!(result.is_ok());
366        assert!(matches!(result.unwrap(), Key::Unknown(65)));
367    }
368
369    #[test]
370    fn test_resolve_key_hex_number() {
371        let result = resolve_key("0x41");
372        assert!(result.is_ok());
373        assert!(matches!(result.unwrap(), Key::Unknown(65)));
374    }
375
376    #[test]
377    fn test_resolve_key_unknown() {
378        let result = resolve_key("not_a_key");
379        assert!(result.is_err());
380    }
381
382    #[test]
383    fn test_resolve_key_space() {
384        let result = resolve_key("space");
385        assert!(result.is_ok());
386        assert_eq!(result.unwrap(), Key::Space);
387    }
388
389    #[test]
390    fn test_resolve_key_f1() {
391        let result = resolve_key("f1");
392        assert!(result.is_ok());
393        assert_eq!(result.unwrap(), Key::F1);
394    }
395
396    #[test]
397    fn test_resolve_key_f13() {
398        let result = resolve_key("f13");
399        assert!(result.is_ok());
400        #[cfg(target_os = "macos")]
401        assert!(matches!(result.unwrap(), Key::Unknown(105))); // CGKeyCode 0x69
402        #[cfg(target_os = "linux")]
403        assert!(matches!(result.unwrap(), Key::Unknown(191))); // X11 keycode
404    }
405
406    #[test]
407    fn test_resolve_key_numpad_0() {
408        let result = resolve_key("numpad_0");
409        assert!(result.is_ok());
410        #[cfg(target_os = "macos")]
411        assert!(matches!(result.unwrap(), Key::Unknown(82))); // CGKeyCode 0x52
412        #[cfg(target_os = "linux")]
413        assert_eq!(result.unwrap(), Key::Kp0);
414    }
415
416    #[test]
417    fn test_resolve_key_numpad_enter() {
418        let result = resolve_key("numpad_enter");
419        assert!(result.is_ok());
420        #[cfg(target_os = "macos")]
421        assert!(matches!(result.unwrap(), Key::Unknown(76))); // CGKeyCode 0x4C
422        #[cfg(target_os = "linux")]
423        assert_eq!(result.unwrap(), Key::KpReturn);
424    }
425
426    #[test]
427    fn test_resolve_key_hex_uppercase() {
428        let result = resolve_key("0X41");
429        assert!(result.is_ok());
430        assert!(matches!(result.unwrap(), Key::Unknown(65)));
431    }
432
433    #[test]
434    fn test_format_key_name_right_option() {
435        assert_eq!(format_key_name("right_option"), "Right Option");
436    }
437
438    #[test]
439    fn test_format_key_name_f13() {
440        assert_eq!(format_key_name("f13"), "F13");
441    }
442
443    #[test]
444    fn test_format_key_name_numpad_enter() {
445        assert_eq!(format_key_name("numpad_enter"), "Numpad Enter");
446    }
447
448    #[test]
449    fn test_format_key_name_space() {
450        assert_eq!(format_key_name("space"), "Space");
451    }
452
453    #[test]
454    fn test_list_key_names_sorted() {
455        let names = list_key_names();
456        assert!(!names.is_empty());
457        assert!(names.windows(2).all(|w| w[0] <= w[1]));
458        assert!(names.contains(&"right_option"));
459        assert!(names.contains(&"space"));
460        assert!(names.contains(&"f1"));
461    }
462
463    #[test]
464    fn test_key_map_has_60_plus_entries() {
465        let map = get_key_map();
466        assert!(
467            map.len() >= 60,
468            "KEY_MAP should have at least 60 entries, has {}",
469            map.len()
470        );
471    }
472
473    #[test]
474    fn test_resolve_key_left_command() {
475        let result = resolve_key("left_command");
476        assert!(result.is_ok());
477        assert_eq!(result.unwrap(), Key::MetaLeft);
478    }
479
480    #[test]
481    fn test_resolve_key_caps_lock() {
482        let result = resolve_key("caps_lock");
483        assert!(result.is_ok());
484        assert_eq!(result.unwrap(), Key::CapsLock);
485    }
486
487    #[test]
488    fn test_resolve_key_escape() {
489        let result = resolve_key("escape");
490        assert!(result.is_ok());
491        assert_eq!(result.unwrap(), Key::Escape);
492    }
493}