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