//! Global hotkey → dictation effect bridge. //! //! Thin adapter from `keytap::chord::ChordMatcher` to Tauri events. keytap //! owns the OS event tap + the chord state machine (Momentary vs Toggle, //! longest-match resolution, sticky-toggle semantics); this module's only //! job is: //! //! 1. Build a `ChordMatcher` from the user's saved PTT + Toggle chords. //! 2. Translate `ChordEvent` → voicebox's [`Effect`] on a dispatcher //! thread. //! 3. Fan [`Effect`]s out into Tauri events + dictate-window show/hide. //! //! The [`Effect::RestartRecording`] signal is emitted when keytap fires //! `End(PTT)` and `Start(Toggle)` with the *same* [`Instant`] — which //! happens when the held set upgrades from a shorter chord to a longer //! superset in a single event (the classic PTT→hands-free transition). //! We detect the pair with a 5 ms peek on the matcher's receiver and //! coalesce into one `Restart` so hosts can discard the transition- //! moment audio rather than treat it as an unrelated Stop+Start pair. //! //! Left- and right-hand modifier variants are kept distinct all the way //! down to the OS event tap (keytap's core promise). Defaults bind to //! right-hand Cmd + right-hand Option on macOS / right-hand Ctrl + //! right-hand Shift on Windows so the usual left-hand shortcuts stay //! with the OS / app. use std::collections::{HashMap, HashSet}; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::thread::{self, JoinHandle}; use std::time::Duration; use keytap::chord::{Chord, ChordEvent, ChordMatcher}; use keytap::{Key, RecvTimeoutError}; use tauri::{AppHandle, Emitter, Manager}; use crate::focus_capture; use crate::DICTATE_WINDOW_LABEL; // ======================================================================== // Public types // ======================================================================== /// Semantic action a chord can be bound to. `PushToTalk` = hold chord to /// record, release to stop. `ToggleToTalk` = press chord to start recording, /// press again to stop. #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum ChordAction { PushToTalk, ToggleToTalk, } /// Effect produced after the chord matcher resolves an event. Hosts /// translate these into UI / recorder calls. #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum Effect { StartRecording(ChordAction), StopRecording(ChordAction), /// Emitted when a push-to-talk chord is "upgraded" into the toggle /// chord mid-hold — hosts may want to discard the captured audio and /// restart so the transition moment isn't in the recording. RestartRecording(ChordAction), } /// Chord key sets from capture settings. Both actions use the same /// `HashSet` shape so callers don't need to know about keytap's /// `Chord` type. pub type Bindings = HashMap>; // ======================================================================== // Monitor // ======================================================================== pub struct HotkeyMonitor { app: AppHandle, active: Option, } struct Active { dispatcher: JoinHandle<()>, shutdown: Arc, } impl HotkeyMonitor { /// Build the monitor with initial bindings. Equivalent to constructing /// an empty monitor and calling [`Self::update_bindings`] once. pub fn spawn(app: AppHandle, bindings: Bindings) -> Self { let mut m = Self { app, active: None }; m.apply(bindings); m } /// Swap in a fresh set of chord bindings. Tears down the existing /// `ChordMatcher` (which stops keytap's chord worker thread and /// closes the OS tap) and spawns a new one. No-op for the "all /// empty" case so "disable hotkey" doesn't keep a tap running for /// no reason. pub fn update_bindings(&mut self, bindings: Bindings) { self.apply(bindings); } fn apply(&mut self, bindings: Bindings) { // Tear down any existing matcher + dispatcher first. The // dispatcher sees the shutdown flag on its next recv_timeout // (≤100ms) and returns; joining waits for that. Dropping the // ChordMatcher stops keytap's chord-worker thread and the // underlying Tap. if let Some(active) = self.active.take() { active.shutdown.store(true, Ordering::Relaxed); let _ = active.dispatcher.join(); } if bindings.values().all(|set| set.is_empty()) { return; } let matcher = match build_matcher(&bindings) { Ok(m) => m, Err(err) => { eprintln!( "HotkeyMonitor: ChordMatcher build failed ({err}). Global chord detection is disabled. On macOS, grant Input Monitoring in System Settings → Privacy & Security → Input Monitoring and relaunch." ); return; } }; let shutdown = Arc::new(AtomicBool::new(false)); let shutdown_for_thread = shutdown.clone(); let app = self.app.clone(); let dispatcher = thread::Builder::new() .name("voicebox-hotkey-dispatcher".into()) .spawn(move || dispatcher_loop(app, matcher, shutdown_for_thread)) .expect("spawn hotkey dispatcher thread"); self.active = Some(Active { dispatcher, shutdown }); } } impl Drop for HotkeyMonitor { fn drop(&mut self) { if let Some(active) = self.active.take() { active.shutdown.store(true, Ordering::Relaxed); let _ = active.dispatcher.join(); } } } // ======================================================================== // Matcher construction + dispatch // ======================================================================== fn build_matcher(bindings: &Bindings) -> Result, keytap::Error> { let mut builder = ChordMatcher::builder(); if let Some(keys) = bindings.get(&ChordAction::PushToTalk) { if !keys.is_empty() { builder = builder.add( ChordAction::PushToTalk, Chord::of(keys.iter().copied()), ); } } if let Some(keys) = bindings.get(&ChordAction::ToggleToTalk) { if !keys.is_empty() { builder = builder.add_toggle( ChordAction::ToggleToTalk, Chord::of(keys.iter().copied()), ); } } builder.build() } fn dispatcher_loop( app: AppHandle, matcher: ChordMatcher, shutdown: Arc, ) { while !shutdown.load(Ordering::Relaxed) { match matcher.recv_timeout(Duration::from_millis(100)) { Ok(event) => process_event(&app, &matcher, event), Err(RecvTimeoutError::Timeout) => continue, Err(RecvTimeoutError::Disconnected) => break, } } } /// Turn a single [`ChordEvent`] into zero or one [`Effect`]s, peeking at /// the matcher once for a same-Instant follow-up so upgrade transitions /// coalesce into [`Effect::RestartRecording`] instead of a Stop+Start /// pair. fn process_event( app: &AppHandle, matcher: &ChordMatcher, event: ChordEvent, ) { match event { ChordEvent::Start { id, .. } => { apply_effect(app, Effect::StartRecording(id)); } ChordEvent::End { id: end_id, time: end_time } => { // Peek for an immediately-following Start. keytap emits // End+Start atomically (same Instant) when the held set // transitions between registered chords — our 5 ms window // is well under perceptible latency but far longer than the // channel hop between keytap's chord worker and our // dispatcher. match matcher.recv_timeout(Duration::from_millis(5)) { Ok(ChordEvent::Start { id: start_id, time: start_time }) if start_time == end_time => { apply_effect(app, Effect::RestartRecording(start_id)); } Ok(other) => { apply_effect(app, Effect::StopRecording(end_id)); // The peeked event wasn't a transition partner; // process it in its own right. Recursion depth is // bounded by the number of back-to-back chord // events, in practice 1–2. process_event(app, matcher, other); } Err(_) => { apply_effect(app, Effect::StopRecording(end_id)); } } } } } // ======================================================================== // Effect → Tauri // ======================================================================== fn apply_effect(app: &AppHandle, effect: Effect) { match effect { Effect::StartRecording(_) => { // Snapshot focus BEFORE we touch the window — any AppKit // reshuffle triggered by set_position / show could in principle // steal key focus and poison the reading. In practice those // calls leave keyWindow alone, but capturing first is free. let focus = focus_capture::capture_focus().ok(); if let Some(window) = app.get_webview_window(DICTATE_WINDOW_LABEL) { // The previous hide-cycle parked the window off-screen and // made it click-through — undo both before showing, so the // pill lands at top-center and the user can actually click // the error pill / stop button. // // `current_monitor()` returns None when the window is off // any display (our hide handler parks it at -10_000, -10_000 // precisely so it never intercepts clicks), so fall back to // the primary monitor for the reposition. let monitor = window .current_monitor() .ok() .flatten() .or_else(|| window.primary_monitor().ok().flatten()); if let Some(monitor) = monitor { let monitor_pos = monitor.position(); let monitor_size = monitor.size(); if let Ok(win_size) = window.outer_size() { let x = monitor_pos.x + (monitor_size.width as i32 - win_size.width as i32) / 2; let y = monitor_pos.y + (monitor_size.height as f64 * 0.04) as i32; let _ = window.set_position(tauri::PhysicalPosition::new(x, y)); } } // Skip on Linux: aborts if the window was never realized // (see show_dictate_window in main.rs). #[cfg(not(target_os = "linux"))] let _ = window.set_ignore_cursor_events(false); // Deliberately no set_focus() — taking key focus would yank // it out of whatever app the user was typing in, which is // the opposite of what a dictation overlay should do. let _ = window.show(); let payload = serde_json::json!({ "focus": focus }); let _ = window.emit("dictate:start", payload); } } Effect::StopRecording(_) => { if let Some(window) = app.get_webview_window(DICTATE_WINDOW_LABEL) { let _ = window.emit("dictate:stop", ()); } } Effect::RestartRecording(_) => { if let Some(window) = app.get_webview_window(DICTATE_WINDOW_LABEL) { let _ = window.emit("dictate:restart", ()); } } } }