Skip to main content

handy_keys/
manager.rs

1//! Platform-agnostic hotkey manager built on top of KeyboardListener
2
3use std::collections::{HashMap, HashSet};
4use std::sync::mpsc::{self, Receiver, Sender, TryRecvError};
5use std::sync::{Arc, Mutex};
6use std::thread::{self, JoinHandle};
7
8use crate::error::{Error, Result};
9use crate::listener::{BlockingHotkeys, KeyboardListener};
10use crate::types::{Hotkey, HotkeyEvent, HotkeyId, HotkeyState, KeyEvent};
11
12/// Internal state shared between the manager and the processing thread
13struct ManagerState {
14    hotkeys: HashMap<HotkeyId, Hotkey>,
15    next_id: u32,
16    /// Track which hotkeys are currently pressed
17    pressed_hotkeys: HashSet<HotkeyId>,
18}
19
20impl ManagerState {
21    fn new() -> Self {
22        Self {
23            hotkeys: HashMap::new(),
24            next_id: 0,
25            pressed_hotkeys: HashSet::new(),
26        }
27    }
28
29    /// Process a key event and return any matching hotkey events
30    fn process_event(&mut self, event: &KeyEvent) -> Vec<HotkeyEvent> {
31        let mut results = Vec::new();
32
33        if event.is_key_down {
34            // Check for hotkeys that should be pressed
35            let to_press: Vec<HotkeyId> = self
36                .hotkeys
37                .iter()
38                .filter(|(&id, hotkey)| {
39                    hotkey.modifiers.matches(event.modifiers)
40                        && hotkey.key == event.key
41                        && !self.pressed_hotkeys.contains(&id)
42                })
43                .map(|(&id, _)| id)
44                .collect();
45
46            for id in to_press {
47                self.pressed_hotkeys.insert(id);
48                results.push(HotkeyEvent {
49                    id,
50                    state: HotkeyState::Pressed,
51                });
52            }
53        } else {
54            // Check for hotkeys that should be released
55            // A hotkey is released when either its key is released or its modifiers change
56            let to_release: Vec<HotkeyId> = self
57                .hotkeys
58                .iter()
59                .filter(|(&id, hotkey)| {
60                    self.pressed_hotkeys.contains(&id)
61                        && (hotkey.key == event.key
62                            || (event.key.is_none() && !hotkey.modifiers.matches(event.modifiers)))
63                })
64                .map(|(&id, _)| id)
65                .collect();
66
67            for id in to_release {
68                self.pressed_hotkeys.remove(&id);
69                results.push(HotkeyEvent {
70                    id,
71                    state: HotkeyState::Released,
72                });
73            }
74        }
75
76        results
77    }
78}
79
80/// Platform-agnostic Hotkey Manager
81///
82/// This manager wraps a `KeyboardListener` and filters events against
83/// registered hotkeys, emitting `HotkeyEvent`s when matches occur.
84///
85/// Registered hotkeys are blocked from reaching other applications.
86/// Note: On Linux/Wayland, blocking may not work due to compositor restrictions.
87pub struct HotkeyManager {
88    state: Arc<Mutex<ManagerState>>,
89    event_receiver: Receiver<HotkeyEvent>,
90    _thread_handle: Option<JoinHandle<()>>,
91    running: Arc<std::sync::atomic::AtomicBool>,
92    /// Shared set of hotkeys to block
93    blocking_hotkeys: Option<BlockingHotkeys>,
94}
95
96impl HotkeyManager {
97    /// Create a new HotkeyManager (non-blocking mode)
98    ///
99    /// On macOS, this will check for accessibility permissions and fail if not granted.
100    pub fn new() -> Result<Self> {
101        let listener = KeyboardListener::new()?;
102
103        let (tx, rx) = mpsc::channel();
104        let state = Arc::new(Mutex::new(ManagerState::new()));
105        let running = Arc::new(std::sync::atomic::AtomicBool::new(true));
106
107        let thread_state = Arc::clone(&state);
108        let thread_running = Arc::clone(&running);
109
110        let handle = thread::spawn(move || {
111            Self::event_loop(listener, thread_state, tx, thread_running);
112        });
113
114        Ok(Self {
115            state,
116            event_receiver: rx,
117            _thread_handle: Some(handle),
118            running,
119            blocking_hotkeys: None,
120        })
121    }
122
123    /// Create a new HotkeyManager with blocking support
124    ///
125    /// On macOS, this will check for accessibility permissions and fail if not granted.
126    /// Registered hotkeys will be blocked from reaching other applications.
127    ///
128    /// Note: On Linux/Wayland, blocking may not work due to compositor restrictions.
129    pub fn new_with_blocking() -> Result<Self> {
130        let blocking_hotkeys: BlockingHotkeys = Arc::new(Mutex::new(HashSet::new()));
131        let listener = KeyboardListener::new_with_blocking(blocking_hotkeys.clone())?;
132
133        let (tx, rx) = mpsc::channel();
134        let state = Arc::new(Mutex::new(ManagerState::new()));
135        let running = Arc::new(std::sync::atomic::AtomicBool::new(true));
136
137        let thread_state = Arc::clone(&state);
138        let thread_running = Arc::clone(&running);
139
140        let handle = thread::spawn(move || {
141            Self::event_loop(listener, thread_state, tx, thread_running);
142        });
143
144        Ok(Self {
145            state,
146            event_receiver: rx,
147            _thread_handle: Some(handle),
148            running,
149            blocking_hotkeys: Some(blocking_hotkeys),
150        })
151    }
152
153    /// Event processing loop
154    fn event_loop(
155        listener: KeyboardListener,
156        state: Arc<Mutex<ManagerState>>,
157        sender: Sender<HotkeyEvent>,
158        running: Arc<std::sync::atomic::AtomicBool>,
159    ) {
160        const RECV_TIMEOUT: std::time::Duration = std::time::Duration::from_millis(100);
161
162        while running.load(std::sync::atomic::Ordering::SeqCst) {
163            // Block until we receive an event or timeout (to check running flag)
164            match listener.recv_timeout(RECV_TIMEOUT) {
165                Ok(key_event) => {
166                    if let Ok(mut state) = state.lock() {
167                        let hotkey_events = state.process_event(&key_event);
168                        for event in hotkey_events {
169                            if sender.send(event).is_err() {
170                                // Receiver dropped, exit
171                                return;
172                            }
173                        }
174                    }
175                }
176                Err(crate::error::Error::Timeout) => {
177                    // No event received, loop continues to check running flag
178                }
179                Err(_) => {
180                    // Listener disconnected, exit
181                    return;
182                }
183            }
184        }
185    }
186
187    /// Register a hotkey and return its unique ID
188    ///
189    /// Returns an error if the hotkey is already registered.
190    pub fn register(&self, hotkey: Hotkey) -> Result<HotkeyId> {
191        let mut state = self.state.lock().map_err(|_| Error::MutexPoisoned)?;
192
193        // Check if already registered
194        for (id, existing) in &state.hotkeys {
195            if existing == &hotkey {
196                return Err(Error::HotkeyAlreadyRegistered(format!(
197                    "{} (id: {:?})",
198                    hotkey, id
199                )));
200            }
201        }
202
203        let id = HotkeyId(state.next_id);
204        state.next_id += 1;
205        state.hotkeys.insert(id, hotkey);
206
207        // Add to blocking set
208        if let Some(blocking_hotkeys) = &self.blocking_hotkeys {
209            if let Ok(mut blocking) = blocking_hotkeys.lock() {
210                blocking.insert(hotkey);
211            }
212        }
213
214        Ok(id)
215    }
216
217    /// Unregister a hotkey by its ID
218    ///
219    /// Returns an error if the hotkey ID is not found.
220    pub fn unregister(&self, id: HotkeyId) -> Result<()> {
221        let mut state = self.state.lock().map_err(|_| Error::MutexPoisoned)?;
222
223        let hotkey = state.hotkeys.remove(&id);
224        if hotkey.is_none() {
225            return Err(Error::HotkeyNotFound(id));
226        }
227
228        // Remove from blocking set
229        if let Some(blocking_hotkeys) = &self.blocking_hotkeys {
230            if let Some(hotkey) = hotkey {
231                if let Ok(mut blocking) = blocking_hotkeys.lock() {
232                    blocking.remove(&hotkey);
233                }
234            }
235        }
236
237        Ok(())
238    }
239
240    /// Get the hotkey definition associated with an ID
241    ///
242    /// Returns `None` if the ID is not found.
243    pub fn get_hotkey(&self, id: HotkeyId) -> Option<Hotkey> {
244        let state = self.state.lock().ok()?;
245        state.hotkeys.get(&id).copied()
246    }
247
248    /// Blocking receive for hotkey events
249    ///
250    /// Blocks until a hotkey event is received or the event loop stops.
251    pub fn recv(&self) -> Result<HotkeyEvent> {
252        self.event_receiver
253            .recv()
254            .map_err(|_| Error::EventLoopNotRunning)
255    }
256
257    /// Non-blocking receive for hotkey events
258    ///
259    /// Returns `Some(event)` if an event is available, `None` otherwise.
260    pub fn try_recv(&self) -> Option<HotkeyEvent> {
261        match self.event_receiver.try_recv() {
262            Ok(event) => Some(event),
263            Err(TryRecvError::Empty) => None,
264            Err(TryRecvError::Disconnected) => None,
265        }
266    }
267
268    /// Get the number of currently registered hotkeys
269    pub fn hotkey_count(&self) -> usize {
270        let state = if let Ok(s) = self.state.lock() {
271            s
272        } else {
273            return 0;
274        };
275        state.hotkeys.len()
276    }
277}
278
279impl Drop for HotkeyManager {
280    fn drop(&mut self) {
281        self.running
282            .store(false, std::sync::atomic::Ordering::SeqCst);
283        // Join the thread to ensure clean shutdown
284        if let Some(handle) = self._thread_handle.take() {
285            let _ = handle.join();
286        }
287    }
288}
289
290#[cfg(test)]
291mod tests {
292    use super::*;
293    use crate::types::{Key, Modifiers};
294
295    fn make_key_event(modifiers: Modifiers, key: Option<Key>, is_key_down: bool) -> KeyEvent {
296        KeyEvent {
297            modifiers,
298            key,
299            is_key_down,
300            changed_modifier: None,
301        }
302    }
303
304    fn make_modifier_event(
305        modifiers: Modifiers,
306        is_key_down: bool,
307        changed: Modifiers,
308    ) -> KeyEvent {
309        KeyEvent {
310            modifiers,
311            key: None,
312            is_key_down,
313            changed_modifier: Some(changed),
314        }
315    }
316
317    mod manager_state {
318        use super::*;
319
320        #[test]
321        fn register_and_lookup_hotkey() {
322            let mut state = ManagerState::new();
323            let hotkey = Hotkey::new(Modifiers::CMD, Key::K).unwrap();
324
325            let id = HotkeyId(state.next_id);
326            state.next_id += 1;
327            state.hotkeys.insert(id, hotkey);
328
329            assert_eq!(state.hotkeys.get(&id), Some(&hotkey));
330            assert_eq!(state.hotkeys.len(), 1);
331        }
332
333        #[test]
334        fn hotkey_press_generates_event() {
335            let mut state = ManagerState::new();
336            let hotkey = Hotkey::new(Modifiers::CMD, Key::K).unwrap();
337            let id = HotkeyId(0);
338            state.hotkeys.insert(id, hotkey);
339
340            // Simulate Cmd+K key down (event uses side-specific modifier)
341            let event = make_key_event(Modifiers::CMD_LEFT, Some(Key::K), true);
342            let results = state.process_event(&event);
343
344            assert_eq!(results.len(), 1);
345            assert_eq!(results[0].id, id);
346            assert_eq!(results[0].state, HotkeyState::Pressed);
347            assert!(state.pressed_hotkeys.contains(&id));
348        }
349
350        #[test]
351        fn hotkey_release_generates_event() {
352            let mut state = ManagerState::new();
353            let hotkey = Hotkey::new(Modifiers::CMD, Key::K).unwrap();
354            let id = HotkeyId(0);
355            state.hotkeys.insert(id, hotkey);
356
357            // Press first
358            let event = make_key_event(Modifiers::CMD_LEFT, Some(Key::K), true);
359            state.process_event(&event);
360
361            // Then release the key
362            let event = make_key_event(Modifiers::CMD_LEFT, Some(Key::K), false);
363            let results = state.process_event(&event);
364
365            assert_eq!(results.len(), 1);
366            assert_eq!(results[0].id, id);
367            assert_eq!(results[0].state, HotkeyState::Released);
368            assert!(!state.pressed_hotkeys.contains(&id));
369        }
370
371        #[test]
372        fn no_duplicate_press_events() {
373            let mut state = ManagerState::new();
374            let hotkey = Hotkey::new(Modifiers::CMD, Key::K).unwrap();
375            let id = HotkeyId(0);
376            state.hotkeys.insert(id, hotkey);
377
378            // Press once
379            let event = make_key_event(Modifiers::CMD_LEFT, Some(Key::K), true);
380            let results = state.process_event(&event);
381            assert_eq!(results.len(), 1);
382
383            // Press again (key repeat) - should not generate another event
384            let results = state.process_event(&event);
385            assert_eq!(results.len(), 0);
386        }
387
388        #[test]
389        fn modifier_release_triggers_hotkey_release() {
390            let mut state = ManagerState::new();
391            let hotkey = Hotkey::new(Modifiers::CMD, Key::K).unwrap();
392            let id = HotkeyId(0);
393            state.hotkeys.insert(id, hotkey);
394
395            // Press Cmd+K
396            let event = make_key_event(Modifiers::CMD_LEFT, Some(Key::K), true);
397            state.process_event(&event);
398            assert!(state.pressed_hotkeys.contains(&id));
399
400            // Release Cmd (while K is still held) - modifier event
401            let event = make_modifier_event(Modifiers::empty(), false, Modifiers::CMD_LEFT);
402            let results = state.process_event(&event);
403
404            assert_eq!(results.len(), 1);
405            assert_eq!(results[0].state, HotkeyState::Released);
406            assert!(!state.pressed_hotkeys.contains(&id));
407        }
408
409        #[test]
410        fn wrong_modifiers_dont_trigger() {
411            let mut state = ManagerState::new();
412            let hotkey = Hotkey::new(Modifiers::CMD, Key::K).unwrap();
413            state.hotkeys.insert(HotkeyId(0), hotkey);
414
415            // Press Shift+K instead of Cmd+K
416            let event = make_key_event(Modifiers::SHIFT_LEFT, Some(Key::K), true);
417            let results = state.process_event(&event);
418
419            assert_eq!(results.len(), 0);
420        }
421
422        #[test]
423        fn modifier_only_hotkey() {
424            let mut state = ManagerState::new();
425            let hotkey = Hotkey::new(Modifiers::CMD | Modifiers::SHIFT, None).unwrap();
426            let id = HotkeyId(0);
427            state.hotkeys.insert(id, hotkey);
428
429            // Press Cmd+Shift (no key) — events use side-specific modifiers
430            let event = make_modifier_event(
431                Modifiers::CMD_LEFT | Modifiers::SHIFT_LEFT,
432                true,
433                Modifiers::SHIFT_LEFT,
434            );
435            let results = state.process_event(&event);
436
437            assert_eq!(results.len(), 1);
438            assert_eq!(results[0].state, HotkeyState::Pressed);
439        }
440
441        #[test]
442        fn multiple_hotkeys_same_key() {
443            let mut state = ManagerState::new();
444
445            // Cmd+K and Ctrl+K
446            let hotkey1 = Hotkey::new(Modifiers::CMD, Key::K).unwrap();
447            let hotkey2 = Hotkey::new(Modifiers::CTRL, Key::K).unwrap();
448            let id1 = HotkeyId(0);
449            let id2 = HotkeyId(1);
450            state.hotkeys.insert(id1, hotkey1);
451            state.hotkeys.insert(id2, hotkey2);
452
453            // Press Cmd+K
454            let event = make_key_event(Modifiers::CMD_LEFT, Some(Key::K), true);
455            let results = state.process_event(&event);
456
457            assert_eq!(results.len(), 1);
458            assert_eq!(results[0].id, id1);
459
460            // Press Ctrl+K (release Cmd first)
461            state.pressed_hotkeys.clear();
462            let event = make_key_event(Modifiers::CTRL_LEFT, Some(Key::K), true);
463            let results = state.process_event(&event);
464
465            assert_eq!(results.len(), 1);
466            assert_eq!(results[0].id, id2);
467        }
468
469        #[test]
470        fn key_only_hotkey() {
471            let mut state = ManagerState::new();
472            let hotkey = Hotkey::new(Modifiers::empty(), Key::F1).unwrap();
473            let id = HotkeyId(0);
474            state.hotkeys.insert(id, hotkey);
475
476            // Press F1 with no modifiers
477            let event = make_key_event(Modifiers::empty(), Some(Key::F1), true);
478            let results = state.process_event(&event);
479
480            assert_eq!(results.len(), 1);
481            assert_eq!(results[0].state, HotkeyState::Pressed);
482
483            // F1 with modifiers should NOT trigger
484            state.pressed_hotkeys.clear();
485            let event = make_key_event(Modifiers::CMD_LEFT, Some(Key::F1), true);
486            let results = state.process_event(&event);
487
488            assert_eq!(results.len(), 0);
489        }
490
491        #[test]
492        fn side_specific_hotkey_matches_correct_side() {
493            let mut state = ManagerState::new();
494            // Register CtrlRight+Space
495            let hotkey = Hotkey::new(Modifiers::CTRL_RIGHT, Key::Space).unwrap();
496            let id = HotkeyId(0);
497            state.hotkeys.insert(id, hotkey);
498
499            // Left ctrl should not trigger
500            let event = make_key_event(Modifiers::CTRL_LEFT, Some(Key::Space), true);
501            assert_eq!(state.process_event(&event).len(), 0);
502
503            // Right ctrl should trigger
504            let event = make_key_event(Modifiers::CTRL_RIGHT, Some(Key::Space), true);
505            let results = state.process_event(&event);
506            assert_eq!(results.len(), 1);
507            assert_eq!(results[0].state, HotkeyState::Pressed);
508        }
509
510        #[test]
511        fn compound_hotkey_matches_either_side() {
512            let mut state = ManagerState::new();
513            let hotkey = Hotkey::new(Modifiers::CMD, Key::K).unwrap();
514            let id = HotkeyId(0);
515            state.hotkeys.insert(id, hotkey);
516
517            // Left Cmd triggers
518            let event = make_key_event(Modifiers::CMD_LEFT, Some(Key::K), true);
519            let results = state.process_event(&event);
520            assert_eq!(results.len(), 1);
521
522            // Release
523            state.pressed_hotkeys.clear();
524
525            // Right Cmd also triggers
526            let event = make_key_event(Modifiers::CMD_RIGHT, Some(Key::K), true);
527            let results = state.process_event(&event);
528            assert_eq!(results.len(), 1);
529        }
530    }
531}