diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 81511a6e..18b1923b 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -283,5 +283,52 @@ pub fn trigger_microphone_prompt(app: AppHandle) -> Result<(), String> { // ─────────────────────────── unused but exported (silences dead_code) ─────────────────────────── +#[tauri::command] +pub fn is_debug_ui_key_events_enabled() -> bool { + std::env::var("OPENLESS_DEBUG_UI_KEY_EVENTS") + .ok() + .as_deref() + == Some("1") +} + +#[tauri::command] +pub fn debug_log_ui_key_event( + event_type: String, + key: String, + code: String, + ctrl: bool, + alt: bool, + shift: bool, + meta: bool, + repeat: bool, +) { + if !is_debug_ui_key_events_enabled() { + return; + } + log::info!( + "[ui-key] type={} key={} code={} ctrl={} alt={} shift={} meta={} repeat={}", + event_type, + key.replace(' ', "_"), + code.replace(' ', "_"), + ctrl, + alt, + shift, + meta, + repeat + ); +} + +#[tauri::command] +pub async fn handle_window_hotkey_event( + coord: CoordinatorState<'_>, + event_type: String, + key: String, + code: String, + repeat: bool, +) -> Result<(), String> { + coord.handle_window_hotkey_event(&event_type, &key, &code, repeat) + .await +} + #[allow(dead_code)] fn _ensure_snapshot_used(_: CredentialsSnapshot) {} diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c2d8c9b2..30b7e217 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -28,7 +28,7 @@ use crate::polish::{OpenAICompatibleConfig, OpenAICompatibleLLMProvider}; use crate::recorder::Recorder; use crate::types::{ CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, - HotkeyStatusState, InsertStatus, PolishMode, + HotkeyStatusState, HotkeyTrigger, InsertStatus, PolishMode, }; #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -86,6 +86,7 @@ struct Inner { recorder: Mutex>, hotkey: Mutex>, hotkey_status: Mutex, + window_hotkey_held: Mutex, } impl Coordinator { @@ -109,6 +110,7 @@ impl Coordinator { recorder: Mutex::new(None), hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), + window_hotkey_held: Mutex::new(false), }), } } @@ -176,6 +178,17 @@ impl Coordinator { Ok(()) } + pub async fn handle_window_hotkey_event( + &self, + event_type: &str, + key: &str, + code: &str, + repeat: bool, + ) -> Result<(), String> { + handle_window_hotkey_event(&self.inner, event_type, key, code, repeat).await; + Ok(()) + } + pub async fn repolish(&self, raw_text: String, mode: PolishMode) -> Result { let hotwords = enabled_phrases(&self.inner); polish_text(&raw_text, mode, &hotwords) @@ -260,6 +273,87 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { } } +async fn handle_window_hotkey_event( + inner: &Arc, + event_type: &str, + key: &str, + code: &str, + repeat: bool, +) { + #[cfg(not(target_os = "windows"))] + { + let _ = (inner, event_type, key, code, repeat); + return; + } + + #[cfg(target_os = "windows")] + { + if event_type == "keydown" && key == "Escape" { + log::info!("[window-hotkey] escape cancel from main window"); + cancel_session(inner); + return; + } + + let binding = inner.prefs.get().hotkey; + if !matches_window_hotkey(binding.trigger, code) { + return; + } + + if event_type == "keydown" { + if repeat { + return; + } + let should_press = { + let mut held = inner.window_hotkey_held.lock(); + if *held { + false + } else { + *held = true; + true + } + }; + if !should_press { + return; + } + log::info!( + "[window-hotkey] pressed trigger={:?} code={} repeat={}", + binding.trigger, code, repeat + ); + handle_pressed(inner).await; + } else if event_type == "keyup" { + let should_release = { + let mut held = inner.window_hotkey_held.lock(); + if !*held { + false + } else { + *held = false; + true + } + }; + if !should_release { + return; + } + log::info!( + "[window-hotkey] released trigger={:?} code={} repeat={}", + binding.trigger, code, repeat + ); + handle_released(inner).await; + } + } +} + +#[cfg(target_os = "windows")] +fn matches_window_hotkey(trigger: HotkeyTrigger, code: &str) -> bool { + match trigger { + HotkeyTrigger::RightControl => code == "ControlRight", + HotkeyTrigger::LeftControl => code == "ControlLeft", + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => code == "AltRight", + HotkeyTrigger::LeftOption => code == "AltLeft", + HotkeyTrigger::RightCommand => code == "MetaRight", + HotkeyTrigger::Fn => false, + } +} + async fn handle_pressed(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 1121287f..14318b1c 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -166,6 +166,9 @@ pub fn run() { commands::read_credential, commands::set_active_asr_provider, commands::set_active_llm_provider, + commands::is_debug_ui_key_events_enabled, + commands::debug_log_ui_key_event, + commands::handle_window_hotkey_event, restart_app, ]) .build(tauri::generate_context!()) @@ -175,9 +178,15 @@ pub fn run() { RunEvent::Reopen { .. } => show_main_window(app), RunEvent::WindowEvent { label, event, .. } => { if label == "main" { - if let tauri::WindowEvent::CloseRequested { api, .. } = event { - api.prevent_close(); - hide_main_window(app); + match event { + tauri::WindowEvent::Focused(focused) => { + log::info!("[window] main focused={focused}"); + } + tauri::WindowEvent::CloseRequested { api, .. } => { + api.prevent_close(); + hide_main_window(app); + } + _ => {} } } } diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index f0a22e59..7814da71 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -3,7 +3,14 @@ import { Capsule } from './components/Capsule'; import { FloatingShell } from './components/FloatingShell'; import { Onboarding } from './components/Onboarding'; import { detectOS } from './components/WindowChrome'; -import { checkAccessibilityPermission, checkMicrophonePermission, isTauri } from './lib/ipc'; +import { + checkAccessibilityPermission, + checkMicrophonePermission, + debugLogUiKeyEvent, + handleWindowHotkeyEvent, + isDebugUiKeyEventsEnabled, + isTauri, +} from './lib/ipc'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; interface AppProps { @@ -53,6 +60,59 @@ export function App({ isCapsule }: AppProps) { }; }, [os]); + useEffect(() => { + if (!isTauri) return; + let disposed = false; + let detach: (() => void) | null = null; + + void isDebugUiKeyEventsEnabled().then(enabled => { + if (!enabled || disposed) return; + const onKeyboardEvent = (event: KeyboardEvent) => { + void debugLogUiKeyEvent({ + eventType: event.type, + key: event.key, + code: event.code, + ctrl: event.ctrlKey, + alt: event.altKey, + shift: event.shiftKey, + meta: event.metaKey, + repeat: event.repeat, + }); + }; + window.addEventListener('keydown', onKeyboardEvent, true); + window.addEventListener('keyup', onKeyboardEvent, true); + detach = () => { + window.removeEventListener('keydown', onKeyboardEvent, true); + window.removeEventListener('keyup', onKeyboardEvent, true); + }; + }); + + return () => { + disposed = true; + detach?.(); + }; + }, []); + + useEffect(() => { + if (!isTauri || os !== 'win') return; + + const onKeyboardEvent = (event: KeyboardEvent) => { + void handleWindowHotkeyEvent({ + eventType: event.type, + key: event.key, + code: event.code, + repeat: event.repeat, + }); + }; + + window.addEventListener('keydown', onKeyboardEvent, true); + window.addEventListener('keyup', onKeyboardEvent, true); + return () => { + window.removeEventListener('keydown', onKeyboardEvent, true); + window.removeEventListener('keyup', onKeyboardEvent, true); + }; + }, [os]); + if (gate === 'checking') { return ; } diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 3c15c098..6dfb70cb 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -220,6 +220,54 @@ export function restartApp(): Promise { return invokeOrMock('restart_app', undefined, () => undefined); } +export function isDebugUiKeyEventsEnabled(): Promise { + return invokeOrMock('is_debug_ui_key_events_enabled', undefined, () => false); +} + +export function debugLogUiKeyEvent(payload: { + eventType: string; + key: string; + code: string; + ctrl: boolean; + alt: boolean; + shift: boolean; + meta: boolean; + repeat: boolean; +}): Promise { + return invokeOrMock( + 'debug_log_ui_key_event', + { + eventType: payload.eventType, + key: payload.key, + code: payload.code, + ctrl: payload.ctrl, + alt: payload.alt, + shift: payload.shift, + meta: payload.meta, + repeat: payload.repeat, + }, + () => undefined, + ); +} + +export function handleWindowHotkeyEvent(payload: { + eventType: string; + key: string; + code: string; + repeat: boolean; +}): Promise { + return invokeOrMock( + 'handle_window_hotkey_event', + { + eventType: payload.eventType, + key: payload.key, + code: payload.code, + repeat: payload.repeat, + }, + () => undefined, + ); +} + export async function openExternal(url: string): Promise { if (!isTauri) { window.open(url, '_blank', 'noopener,noreferrer');