merge: integrate PR 86 windows foreground hotkey

This commit is contained in:
baiqing committed 2026-04-30 18:52:01 +08:00
commit e86edd13c2
5 files changed
+263 -5

No files matched your search

@@ -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) {}
+95 -1
View File
@@ -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<Option<Recorder>>,
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
window_hotkey_held: Mutex<bool>,
}
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<String, String> {
let hotwords = enabled_phrases(&self.inner);
polish_text(&raw_text, mode, &hotwords)
@@ -260,6 +273,87 @@ fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
}
}
async fn handle_window_hotkey_event(
inner: &Arc<Inner>,
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<Inner>) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
+12 -3
View File
@@ -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);
}
_ => {}
}
}
}
+61 -1
View File
@@ -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 <StartupShell />;
}
+48
View File
@@ -220,6 +220,54 @@ export function restartApp(): Promise<void> {
return invokeOrMock('restart_app', undefined, () => undefined);
}
export function isDebugUiKeyEventsEnabled(): Promise<boolean> {
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<void> {
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<void> {
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<void> {
if (!isTauri) {
window.open(url, '_blank', 'noopener,noreferrer');