From 92e1c91097973be14da9683cc72b1d36c6bcb21a Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 17:35:18 +0800 Subject: [PATCH 1/9] feat: add configurable recording hotkeys --- openless-all/app/src-tauri/src/commands.rs | 1 + openless-all/app/src-tauri/src/coordinator.rs | 150 ++-- openless-all/app/src-tauri/src/hotkey.rs | 670 +++++++++++++++--- openless-all/app/src-tauri/src/types.rs | 171 ++++- openless-all/app/src/i18n/en.ts | 29 +- openless-all/app/src/i18n/zh-CN.ts | 31 +- openless-all/app/src/lib/hotkey.ts | 113 ++- openless-all/app/src/lib/ipc.ts | 4 +- openless-all/app/src/lib/types.ts | 7 +- openless-all/app/src/pages/History.tsx | 4 +- openless-all/app/src/pages/Overview.tsx | 4 +- openless-all/app/src/pages/QaPanel.tsx | 6 +- openless-all/app/src/pages/SelectionAsk.tsx | 4 +- openless-all/app/src/pages/Settings.tsx | 362 +++++++++- openless-all/app/src/pages/Translation.tsx | 4 +- 15 files changed, 1321 insertions(+), 239 deletions(-) diff --git a/openless-all/app/src-tauri/src/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 508000d3..24d0a69c 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -632,6 +632,7 @@ mod tests { hotkey: HotkeyBinding { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, + ..Default::default() }, qa_hotkey: Some(QaHotkeyBinding { primary: ";".to_string(), diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index a2867b61..0b860916 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -8,7 +8,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; -use std::time::Instant; +use std::time::{Duration, Instant}; use chrono::Utc; use parking_lot::Mutex; @@ -30,8 +30,8 @@ use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor}; use crate::recorder::{Recorder, RecorderError}; use crate::selection::{capture_selection, SelectionContext}; use crate::types::{ - CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus, - HotkeyStatusState, InsertStatus, PolishMode, + CapsulePayload, CapsuleState, DictationSession, HotkeyBinding, HotkeyCapability, HotkeyMode, + HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode, }; #[cfg(target_os = "windows")] use crate::windows_ime_ipc::ImeSubmitTarget; @@ -51,6 +51,8 @@ enum SessionPhase { Inserting, } +const HOTKEY_DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450); + enum ActiveAsr { Volcengine(Arc), Whisper(Arc), @@ -127,6 +129,7 @@ struct Inner { hotkey: Mutex>, hotkey_status: Mutex, hotkey_trigger_held: AtomicBool, + hotkey_last_click_at: Mutex>, /// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在 /// Listening / Starting 阶段看到 Shift down 边沿时 set true。 /// end_session 在调 polish/translate 前读这个 flag + translation_target_language @@ -222,6 +225,7 @@ impl Coordinator { hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), + hotkey_last_click_at: Mutex::new(None), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), @@ -698,6 +702,9 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { async fn handle_pressed_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); if !was_held { + if !should_accept_pressed_edge(inner) { + return; + } // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 let panel_visible = inner.qa_state.lock().panel_visible; if panel_visible { @@ -708,15 +715,30 @@ async fn handle_pressed_edge(inner: &Arc) { } } +fn should_accept_pressed_edge(inner: &Arc) -> bool { + if inner.prefs.get().hotkey.mode != HotkeyMode::DoubleClick { + *inner.hotkey_last_click_at.lock() = None; + return true; + } + + let now = Instant::now(); + let mut last_click_at = inner.hotkey_last_click_at.lock(); + let accepted = last_click_at + .map(|previous| now.duration_since(previous) <= HOTKEY_DOUBLE_CLICK_INTERVAL) + .unwrap_or(false); + *last_click_at = if accepted { None } else { Some(now) }; + accepted +} + async fn handle_pressed(inner: &Arc) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; log::info!("[coord] hotkey pressed (mode={mode:?}, phase={phase:?})"); match (mode, phase) { - (HotkeyMode::Toggle, SessionPhase::Idle) => { + (HotkeyMode::Toggle | HotkeyMode::DoubleClick, SessionPhase::Idle) => { let _ = begin_session(inner).await; } - (HotkeyMode::Toggle, SessionPhase::Listening) => { + (HotkeyMode::Toggle | HotkeyMode::DoubleClick, SessionPhase::Listening) => { let _ = end_session(inner).await; } (HotkeyMode::Hold, SessionPhase::Idle) => { @@ -724,7 +746,7 @@ async fn handle_pressed(inner: &Arc) { } // Toggle 模式 Starting 阶段第二次按 → 用户想停。 // 不能直接 end_session(ASR session 还没建好),存边沿,握手完成后立即触发。 - (HotkeyMode::Toggle, SessionPhase::Starting) => { + (HotkeyMode::Toggle | HotkeyMode::DoubleClick, SessionPhase::Starting) => { request_stop_during_starting(inner, "toggle stop edge"); } _ => {} @@ -886,8 +908,8 @@ async fn handle_window_hotkey_event( return Ok(()); } - let trigger = inner.prefs.get().hotkey.trigger; - if !window_key_matches_trigger(trigger, &key, &code) { + let binding = inner.prefs.get().hotkey; + if !window_key_matches_binding(&binding, &key, &code) { return Ok(()); } @@ -896,13 +918,11 @@ async fn handle_window_hotkey_event( if repeat { return Ok(()); } - log::info!( - "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" - ); + log::info!("[window-hotkey] pressed code={code} repeat={repeat}"); handle_pressed_edge(inner).await; } "keyup" => { - log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); + log::info!("[window-hotkey] released code={code}"); handle_released_edge(inner).await; } _ => {} @@ -916,18 +936,34 @@ fn window_hotkey_fallback_enabled() -> bool { } #[cfg(any(target_os = "windows", test))] -fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool { - use crate::types::HotkeyTrigger; +fn window_key_matches_binding(binding: &HotkeyBinding, key: &str, code: &str) -> bool { + let normalized = normalize_window_hotkey_code(key, code); + !normalized.is_empty() + && binding + .effective_codes() + .iter() + .any(|candidate| candidate == &normalized) +} - match trigger { - HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight", - HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft", - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { - (key == "Alt" || key == "AltGraph") && code == "AltRight" +#[cfg(any(target_os = "windows", test))] +fn normalize_window_hotkey_code(key: &str, code: &str) -> String { + if !code.is_empty() { + return code.to_string(); + } + match key { + "Control" => "ControlLeft".into(), + "Alt" | "AltGraph" => "AltLeft".into(), + "Shift" => "ShiftLeft".into(), + "Meta" => "MetaLeft".into(), + " " => "Space".into(), + "ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" => key.into(), + other if other.len() == 1 && other.as_bytes()[0].is_ascii_alphabetic() => { + format!("Key{}", other.to_ascii_uppercase()) + } + other if other.len() == 1 && other.as_bytes()[0].is_ascii_digit() => { + format!("Digit{other}") } - HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight", - HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", - HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", + other => other.into(), } } @@ -2548,7 +2584,7 @@ fn resolve_ark_endpoint_with_policy( #[cfg(test)] mod tests { use super::*; - use crate::types::HotkeyTrigger; + use crate::types::{HotkeyKey, HotkeyTrigger}; #[tokio::test] async fn hotkey_injection_gate_logs_pressed_and_cancels() { @@ -2566,35 +2602,21 @@ mod tests { } #[test] - fn window_key_matcher_mirrors_windows_trigger_aliases() { - let cases = [ - (HotkeyTrigger::RightControl, "Control", "ControlRight"), - (HotkeyTrigger::LeftControl, "Control", "ControlLeft"), - (HotkeyTrigger::RightOption, "Alt", "AltRight"), - (HotkeyTrigger::RightAlt, "AltGraph", "AltRight"), - (HotkeyTrigger::RightCommand, "Meta", "MetaRight"), - // Mirrors Windows trigger_to_vk_code aliases. - (HotkeyTrigger::LeftOption, "Alt", "AltRight"), - (HotkeyTrigger::Fn, "Control", "ControlRight"), - ]; - for (trigger, key, code) in cases { - assert!( - window_key_matches_trigger(trigger, key, code), - "{trigger:?} should match {key}/{code}" - ); - } + fn window_key_matcher_accepts_legacy_and_configured_codes() { + let legacy = HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + ..Default::default() + }; + assert!(window_key_matches_binding(&legacy, "Control", "ControlRight")); + assert!(!window_key_matches_binding(&legacy, "Control", "ControlLeft")); - assert!(!window_key_matches_trigger( - HotkeyTrigger::RightControl, - "Control", - "ControlLeft" - )); - assert!(!window_key_matches_trigger( - HotkeyTrigger::LeftOption, - "Alt", - "AltLeft" - )); - assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn")); + let caps_lock = HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + keys: Some(vec![HotkeyKey::new("CapsLock")]), + ..Default::default() + }; + assert!(window_key_matches_binding(&caps_lock, "CapsLock", "CapsLock")); + assert!(!window_key_matches_binding(&caps_lock, "Control", "ControlRight")); } #[test] @@ -2710,6 +2732,31 @@ mod tests { assert_eq!(state.session_id, 41); } + #[test] + fn double_click_mode_requires_second_press_within_window() { + let coordinator = Coordinator::new(); + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + ..Default::default() + }, + ..Default::default() + }) + .unwrap(); + + assert!(!should_accept_pressed_edge(&coordinator.inner)); + assert!(should_accept_pressed_edge(&coordinator.inner)); + assert!(!should_accept_pressed_edge(&coordinator.inner)); + + *coordinator.inner.hotkey_last_click_at.lock() = + Some(Instant::now() - HOTKEY_DOUBLE_CLICK_INTERVAL - Duration::from_millis(1)); + assert!(!should_accept_pressed_edge(&coordinator.inner)); + } + #[tokio::test] async fn repeated_pressed_edge_during_hold_session_does_not_restart() { let coordinator = Coordinator::new(); @@ -2720,6 +2767,7 @@ mod tests { hotkey: crate::types::HotkeyBinding { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Hold, + ..Default::default() }, ..Default::default() }) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 242f3c5d..c7ff68bc 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -10,6 +10,7 @@ //! //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 +use std::collections::BTreeSet; use std::sync::atomic::AtomicBool; use std::sync::mpsc::{self, Sender}; use std::sync::Arc; @@ -37,6 +38,7 @@ pub trait HotkeyAdapter: Send + Sync { struct Shared { binding: RwLock, + pressed_codes: RwLock>, /// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。 trigger_held: AtomicBool, /// Shift(翻译修饰键)当前是否按住。用于在 FLAGS_CHANGED 上识别 down 边沿 @@ -113,6 +115,7 @@ where { let shared = Arc::new(Shared { binding: RwLock::new(binding), + pressed_codes: RwLock::new(BTreeSet::new()), trigger_held: AtomicBool::new(false), translation_modifier_held: AtomicBool::new(false), }); @@ -133,11 +136,44 @@ where fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) { *shared.binding.write() = binding; + shared.pressed_codes.write().clear(); shared .trigger_held .store(false, std::sync::atomic::Ordering::SeqCst); } +fn dispatch_hotkey_code(shared: &Shared, tx: &Sender, code: &str, pressed: bool) { + if code.is_empty() { + return; + } + let binding = shared.binding.read().clone(); + let active_after = { + let mut pressed_codes = shared.pressed_codes.write(); + if pressed { + pressed_codes.insert(code.to_string()); + } else { + pressed_codes.remove(code); + } + binding_matches_pressed_codes(&binding, &pressed_codes) + }; + let was_active = shared + .trigger_held + .swap(active_after, std::sync::atomic::Ordering::SeqCst); + if active_after && !was_active { + send_or_log(tx, HotkeyEvent::Pressed); + } else if !active_after && was_active { + send_or_log(tx, HotkeyEvent::Released); + } +} + +fn binding_matches_pressed_codes(binding: &HotkeyBinding, pressed_codes: &BTreeSet) -> bool { + let codes = binding.effective_codes(); + !codes.is_empty() + && codes + .iter() + .all(|code| pressed_codes.contains(code.as_str())) +} + // ─────────────────────────── macOS implementation ─────────────────────────── #[cfg(target_os = "macos")] @@ -148,10 +184,10 @@ mod platform { use std::sync::Arc; use super::{ - install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, - HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, + update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; - use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; pub fn start_adapter( binding: HotkeyBinding, @@ -218,12 +254,17 @@ mod platform { const TAP_OPTION_DEFAULT: CgEventTapOptions = 0; const KEY_DOWN: CgEventType = 10; + const KEY_UP: CgEventType = 11; const FLAGS_CHANGED: CgEventType = 12; + const OTHER_MOUSE_DOWN: CgEventType = 25; + const OTHER_MOUSE_UP: CgEventType = 26; const TAP_DISABLED_BY_TIMEOUT: CgEventType = 0xFFFF_FFFE; const TAP_DISABLED_BY_USER_INPUT: CgEventType = 0xFFFF_FFFF; + const MOUSE_EVENT_BUTTON_NUMBER: CgEventField = 3; const KEYBOARD_EVENT_KEYCODE: CgEventField = 9; + const FLAG_MASK_ALPHA_SHIFT: CgEventFlags = 0x0001_0000; const FLAG_MASK_SHIFT: CgEventFlags = 0x0002_0000; const FLAG_MASK_CONTROL: CgEventFlags = 0x0004_0000; const FLAG_MASK_ALTERNATE: CgEventFlags = 0x0008_0000; @@ -277,7 +318,11 @@ mod platform { unsafe impl Sync for CallbackContext {} fn run_listen_loop(shared: Arc, tx: Sender, status_tx: StartupTx<()>) { - let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN); + let mask: CgEventMask = (1u64 << FLAGS_CHANGED) + | (1u64 << KEY_DOWN) + | (1u64 << KEY_UP) + | (1u64 << OTHER_MOUSE_DOWN) + | (1u64 << OTHER_MOUSE_UP); let context = Box::into_raw(Box::new(CallbackContext { shared, tx, @@ -337,6 +382,9 @@ mod platform { } FLAGS_CHANGED => handle_flags_changed(ctx, event), KEY_DOWN => handle_key_down(ctx, event), + KEY_UP => handle_key_up(ctx, event), + OTHER_MOUSE_DOWN => handle_mouse_button(ctx, event, true), + OTHER_MOUSE_UP => handle_mouse_button(ctx, event, false), _ => {} } event @@ -360,21 +408,17 @@ mod platform { } let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; - let trigger = ctx.shared.binding.read().trigger; - let expected_keycode = trigger_to_keycode(trigger); - if keycode != expected_keycode { - return; - } - let mask = trigger_to_flag_mask(trigger); - let is_active = (flags & mask) != 0; - let was_held = ctx.shared.trigger_held.load(Ordering::SeqCst); - - if is_active && !was_held { - ctx.shared.trigger_held.store(true, Ordering::SeqCst); - send_or_log(&ctx.tx, HotkeyEvent::Pressed); - } else if !is_active && was_held { - ctx.shared.trigger_held.store(false, Ordering::SeqCst); - send_or_log(&ctx.tx, HotkeyEvent::Released); + if let Some(code) = mac_keycode_to_hotkey_code(keycode) { + if let Some(mask) = mac_keycode_flag_mask(keycode) { + let family_active = (flags & mask) != 0; + let code_was_pressed = ctx.shared.pressed_codes.read().contains(code); + dispatch_hotkey_code( + &ctx.shared, + &ctx.tx, + code, + family_active && !code_was_pressed, + ); + } } } @@ -382,28 +426,154 @@ mod platform { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if keycode == ESC_KEYCODE { send_or_log(&ctx.tx, HotkeyEvent::Cancelled); + return; + } + if let Some(code) = mac_keycode_to_hotkey_code(keycode) { + if mac_keycode_flag_mask(keycode).is_none() { + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, true); + } } } - fn trigger_to_keycode(trigger: HotkeyTrigger) -> i64 { - match trigger { - HotkeyTrigger::LeftControl => 59, - HotkeyTrigger::RightControl => 62, - HotkeyTrigger::LeftOption => 58, - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => 61, - HotkeyTrigger::RightCommand => 54, - HotkeyTrigger::Fn => 63, + fn handle_key_up(ctx: &CallbackContext, event: CgEventRef) { + let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + if let Some(code) = mac_keycode_to_hotkey_code(keycode) { + if mac_keycode_flag_mask(keycode).is_none() { + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, false); + } } } - fn trigger_to_flag_mask(trigger: HotkeyTrigger) -> CgEventFlags { - match trigger { - HotkeyTrigger::LeftControl | HotkeyTrigger::RightControl => FLAG_MASK_CONTROL, - HotkeyTrigger::RightCommand => FLAG_MASK_COMMAND, - HotkeyTrigger::LeftOption | HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { - FLAG_MASK_ALTERNATE - } - HotkeyTrigger::Fn => FLAG_MASK_SECONDARY_FN, + fn handle_mouse_button(ctx: &CallbackContext, event: CgEventRef, pressed: bool) { + let button = unsafe { CGEventGetIntegerValueField(event, MOUSE_EVENT_BUTTON_NUMBER) }; + let code = match button { + 3 => "Mouse4", + 4 => "Mouse5", + _ => return, + }; + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, pressed); + } + + fn mac_keycode_flag_mask(keycode: i64) -> Option { + match keycode { + 54 | 55 => Some(FLAG_MASK_COMMAND), + 56 | 60 => Some(FLAG_MASK_SHIFT), + 57 => Some(FLAG_MASK_ALPHA_SHIFT), + 58 | 61 => Some(FLAG_MASK_ALTERNATE), + 59 | 62 => Some(FLAG_MASK_CONTROL), + 63 => Some(FLAG_MASK_SECONDARY_FN), + _ => None, + } + } + + fn mac_keycode_to_hotkey_code(keycode: i64) -> Option<&'static str> { + match keycode { + 0 => Some("KeyA"), + 1 => Some("KeyS"), + 2 => Some("KeyD"), + 3 => Some("KeyF"), + 4 => Some("KeyH"), + 5 => Some("KeyG"), + 6 => Some("KeyZ"), + 7 => Some("KeyX"), + 8 => Some("KeyC"), + 9 => Some("KeyV"), + 11 => Some("KeyB"), + 12 => Some("KeyQ"), + 13 => Some("KeyW"), + 14 => Some("KeyE"), + 15 => Some("KeyR"), + 16 => Some("KeyY"), + 17 => Some("KeyT"), + 18 => Some("Digit1"), + 19 => Some("Digit2"), + 20 => Some("Digit3"), + 21 => Some("Digit4"), + 22 => Some("Digit6"), + 23 => Some("Digit5"), + 24 => Some("Equal"), + 25 => Some("Digit9"), + 26 => Some("Digit7"), + 27 => Some("Minus"), + 28 => Some("Digit8"), + 29 => Some("Digit0"), + 30 => Some("BracketRight"), + 31 => Some("KeyO"), + 32 => Some("KeyU"), + 33 => Some("BracketLeft"), + 34 => Some("KeyI"), + 35 => Some("KeyP"), + 36 => Some("Enter"), + 37 => Some("KeyL"), + 38 => Some("KeyJ"), + 39 => Some("Quote"), + 40 => Some("KeyK"), + 41 => Some("Semicolon"), + 42 => Some("Backslash"), + 43 => Some("Comma"), + 44 => Some("Slash"), + 45 => Some("KeyN"), + 46 => Some("KeyM"), + 47 => Some("Period"), + 48 => Some("Tab"), + 49 => Some("Space"), + 50 => Some("Backquote"), + 51 => Some("Backspace"), + 54 => Some("MetaRight"), + 55 => Some("MetaLeft"), + 56 => Some("ShiftLeft"), + 57 => Some("CapsLock"), + 58 => Some("AltLeft"), + 59 => Some("ControlLeft"), + 60 => Some("ShiftRight"), + 61 => Some("AltRight"), + 62 => Some("ControlRight"), + 63 => Some("Fn"), + 64 => Some("F17"), + 65 => Some("NumpadDecimal"), + 67 => Some("NumpadMultiply"), + 69 => Some("NumpadAdd"), + 75 => Some("NumpadDivide"), + 76 => Some("NumpadEnter"), + 78 => Some("NumpadSubtract"), + 79 => Some("F18"), + 80 => Some("F19"), + 82 => Some("Numpad0"), + 83 => Some("Numpad1"), + 84 => Some("Numpad2"), + 85 => Some("Numpad3"), + 86 => Some("Numpad4"), + 87 => Some("Numpad5"), + 88 => Some("Numpad6"), + 89 => Some("Numpad7"), + 91 => Some("Numpad8"), + 92 => Some("Numpad9"), + 96 => Some("F5"), + 97 => Some("F6"), + 98 => Some("F7"), + 99 => Some("F3"), + 100 => Some("F8"), + 101 => Some("F9"), + 103 => Some("F11"), + 105 => Some("F13"), + 106 => Some("F16"), + 107 => Some("F14"), + 109 => Some("F10"), + 111 => Some("F12"), + 113 => Some("F15"), + 115 => Some("Home"), + 116 => Some("PageUp"), + 117 => Some("Delete"), + 118 => Some("F4"), + 119 => Some("End"), + 120 => Some("F2"), + 121 => Some("PageDown"), + 122 => Some("F1"), + 123 => Some("ArrowLeft"), + 124 => Some("ArrowRight"), + 125 => Some("ArrowDown"), + 126 => Some("ArrowUp"), + _ => None, } } } @@ -421,29 +591,72 @@ mod platform { use windows::Win32::System::Threading::GetCurrentThreadId; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, DispatchMessageW, GetMessageW, PostThreadMessageW, SetWindowsHookExW, - TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, - WH_KEYBOARD_LL, WM_QUIT, + TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, + MSLLHOOKSTRUCT, MSG, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_QUIT, }; use super::{ - install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter, - HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, + update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; - use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; const WM_KEYDOWN: usize = 0x0100; const WM_KEYUP: usize = 0x0101; const WM_SYSKEYDOWN: usize = 0x0104; const WM_SYSKEYUP: usize = 0x0105; + const WM_XBUTTONDOWN: usize = 0x020B; + const WM_XBUTTONUP: usize = 0x020C; + const VK_BACK: u32 = 0x08; + const VK_TAB: u32 = 0x09; + const VK_RETURN: u32 = 0x0D; const VK_ESCAPE: u32 = 0x1B; const VK_SHIFT: u32 = 0x10; + const VK_CONTROL: u32 = 0x11; + const VK_MENU: u32 = 0x12; + const VK_PAUSE: u32 = 0x13; + const VK_CAPITAL: u32 = 0x14; + const VK_SPACE: u32 = 0x20; + const VK_PRIOR: u32 = 0x21; + const VK_NEXT: u32 = 0x22; + const VK_END: u32 = 0x23; + const VK_HOME: u32 = 0x24; + const VK_LEFT: u32 = 0x25; + const VK_UP: u32 = 0x26; + const VK_RIGHT: u32 = 0x27; + const VK_DOWN: u32 = 0x28; + const VK_SNAPSHOT: u32 = 0x2C; + const VK_INSERT: u32 = 0x2D; + const VK_DELETE: u32 = 0x2E; + const VK_APPS: u32 = 0x5D; + const VK_LWIN: u32 = 0x5B; + const VK_RWIN: u32 = 0x5C; + const VK_MULTIPLY: u32 = 0x6A; + const VK_ADD: u32 = 0x6B; + const VK_SUBTRACT: u32 = 0x6D; + const VK_DECIMAL: u32 = 0x6E; + const VK_DIVIDE: u32 = 0x6F; + const VK_SCROLL: u32 = 0x91; const VK_LSHIFT: u32 = 0xA0; const VK_RSHIFT: u32 = 0xA1; const VK_LCONTROL: u32 = 0xA2; const VK_RCONTROL: u32 = 0xA3; + const VK_LMENU: u32 = 0xA4; const VK_RMENU: u32 = 0xA5; - const VK_RWIN: u32 = 0x5C; + const VK_OEM_1: u32 = 0xBA; + const VK_OEM_PLUS: u32 = 0xBB; + const VK_OEM_COMMA: u32 = 0xBC; + const VK_OEM_MINUS: u32 = 0xBD; + const VK_OEM_PERIOD: u32 = 0xBE; + const VK_OEM_2: u32 = 0xBF; + const VK_OEM_3: u32 = 0xC0; + const VK_OEM_4: u32 = 0xDB; + const VK_OEM_5: u32 = 0xDC; + const VK_OEM_6: u32 = 0xDD; + const VK_OEM_7: u32 = 0xDE; + const XBUTTON1: u32 = 0x0001; + const XBUTTON2: u32 = 0x0002; const LLKHF_INJECTED: u32 = 0x0000_0010; const ACCEPT_INJECTED_ENV: &str = "OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS"; @@ -493,7 +706,8 @@ mod platform { struct CallbackContext { shared: Arc, tx: Sender, - hook: std::sync::Mutex>, + keyboard_hook: std::sync::Mutex>, + mouse_hook: std::sync::Mutex>, } unsafe impl Send for CallbackContext {} @@ -504,7 +718,8 @@ mod platform { let context = Box::into_raw(Box::new(CallbackContext { shared, tx, - hook: std::sync::Mutex::new(None), + keyboard_hook: std::sync::Mutex::new(None), + mouse_hook: std::sync::Mutex::new(None), })); HOOK_CONTEXT.store(context, AtomicOrdering::SeqCst); @@ -512,9 +727,8 @@ mod platform { let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(low_level_keyboard_proc), None, 0); match hook { Ok(hook) => { - *(*context).hook.lock().unwrap() = Some(hook); + *(*context).keyboard_hook.lock().unwrap() = Some(hook); log::info!("[hotkey] Windows low-level keyboard hook 已启动"); - let _ = status_tx.send(Ok(thread_id)); } Err(err) => { HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst); @@ -527,6 +741,19 @@ mod platform { } } + match SetWindowsHookExW(WH_MOUSE_LL, Some(low_level_mouse_proc), None, 0) { + Ok(hook) => { + *(*context).mouse_hook.lock().unwrap() = Some(hook); + log::info!("[hotkey] Windows low-level mouse hook installed"); + } + Err(err) => { + log::warn!( + "[hotkey] Windows low-level mouse hook install failed; Mouse4/Mouse5 hotkeys will be unavailable: {err}" + ); + } + } + let _ = status_tx.send(Ok(thread_id)); + let mut message = MSG::default(); loop { let result = GetMessageW(&mut message, None, 0, 0).0; @@ -542,7 +769,10 @@ mod platform { let _ = DispatchMessageW(&message); } - if let Some(hook) = (*context).hook.lock().unwrap().take() { + if let Some(hook) = (*context).keyboard_hook.lock().unwrap().take() { + let _ = UnhookWindowsHookEx(hook); + } + if let Some(hook) = (*context).mouse_hook.lock().unwrap().take() { let _ = UnhookWindowsHookEx(hook); } HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst); @@ -567,6 +797,21 @@ mod platform { CallNextHookEx(None, code, wparam, lparam) } + unsafe extern "system" fn low_level_mouse_proc( + code: i32, + wparam: WPARAM, + lparam: LPARAM, + ) -> LRESULT { + if code == HC_ACTION as i32 && lparam.0 != 0 { + if let Some(ctx) = callback_context() { + let mouse = *(lparam.0 as *const MSLLHOOKSTRUCT); + dispatch_mouse_event(ctx, mouse.mouseData, wparam.0); + } + } + + CallNextHookEx(None, code, wparam, lparam) + } + unsafe fn callback_context<'a>() -> Option<&'a CallbackContext> { let ptr = HOOK_CONTEXT.load(AtomicOrdering::SeqCst); if ptr.is_null() { @@ -577,7 +822,10 @@ mod platform { } fn dispatch_keyboard_event(ctx: &CallbackContext, vk_code: u32, message: usize) { - if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) { + let is_down = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN); + let is_up = matches!(message, WM_KEYUP | WM_SYSKEYUP); + + if vk_code == VK_ESCAPE && is_down { send_or_log(&ctx.tx, HotkeyEvent::Cancelled); return; } @@ -601,47 +849,145 @@ mod platform { } _ => {} } - return; } - let trigger = ctx.shared.binding.read().trigger; - if vk_code != trigger_to_vk_code(trigger) { - return; - } - - match message { - WM_KEYDOWN | WM_SYSKEYDOWN => { - let was_held = ctx.shared.trigger_held.swap(true, Ordering::SeqCst); - if !was_held { - log::info!("[hotkey] Windows trigger pressed vk={vk_code}"); - send_or_log(&ctx.tx, HotkeyEvent::Pressed); - } + if let Some(code) = vk_to_hotkey_code(vk_code) { + if is_down { + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, true); + } else if is_up { + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, false); } - WM_KEYUP | WM_SYSKEYUP => { - let was_held = ctx.shared.trigger_held.swap(false, Ordering::SeqCst); - if was_held { - log::info!("[hotkey] Windows trigger released vk={vk_code}"); - send_or_log(&ctx.tx, HotkeyEvent::Released); - } - } - _ => {} } } - fn trigger_to_vk_code(trigger: HotkeyTrigger) -> u32 { - // Windows only gives us a small set of modifier virtual keys that can be - // used as reliable modifier-only global triggers, so the cross-platform - // trigger list intentionally collapses a few aliases onto the same - // physical Windows key: - // - LeftOption reuses RightAlt / VK_RMENU - // - Fn reuses RightControl / VK_RCONTROL - match trigger { - HotkeyTrigger::RightControl => VK_RCONTROL, - HotkeyTrigger::LeftControl => VK_LCONTROL, - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => VK_RMENU, - HotkeyTrigger::RightCommand => VK_RWIN, - HotkeyTrigger::LeftOption => VK_RMENU, - HotkeyTrigger::Fn => VK_RCONTROL, + fn dispatch_mouse_event(ctx: &CallbackContext, mouse_data: u32, message: usize) { + let code = match ((mouse_data >> 16) & 0xffff, message) { + (XBUTTON1, WM_XBUTTONDOWN | WM_XBUTTONUP) => "Mouse4", + (XBUTTON2, WM_XBUTTONDOWN | WM_XBUTTONUP) => "Mouse5", + _ => return, + }; + dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, message == WM_XBUTTONDOWN); + } + + fn vk_to_hotkey_code(vk_code: u32) -> Option<&'static str> { + match vk_code { + VK_SHIFT => Some("ShiftLeft"), + VK_LSHIFT => Some("ShiftLeft"), + VK_RSHIFT => Some("ShiftRight"), + VK_CONTROL => Some("ControlLeft"), + VK_LCONTROL => Some("ControlLeft"), + VK_RCONTROL => Some("ControlRight"), + VK_MENU => Some("AltLeft"), + VK_LMENU => Some("AltLeft"), + VK_RMENU => Some("AltRight"), + VK_LWIN => Some("MetaLeft"), + VK_RWIN => Some("MetaRight"), + VK_BACK => Some("Backspace"), + VK_TAB => Some("Tab"), + VK_RETURN => Some("Enter"), + VK_CAPITAL => Some("CapsLock"), + VK_PAUSE => Some("Pause"), + VK_SPACE => Some("Space"), + VK_PRIOR => Some("PageUp"), + VK_NEXT => Some("PageDown"), + VK_END => Some("End"), + VK_HOME => Some("Home"), + VK_LEFT => Some("ArrowLeft"), + VK_UP => Some("ArrowUp"), + VK_RIGHT => Some("ArrowRight"), + VK_DOWN => Some("ArrowDown"), + VK_SNAPSHOT => Some("PrintScreen"), + VK_INSERT => Some("Insert"), + VK_DELETE => Some("Delete"), + VK_APPS => Some("ContextMenu"), + VK_MULTIPLY => Some("NumpadMultiply"), + VK_ADD => Some("NumpadAdd"), + VK_SUBTRACT => Some("NumpadSubtract"), + VK_DECIMAL => Some("NumpadDecimal"), + VK_DIVIDE => Some("NumpadDivide"), + VK_SCROLL => Some("ScrollLock"), + VK_OEM_1 => Some("Semicolon"), + VK_OEM_PLUS => Some("Equal"), + VK_OEM_COMMA => Some("Comma"), + VK_OEM_MINUS => Some("Minus"), + VK_OEM_PERIOD => Some("Period"), + VK_OEM_2 => Some("Slash"), + VK_OEM_3 => Some("Backquote"), + VK_OEM_4 => Some("BracketLeft"), + VK_OEM_5 => Some("Backslash"), + VK_OEM_6 => Some("BracketRight"), + VK_OEM_7 => Some("Quote"), + 0x30 => Some("Digit0"), + 0x31 => Some("Digit1"), + 0x32 => Some("Digit2"), + 0x33 => Some("Digit3"), + 0x34 => Some("Digit4"), + 0x35 => Some("Digit5"), + 0x36 => Some("Digit6"), + 0x37 => Some("Digit7"), + 0x38 => Some("Digit8"), + 0x39 => Some("Digit9"), + 0x41 => Some("KeyA"), + 0x42 => Some("KeyB"), + 0x43 => Some("KeyC"), + 0x44 => Some("KeyD"), + 0x45 => Some("KeyE"), + 0x46 => Some("KeyF"), + 0x47 => Some("KeyG"), + 0x48 => Some("KeyH"), + 0x49 => Some("KeyI"), + 0x4A => Some("KeyJ"), + 0x4B => Some("KeyK"), + 0x4C => Some("KeyL"), + 0x4D => Some("KeyM"), + 0x4E => Some("KeyN"), + 0x4F => Some("KeyO"), + 0x50 => Some("KeyP"), + 0x51 => Some("KeyQ"), + 0x52 => Some("KeyR"), + 0x53 => Some("KeyS"), + 0x54 => Some("KeyT"), + 0x55 => Some("KeyU"), + 0x56 => Some("KeyV"), + 0x57 => Some("KeyW"), + 0x58 => Some("KeyX"), + 0x59 => Some("KeyY"), + 0x5A => Some("KeyZ"), + 0x60 => Some("Numpad0"), + 0x61 => Some("Numpad1"), + 0x62 => Some("Numpad2"), + 0x63 => Some("Numpad3"), + 0x64 => Some("Numpad4"), + 0x65 => Some("Numpad5"), + 0x66 => Some("Numpad6"), + 0x67 => Some("Numpad7"), + 0x68 => Some("Numpad8"), + 0x69 => Some("Numpad9"), + 0x70 => Some("F1"), + 0x71 => Some("F2"), + 0x72 => Some("F3"), + 0x73 => Some("F4"), + 0x74 => Some("F5"), + 0x75 => Some("F6"), + 0x76 => Some("F7"), + 0x77 => Some("F8"), + 0x78 => Some("F9"), + 0x79 => Some("F10"), + 0x7A => Some("F11"), + 0x7B => Some("F12"), + 0x7C => Some("F13"), + 0x7D => Some("F14"), + 0x7E => Some("F15"), + 0x7F => Some("F16"), + 0x80 => Some("F17"), + 0x81 => Some("F18"), + 0x82 => Some("F19"), + 0x83 => Some("F20"), + 0x84 => Some("F21"), + 0x85 => Some("F22"), + 0x86 => Some("F23"), + 0x87 => Some("F24"), + _ => None, } } @@ -659,13 +1005,13 @@ mod platform { use std::sync::Arc; use std::time::Duration; - use rdev::{listen, Event, EventType, Key}; + use rdev::{listen, Button, Event, EventType, Key}; use super::{ - install_error, start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, - Shared, StartupTx, + dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, + update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; - use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; pub fn start_adapter( binding: HotkeyBinding, @@ -730,11 +1076,10 @@ mod platform { } fn dispatch_event(shared: &Shared, tx: &Sender, event: Event) { - let trigger = shared.binding.read().trigger; match event.event_type { EventType::KeyPress(key) => { if key == Key::Escape { - let _ = tx.send(HotkeyEvent::Cancelled); + send_or_log(tx, HotkeyEvent::Cancelled); return; } // Shift(任一侧)= 翻译模式修饰键。详见 issue #4。 @@ -743,15 +1088,11 @@ mod platform { .translation_modifier_held .swap(true, Ordering::SeqCst); if !was_held { - let _ = tx.send(HotkeyEvent::TranslationModifierPressed); + send_or_log(tx, HotkeyEvent::TranslationModifierPressed); } - return; } - if key == trigger_to_rdev_key(trigger) { - let was_held = shared.trigger_held.swap(true, Ordering::SeqCst); - if !was_held { - let _ = tx.send(HotkeyEvent::Pressed); - } + if let Some(code) = rdev_key_to_hotkey_code(key) { + dispatch_hotkey_code(shared, tx, code, true); } } EventType::KeyRelease(key) => { @@ -759,27 +1100,138 @@ mod platform { shared .translation_modifier_held .store(false, Ordering::SeqCst); - return; } - if key == trigger_to_rdev_key(trigger) { - let was_held = shared.trigger_held.swap(false, Ordering::SeqCst); - if was_held { - let _ = tx.send(HotkeyEvent::Released); - } + if let Some(code) = rdev_key_to_hotkey_code(key) { + dispatch_hotkey_code(shared, tx, code, false); + } + } + EventType::ButtonPress(button) => { + if let Some(code) = rdev_button_to_hotkey_code(button) { + dispatch_hotkey_code(shared, tx, code, true); + } + } + EventType::ButtonRelease(button) => { + if let Some(code) = rdev_button_to_hotkey_code(button) { + dispatch_hotkey_code(shared, tx, code, false); } } _ => {} } } - fn trigger_to_rdev_key(trigger: HotkeyTrigger) -> Key { - match trigger { - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => Key::AltGr, - HotkeyTrigger::LeftOption => Key::Alt, - HotkeyTrigger::RightControl => Key::ControlRight, - HotkeyTrigger::LeftControl => Key::ControlLeft, - HotkeyTrigger::RightCommand => Key::MetaRight, - HotkeyTrigger::Fn => Key::Function, + fn rdev_button_to_hotkey_code(button: Button) -> Option<&'static str> { + match button { + Button::Unknown(4) | Button::Unknown(8) => Some("Mouse4"), + Button::Unknown(5) | Button::Unknown(9) => Some("Mouse5"), + _ => None, + } + } + + fn rdev_key_to_hotkey_code(key: Key) -> Option<&'static str> { + match key { + Key::Alt => Some("AltLeft"), + Key::AltGr => Some("AltRight"), + Key::Backspace => Some("Backspace"), + Key::CapsLock => Some("CapsLock"), + Key::ControlLeft => Some("ControlLeft"), + Key::ControlRight => Some("ControlRight"), + Key::Delete => Some("Delete"), + Key::DownArrow => Some("ArrowDown"), + Key::End => Some("End"), + Key::F1 => Some("F1"), + Key::F2 => Some("F2"), + Key::F3 => Some("F3"), + Key::F4 => Some("F4"), + Key::F5 => Some("F5"), + Key::F6 => Some("F6"), + Key::F7 => Some("F7"), + Key::F8 => Some("F8"), + Key::F9 => Some("F9"), + Key::F10 => Some("F10"), + Key::F11 => Some("F11"), + Key::F12 => Some("F12"), + Key::Home => Some("Home"), + Key::LeftArrow => Some("ArrowLeft"), + Key::MetaLeft => Some("MetaLeft"), + Key::MetaRight => Some("MetaRight"), + Key::PageDown => Some("PageDown"), + Key::PageUp => Some("PageUp"), + Key::Return => Some("Enter"), + Key::RightArrow => Some("ArrowRight"), + Key::ShiftLeft => Some("ShiftLeft"), + Key::ShiftRight => Some("ShiftRight"), + Key::Space => Some("Space"), + Key::Tab => Some("Tab"), + Key::UpArrow => Some("ArrowUp"), + Key::PrintScreen => Some("PrintScreen"), + Key::ScrollLock => Some("ScrollLock"), + Key::Pause => Some("Pause"), + Key::BackQuote => Some("Backquote"), + Key::Num0 => Some("Digit0"), + Key::Num1 => Some("Digit1"), + Key::Num2 => Some("Digit2"), + Key::Num3 => Some("Digit3"), + Key::Num4 => Some("Digit4"), + Key::Num5 => Some("Digit5"), + Key::Num6 => Some("Digit6"), + Key::Num7 => Some("Digit7"), + Key::Num8 => Some("Digit8"), + Key::Num9 => Some("Digit9"), + Key::Minus => Some("Minus"), + Key::Equal => Some("Equal"), + Key::KeyA => Some("KeyA"), + Key::KeyB => Some("KeyB"), + Key::KeyC => Some("KeyC"), + Key::KeyD => Some("KeyD"), + Key::KeyE => Some("KeyE"), + Key::KeyF => Some("KeyF"), + Key::KeyG => Some("KeyG"), + Key::KeyH => Some("KeyH"), + Key::KeyI => Some("KeyI"), + Key::KeyJ => Some("KeyJ"), + Key::KeyK => Some("KeyK"), + Key::KeyL => Some("KeyL"), + Key::KeyM => Some("KeyM"), + Key::KeyN => Some("KeyN"), + Key::KeyO => Some("KeyO"), + Key::KeyP => Some("KeyP"), + Key::KeyQ => Some("KeyQ"), + Key::KeyR => Some("KeyR"), + Key::KeyS => Some("KeyS"), + Key::KeyT => Some("KeyT"), + Key::KeyU => Some("KeyU"), + Key::KeyV => Some("KeyV"), + Key::KeyW => Some("KeyW"), + Key::KeyX => Some("KeyX"), + Key::KeyY => Some("KeyY"), + Key::KeyZ => Some("KeyZ"), + Key::LeftBracket => Some("BracketLeft"), + Key::RightBracket => Some("BracketRight"), + Key::SemiColon => Some("Semicolon"), + Key::Quote => Some("Quote"), + Key::BackSlash | Key::IntlBackslash => Some("Backslash"), + Key::Comma => Some("Comma"), + Key::Dot => Some("Period"), + Key::Slash => Some("Slash"), + Key::Insert => Some("Insert"), + Key::KpReturn => Some("NumpadEnter"), + Key::KpMinus => Some("NumpadSubtract"), + Key::KpPlus => Some("NumpadAdd"), + Key::KpMultiply => Some("NumpadMultiply"), + Key::KpDivide => Some("NumpadDivide"), + Key::Kp0 => Some("Numpad0"), + Key::Kp1 => Some("Numpad1"), + Key::Kp2 => Some("Numpad2"), + Key::Kp3 => Some("Numpad3"), + Key::Kp4 => Some("Numpad4"), + Key::Kp5 => Some("Numpad5"), + Key::Kp6 => Some("Numpad6"), + Key::Kp7 => Some("Numpad7"), + Key::Kp8 => Some("Numpad8"), + Key::Kp9 => Some("Numpad9"), + Key::KpDelete => Some("NumpadDecimal"), + Key::Function => Some("Fn"), + _ => None, } } } diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index 50b2f078..fae9cc64 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -258,6 +258,7 @@ impl HotkeyTrigger { pub enum HotkeyMode { Toggle, Hold, + DoubleClick, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -278,11 +279,131 @@ impl HotkeyAdapterKind { } } -#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "camelCase")] +pub struct HotkeyKey { + pub code: String, +} + +impl HotkeyKey { + pub fn new(code: impl Into) -> Self { + Self { code: code.into() } + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] #[serde(default, rename_all = "camelCase")] pub struct HotkeyBinding { pub trigger: HotkeyTrigger, pub mode: HotkeyMode, + pub keys: Option>, +} + +impl HotkeyBinding { + pub fn effective_codes(&self) -> Vec { + let Some(keys) = &self.keys else { + return vec![legacy_trigger_code(self.trigger).to_string()]; + }; + keys.iter() + .map(|key| key.code.trim().to_string()) + .filter(|code| !code.is_empty()) + .collect() + } + + pub fn display_label(&self) -> String { + let codes = self.effective_codes(); + if codes.is_empty() { + return "未设置".to_string(); + } + codes + .iter() + .map(|code| display_hotkey_code(code)) + .collect::>() + .join("+") + } +} + +fn legacy_trigger_code(trigger: HotkeyTrigger) -> &'static str { + match trigger { + HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => "AltRight", + HotkeyTrigger::LeftOption => "AltLeft", + HotkeyTrigger::RightControl => "ControlRight", + HotkeyTrigger::LeftControl => "ControlLeft", + HotkeyTrigger::RightCommand => "MetaRight", + HotkeyTrigger::Fn => "Fn", + } +} + +fn display_hotkey_code(code: &str) -> String { + let label = match code { + "ControlLeft" => "左Ctrl", + "ControlRight" => "右 Control", + "AltLeft" => "左Alt", + "AltRight" => "右Alt", + "ShiftLeft" => "左Shift", + "ShiftRight" => "右Shift", + "MetaLeft" | "OSLeft" => "左Win", + "MetaRight" | "OSRight" => "右Win", + "Fn" => "Fn", + "FnLock" => "FnLock", + "CapsLock" => "CapsLock", + "ScrollLock" => "ScrLock", + "Pause" => "Pause", + "PrintScreen" => "PrtSc", + "Backspace" => "Backspace", + "Tab" => "Tab", + "Enter" => "Enter", + "Space" => "Space", + "Insert" => "Insert", + "Delete" => "Delete", + "Home" => "Home", + "End" => "End", + "PageUp" => "PageUp", + "PageDown" => "PageDown", + "ArrowUp" => "Up", + "ArrowDown" => "Down", + "ArrowLeft" => "Left", + "ArrowRight" => "Right", + "NumpadAdd" => "Num+", + "NumpadSubtract" => "Num-", + "NumpadMultiply" => "Num*", + "NumpadDivide" => "Num/", + "NumpadDecimal" => "Num.", + "NumpadEnter" => "NumEnter", + "Mouse4" => "Mouse4", + "Mouse5" => "Mouse5", + "Backquote" => "`", + "Minus" => "-", + "Equal" => "=", + "BracketLeft" => "[", + "BracketRight" => "]", + "Backslash" => "\\", + "Semicolon" => ";", + "Quote" => "'", + "Comma" => ",", + "Period" => ".", + "Slash" => "/", + _ => "", + }; + if !label.is_empty() { + return label.to_string(); + } + if let Some(letter) = code.strip_prefix("Key") { + if letter.len() == 1 { + return letter.to_string(); + } + } + if let Some(digit) = code.strip_prefix("Digit") { + if digit.len() == 1 { + return digit.to_string(); + } + } + if let Some(num) = code.strip_prefix("Numpad") { + if num.len() == 1 && num.as_bytes()[0].is_ascii_digit() { + return format!("Num{num}"); + } + } + code.to_string() } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] @@ -334,7 +455,7 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。" + "默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。" .into(), ), }; @@ -427,6 +548,7 @@ impl Default for HotkeyBinding { Self { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("ControlRight")]), } } @@ -435,6 +557,7 @@ impl Default for HotkeyBinding { Self { trigger: HotkeyTrigger::RightOption, mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("AltRight")]), } } } @@ -511,4 +634,48 @@ mod tests { assert!(prefs.allow_non_tsf_insertion_fallback); } + + #[test] + fn legacy_hotkey_trigger_still_produces_effective_key_codes() { + let binding: HotkeyBinding = + serde_json::from_str(r#"{"trigger":"rightControl","mode":"toggle"}"#).unwrap(); + + assert_eq!(binding.effective_codes(), vec!["ControlRight".to_string()]); + assert_eq!(binding.display_label(), "右 Control"); + } + + #[test] + fn hotkey_binding_supports_combo_side_keys_mouse_and_double_click_mode() { + let binding = HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + keys: Some(vec![ + HotkeyKey::new("ControlLeft"), + HotkeyKey::new("AltLeft"), + HotkeyKey::new("Mouse4"), + ]), + }; + + assert_eq!( + binding.effective_codes(), + vec![ + "ControlLeft".to_string(), + "AltLeft".to_string(), + "Mouse4".to_string() + ] + ); + assert_eq!(binding.display_label(), "左Ctrl+左Alt+Mouse4"); + + let json = serde_json::to_value(&binding).unwrap(); + assert_eq!(json["mode"], "doubleClick"); + } + + #[test] + fn explicit_empty_hotkey_keys_clear_the_binding() { + let binding: HotkeyBinding = + serde_json::from_str(r#"{"trigger":"rightControl","mode":"toggle","keys":[]}"#) + .unwrap(); + + assert!(binding.effective_codes().is_empty()); + } } diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index b4a3804a..36476876 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -84,7 +84,7 @@ export const en: typeof zhCN = { }, hotkeyModePrompt: { title: 'Review your recording mode', - body: 'This version now defaults to Toggle. If you changed the hotkey trigger mode before, please open Recording settings and confirm it once. This update also adjusts how the hotkey mode preference is read; if you prefer push-to-talk, switch it back manually.', + body: 'This version now defaults to Single. If you changed the hotkey trigger mode before, please open Recording settings and confirm it once. This update also adjusts how the hotkey mode preference is read; if you prefer hold-to-talk, switch it back to Hold.', later: 'Remind me later', openSettings: 'Open Recording', }, @@ -254,14 +254,20 @@ export const en: typeof zhCN = { title: 'Recording', desc: 'Define the global recording hotkey and how it triggers.', hotkeyLabel: 'Recording hotkey', - hotkeyDescAcc: 'Pressing it captures voice globally. Requires Accessibility permission.', - hotkeyDescNoAcc: 'Pressing it captures voice globally. No Accessibility permission required.', + hotkeyDescAcc: 'Choose single, hold, or double trigger; the hotkey can be left empty.', + hotkeyDescNoAcc: 'Choose single, hold, or double trigger; the hotkey can be left empty.', + hotkeyRecording: 'Press keys...', + hotkeyClear: 'Clear hotkey', + hotkeySetStatus: 'Set: {{hotkey}}', + hotkeyUnsetStatus: 'Not set', modeLabel: 'Trigger mode', - modeDesc: 'Toggle = press once to start, again to stop. Push-to-talk = hold to record, release to stop.', - modeToggle: 'Toggle', - modeHold: 'Push-to-talk', - migrationNoticeTitle: 'Default recording mode is now Toggle', - migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.', + keyLabel: 'Hotkey', + modeDesc: 'Single starts/stops recording. Hold starts on press and stops on release. Double requires two presses.', + modeToggle: 'Single', + modeHold: 'Hold', + modeDoubleClick: 'Double', + migrationNoticeTitle: 'Default recording mode is now Single', + migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer hold-to-talk, switch it back to Hold.', capsuleLabel: 'Recording capsule', capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.', restoreClipboardLabel: 'Restore clipboard after insert', @@ -485,10 +491,13 @@ export const en: typeof zhCN = { rightAlt: 'Right Alt', }, fallback: 'Global hotkey', - modeHoldSuffix: ' (push-to-talk)', - modeToggleSuffix: ' (start / stop)', + unset: 'Not set', + modeHoldSuffix: ' (hold)', + modeToggleSuffix: ' (single)', + modeDoubleClickSuffix: ' (double)', usageHold: 'Hold {{trigger}} to talk, release to stop.', usageToggle: 'Press {{trigger}} to start, press again to stop.', + usageDoubleClick: 'Double-click {{trigger}} to start or stop recording.', adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows low-level keyboard hook', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 99380f66..f170657d 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -82,7 +82,7 @@ export const zhCN = { }, hotkeyModePrompt: { title: '检查录音方式', - body: '本版本默认改为“切换式说话”。如果你之前改过快捷键触发方式,请到“录音”里手动确认一次。本次更新同时调整了快捷键方式的读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。', + body: '本版本默认改为“单击”触发。如果你之前改过快捷键触发方式,请到“录音”里手动确认一次。本次更新同时调整了快捷键方式的读取逻辑;如果你更习惯按住说话,可以切回“按住”。', later: '稍后提醒', openSettings: '去录音设置', }, @@ -252,14 +252,20 @@ export const zhCN = { title: '录音', desc: '定义全局录音的快捷键与触发方式。', hotkeyLabel: '录音快捷键', - hotkeyDescAcc: '按下即开始捕获语音,全局生效。需要授予辅助功能权限。', - hotkeyDescNoAcc: '按下即开始捕获语音,全局生效。无需额外辅助功能授权。', - modeLabel: '录音方式', - modeDesc: '切换式 = 按一次开始、再按一次结束;按住说话 = 按住开始、松开结束。', - modeToggle: '切换式', - modeHold: '按住说话', - migrationNoticeTitle: '默认已改为切换式说话', - migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。', + hotkeyDescAcc: '选择单击、按住或双击触发;快捷键可留空。', + hotkeyDescNoAcc: '选择单击、按住或双击触发;快捷键可留空。', + hotkeyRecording: '请按键...', + hotkeyClear: '清除快捷键', + hotkeySetStatus: '已设置:{{hotkey}}', + hotkeyUnsetStatus: '未设置', + modeLabel: '触发方式', + keyLabel: '快捷键', + modeDesc: '单击开始/停止录音;按住为按下开始、松开结束;双击需要连续按两次。', + modeToggle: '单击', + modeHold: '按住', + modeDoubleClick: '双击', + migrationNoticeTitle: '默认已改为单击触发', + migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以切回“按住”。', capsuleLabel: '录音胶囊', capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。', restoreClipboardLabel: '插入后恢复剪贴板', @@ -483,10 +489,13 @@ export const zhCN = { rightAlt: '右 Alt', }, fallback: '全局快捷键', - modeHoldSuffix: '(按住说话)', - modeToggleSuffix: '(开始 / 停止)', + unset: '未设置', + modeHoldSuffix: '(按住)', + modeToggleSuffix: '(单击)', + modeDoubleClickSuffix: '(双击)', usageHold: '按住 {{trigger}} 说话,松开结束。', usageToggle: '按 {{trigger}} 开始录音,再按一次结束。', + usageDoubleClick: '双击 {{trigger}} 开始或结束录音。', adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低层键盘 hook', diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 4819d8b1..e5123a91 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -7,16 +7,117 @@ export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined) } export function getHotkeyStartStopLabel(binding: HotkeyBinding | null | undefined): string { - const trigger = getHotkeyTriggerLabel(binding?.trigger); + const trigger = getHotkeyBindingLabel(binding); const suffix = binding?.mode === 'hold' ? i18n.t('hotkey.modeHoldSuffix') - : i18n.t('hotkey.modeToggleSuffix'); + : binding?.mode === 'doubleClick' + ? i18n.t('hotkey.modeDoubleClickSuffix') + : i18n.t('hotkey.modeToggleSuffix'); return `${trigger}${suffix}`; } export function getHotkeyUsageHint(binding: HotkeyBinding | null | undefined): string { - const trigger = getHotkeyTriggerLabel(binding?.trigger); - return binding?.mode === 'hold' - ? i18n.t('hotkey.usageHold', { trigger }) - : i18n.t('hotkey.usageToggle', { trigger }); + const trigger = getHotkeyBindingLabel(binding); + if (binding?.mode === 'hold') return i18n.t('hotkey.usageHold', { trigger }); + if (binding?.mode === 'doubleClick') return i18n.t('hotkey.usageDoubleClick', { trigger }); + return i18n.t('hotkey.usageToggle', { trigger }); +} + +export function getHotkeyBindingCodes(binding: HotkeyBinding | null | undefined): string[] { + if (!binding) return []; + if (Array.isArray(binding.keys)) { + return binding.keys.map(key => key.code.trim()).filter(Boolean); + } + const legacy = legacyTriggerCode(binding.trigger); + return legacy ? [legacy] : []; +} + +export function getHotkeyBindingLabel(binding: HotkeyBinding | null | undefined): string { + const codes = getHotkeyBindingCodes(binding); + if (codes.length === 0) return i18n.t('hotkey.unset'); + return codes.map(getHotkeyCodeLabel).join('+'); +} + +export function getHotkeyCodeLabel(code: string): string { + const zh = i18n.language.toLowerCase().startsWith('zh'); + const labels: Record = { + ControlLeft: zh ? '左Ctrl' : 'Left Ctrl', + ControlRight: zh ? '右Ctrl' : 'Right Ctrl', + AltLeft: zh ? '左Alt' : 'Left Alt', + AltRight: zh ? '右Alt' : 'Right Alt', + ShiftLeft: zh ? '左Shift' : 'Left Shift', + ShiftRight: zh ? '右Shift' : 'Right Shift', + MetaLeft: zh ? '左Win' : 'Left Win', + MetaRight: zh ? '右Win' : 'Right Win', + OSLeft: zh ? '左Win' : 'Left Win', + OSRight: zh ? '右Win' : 'Right Win', + Fn: 'Fn', + FnLock: 'FnLock', + CapsLock: 'CapsLock', + ScrollLock: 'ScrLock', + Pause: 'Pause', + PrintScreen: 'PrtSc', + Backspace: 'Backspace', + Tab: 'Tab', + Enter: 'Enter', + Space: 'Space', + Insert: 'Insert', + Delete: 'Delete', + Home: 'Home', + End: 'End', + PageUp: 'PageUp', + PageDown: 'PageDown', + ArrowUp: 'Up', + ArrowDown: 'Down', + ArrowLeft: 'Left', + ArrowRight: 'Right', + ContextMenu: 'Menu', + NumpadAdd: 'Num+', + NumpadSubtract: 'Num-', + NumpadMultiply: 'Num*', + NumpadDivide: 'Num/', + NumpadDecimal: 'Num.', + NumpadEnter: 'NumEnter', + Mouse4: 'Mouse4', + Mouse5: 'Mouse5', + Backquote: '`', + Minus: '-', + Equal: '=', + BracketLeft: '[', + BracketRight: ']', + Backslash: '\\', + Semicolon: ';', + Quote: "'", + Comma: ',', + Period: '.', + Slash: '/', + }; + if (labels[code]) return labels[code]; + const letter = code.match(/^Key([A-Z])$/); + if (letter) return letter[1]; + const digit = code.match(/^Digit([0-9])$/); + if (digit) return digit[1]; + const numpad = code.match(/^Numpad([0-9])$/); + if (numpad) return `Num${numpad[1]}`; + return code; +} + +function legacyTriggerCode(trigger: HotkeyTrigger | null | undefined): string | null { + switch (trigger) { + case 'rightOption': + case 'rightAlt': + return 'AltRight'; + case 'leftOption': + return 'AltLeft'; + case 'rightControl': + return 'ControlRight'; + case 'leftControl': + return 'ControlLeft'; + case 'rightCommand': + return 'MetaRight'; + case 'fn': + return 'Fn'; + default: + return null; + } } diff --git a/openless-all/app/src/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index e49824f8..5342eca0 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -38,7 +38,7 @@ export async function invokeOrMock( // ── Mock fixtures ────────────────────────────────────────────────────── const mockSettings: UserPreferences = { - hotkey: { trigger: 'rightControl', mode: 'toggle' }, + hotkey: { trigger: 'rightControl', mode: 'toggle', keys: [{ code: 'ControlRight' }] }, defaultMode: 'structured', enabledModes: ['raw', 'light', 'structured', 'formal'], launchAtLogin: false, @@ -60,7 +60,7 @@ const mockHotkeyCapability: HotkeyCapability = { supportsModifierOnlyTrigger: true, supportsSideSpecificModifiers: true, explicitFallbackAvailable: false, - statusHint: '默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。', + statusHint: '默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。', }; const mockCredentialsStatus: CredentialsStatus = { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 53b62037..da318394 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -38,11 +38,16 @@ export type HotkeyTrigger = | 'fn' | 'rightAlt'; -export type HotkeyMode = 'toggle' | 'hold'; +export type HotkeyMode = 'toggle' | 'hold' | 'doubleClick'; + +export interface HotkeyKey { + code: string; +} export interface HotkeyBinding { trigger: HotkeyTrigger; mode: HotkeyMode; + keys?: HotkeyKey[] | null; } export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'rdev'; diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 511851b1..5a4b8b23 100644 --- a/openless-all/app/src/pages/History.tsx +++ b/openless-all/app/src/pages/History.tsx @@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; import { detectOS } from '../components/WindowChrome'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc'; import type { DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -130,7 +130,7 @@ export function History() { {loading &&
{t('common.loading')}
} {!loading && filtered.length === 0 && (
- {t('history.empty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })} + {t('history.empty', { trigger: getHotkeyBindingLabel(hotkey) })}
)} {filtered.map(s => ( diff --git a/openless-all/app/src/pages/Overview.tsx b/openless-all/app/src/pages/Overview.tsx index 32ac32f0..e63940a1 100644 --- a/openless-all/app/src/pages/Overview.tsx +++ b/openless-all/app/src/pages/Overview.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { Icon } from '../components/Icon'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; import { getCredentials, listHistory } from '../lib/ipc'; import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; @@ -114,7 +114,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
{history.length === 0 && (
- {t('overview.recentEmpty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })} + {t('overview.recentEmpty', { trigger: getHotkeyBindingLabel(hotkey) })}
)} {history.slice(0, 5).map(s => ( diff --git a/openless-all/app/src/pages/QaPanel.tsx b/openless-all/app/src/pages/QaPanel.tsx index 5703ac82..00261951 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next'; import { marked } from 'marked'; import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc'; import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; const SELECTION_PREVIEW_MAX = 60; @@ -123,7 +123,7 @@ export function QaPanel() { // webview,没有 HotkeySettingsContext;如果用户在主窗口改了录音键, // 浮窗里的 "{recordHotkey}" 文案必须立刻跟上,否则会一直停在旧值。 const prefsHandle = await listen('prefs:changed', event => { - setRecordHotkeyLabel(getHotkeyTriggerLabel(event.payload?.hotkey?.trigger)); + setRecordHotkeyLabel(getHotkeyBindingLabel(event.payload?.hotkey)); }); if (cancelled) { stateHandle(); @@ -174,7 +174,7 @@ export function QaPanel() { void getSettings() .then(prefs => { if (cancelled) return; - setRecordHotkeyLabel(getHotkeyTriggerLabel(prefs.hotkey?.trigger)); + setRecordHotkeyLabel(getHotkeyBindingLabel(prefs.hotkey)); }) .catch(err => { console.warn('[QaPanel] load hotkey label failed', err); diff --git a/openless-all/app/src/pages/SelectionAsk.tsx b/openless-all/app/src/pages/SelectionAsk.tsx index 98952160..79871321 100644 --- a/openless-all/app/src/pages/SelectionAsk.tsx +++ b/openless-all/app/src/pages/SelectionAsk.tsx @@ -11,7 +11,7 @@ import { useHotkeySettings } from '../state/HotkeySettingsContext'; import { setQaHotkey } from '../lib/ipc'; import type { QaHotkeyBinding } from '../lib/types'; import { detectOS, type OS } from '../components/WindowChrome'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; const QA_HOTKEY_DISABLED_ID = 'disabled' as const; @@ -83,7 +83,7 @@ export function SelectionAsk() { const os = detectOS(); const qaHotkeyPresets = getQaHotkeyPresets(os); const defaultHotkeyLabel = qaHotkeyPresets[0]?.label ?? '快捷键'; - const recordHotkeyLabel = getHotkeyTriggerLabel(hotkey?.trigger); + const recordHotkeyLabel = getHotkeyBindingLabel(hotkey); if (!prefs) { return ( diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index 7ab68f90..c291c3fd 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -9,7 +9,7 @@ import { Icon } from '../components/Icon'; import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate'; import { APP_VERSION_LABEL } from '../lib/appVersion'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; -import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingCodes, getHotkeyBindingLabel, getHotkeyCodeLabel, getHotkeyStartStopLabel } from '../lib/hotkey'; import { checkAccessibilityPermission, checkMicrophonePermission, @@ -28,6 +28,7 @@ import { } from '../lib/ipc'; import type { HotkeyCapability, + HotkeyBinding, HotkeyMode, HotkeyStatus, HotkeyTrigger, @@ -149,10 +150,17 @@ function RecordingSection() { ); } - const onTriggerChange = (trigger: HotkeyTrigger) => - savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, trigger } }); const onModeChange = (mode: HotkeyMode) => savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } }); + const onHotkeyKeysChange = (codes: string[]) => + savePrefs({ + ...prefs, + hotkey: { + ...prefs.hotkey, + trigger: inferLegacyTrigger(codes, prefs.hotkey.trigger), + keys: codes.map(code => ({ code })), + }, + }); const onShowCapsuleChange = (showCapsule: boolean) => savePrefs({ ...prefs, showCapsule }); const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) => @@ -163,6 +171,7 @@ function RecordingSection() { const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], ['hold', t('settings.recording.modeHold')], + ['doubleClick', t('settings.recording.modeDoubleClick')], ]; const hotkeyDesc = capability.requiresAccessibilityPermission ? t('settings.recording.hotkeyDescAcc') @@ -192,39 +201,49 @@ function RecordingSection() {
)} - - - -
- {choices.map(([v, l]) => ( - - ))} +
+
+ {t('settings.recording.modeLabel')} +
+ {choices.map(([v, l]) => ( + + ))} +
+
+
+ {t('settings.recording.keyLabel')} + +
+
0 ? '#16813d' : 'var(--ol-ink-4)', + }} + > + {getHotkeyBindingCodes(prefs.hotkey).length > 0 + ? t('settings.recording.hotkeySetStatus', { hotkey: getHotkeyBindingLabel(prefs.hotkey) }) + : t('settings.recording.hotkeyUnsetStatus')} +
@@ -259,6 +278,204 @@ function RecordingSection() { // 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop / // windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。 +function HotkeyRecorder({ + binding, + onCommit, +}: { + binding: HotkeyBinding; + onCommit: (codes: string[]) => void; +}) { + const { t } = useTranslation(); + const [recording, setRecording] = useState(false); + const [draftCodes, setDraftCodes] = useState([]); + const pressedRef = useRef>(new Set()); + const recordingRef = useRef(false); + + const resetRecording = () => { + recordingRef.current = false; + pressedRef.current.clear(); + setDraftCodes([]); + setRecording(false); + }; + + const commitCodes = (codes: string[]) => { + const ordered = orderHotkeyCodes(codes); + resetRecording(); + onCommit(ordered); + }; + + const startRecording = () => { + recordingRef.current = true; + pressedRef.current.clear(); + setDraftCodes([]); + setRecording(true); + }; + + useEffect(() => { + if (!recording) return undefined; + + const stopEvent = (event: Event) => { + event.preventDefault(); + event.stopPropagation(); + }; + + const onKeyDown = (event: KeyboardEvent) => { + stopEvent(event); + if (event.key === 'Escape' || event.code === 'Escape') { + resetRecording(); + return; + } + const code = normalizeKeyboardHotkeyCode(event); + if (!code) return; + pressedRef.current.add(code); + setDraftCodes(orderHotkeyCodes([...pressedRef.current])); + }; + + const onKeyUp = (event: KeyboardEvent) => { + stopEvent(event); + if (!recordingRef.current) return; + if (event.key === 'Escape' || event.code === 'Escape') { + resetRecording(); + return; + } + const codes = orderHotkeyCodes([...pressedRef.current]); + if (codes.length > 0) commitCodes(codes); + }; + + const onMouseDown = (event: MouseEvent) => { + const code = mouseButtonToHotkeyCode(event.button); + if (!code) return; + stopEvent(event); + pressedRef.current.add(code); + commitCodes([...pressedRef.current]); + }; + + window.addEventListener('keydown', onKeyDown, true); + window.addEventListener('keyup', onKeyUp, true); + window.addEventListener('mousedown', onMouseDown, true); + return () => { + window.removeEventListener('keydown', onKeyDown, true); + window.removeEventListener('keyup', onKeyUp, true); + window.removeEventListener('mousedown', onMouseDown, true); + }; + }, [recording]); + + const label = recording + ? draftCodes.length > 0 + ? draftCodes.map(getHotkeyCodeLabel).join('+') + : t('settings.recording.hotkeyRecording') + : getHotkeyBindingLabel(binding); + const hasKeys = getHotkeyBindingCodes(binding).length > 0; + + return ( +
+ +
+ ); +} + +function inferLegacyTrigger(codes: string[], fallback: HotkeyTrigger): HotkeyTrigger { + if (codes.includes('ControlRight')) return 'rightControl'; + if (codes.includes('ControlLeft')) return 'leftControl'; + if (codes.includes('AltRight')) return 'rightAlt'; + if (codes.includes('AltLeft')) return 'leftOption'; + if (codes.includes('MetaRight')) return 'rightCommand'; + if (codes.includes('Fn')) return 'fn'; + return fallback; +} + +function normalizeKeyboardHotkeyCode(event: KeyboardEvent): string | null { + if (event.key === 'Fn' || event.code === 'Fn') return 'Fn'; + if (event.key === 'FnLock' || event.code === 'FnLock') return 'FnLock'; + const code = event.code === 'OSLeft' ? 'MetaLeft' : event.code === 'OSRight' ? 'MetaRight' : event.code; + if (SUPPORTED_HOTKEY_CODES.has(code)) return code; + if (/^Key[A-Z]$/.test(code)) return code; + if (/^Digit[0-9]$/.test(code)) return code; + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return code; + if (/^Numpad[0-9]$/.test(code)) return code; + return null; +} + +function mouseButtonToHotkeyCode(button: number): string | null { + if (button === 3) return 'Mouse4'; + if (button === 4) return 'Mouse5'; + return null; +} + +function orderHotkeyCodes(codes: string[]): string[] { + const seen = new Set(); + return codes + .filter(code => { + if (!code || seen.has(code)) return false; + seen.add(code); + return true; + }) + .sort((a, b) => hotkeyCodeRank(a) - hotkeyCodeRank(b)); +} + +function hotkeyCodeRank(code: string): number { + const index = HOTKEY_CODE_ORDER.indexOf(code); + if (index >= 0) return index; + if (/^Key[A-Z]$/.test(code)) return 100 + code.charCodeAt(3); + if (/^Digit[0-9]$/.test(code)) return 200 + Number(code.slice(5)); + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return 300 + Number(code.slice(1)); + if (/^Numpad[0-9]$/.test(code)) return 400 + Number(code.slice(6)); + return 1000; +} + +const SUPPORTED_HOTKEY_CODES = new Set([ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', + 'MetaLeft', 'MetaRight', 'CapsLock', 'ScrollLock', 'Pause', 'PrintScreen', + 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', 'End', + 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'ContextMenu', 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', + 'NumpadDecimal', 'NumpadEnter', 'Backquote', 'Minus', 'Equal', 'BracketLeft', + 'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', + 'Fn', 'FnLock', +]); + +const HOTKEY_CODE_ORDER = [ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', + 'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause', + 'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', + 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', + 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd', + 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', + 'Mouse4', 'Mouse5', +]; + function AutostartRow() { const { t } = useTranslation(); const [enabled, setEnabled] = useState(false); @@ -842,6 +1059,79 @@ const miniBtnStyle: CSSProperties = { transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', }; +const recordingHotkeyControlWidth = 178; + +const hotkeyRecorderButtonStyle: CSSProperties = { + width: recordingHotkeyControlWidth, + height: 32, + padding: '0 8px 0 11px', + border: '0.5px solid var(--ol-line-strong)', + borderRadius: 8, + background: 'var(--ol-surface-2)', + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'space-between', + gap: 8, + fontFamily: 'var(--ol-font-mono)', + fontSize: 12.5, + cursor: 'default', + transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)', +}; + +const recordingHotkeySegmentedStyle: CSSProperties = { + width: recordingHotkeyControlWidth, + display: 'inline-flex', + padding: 2, + borderRadius: 8, + background: 'rgba(0,0,0,0.05)', +}; + +const recordingHotkeyGroupStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: 'auto', + rowGap: 10, + justifyItems: 'start', +}; + +const recordingHotkeyLineStyle: CSSProperties = { + display: 'grid', + gridTemplateColumns: '64px auto', + alignItems: 'center', + columnGap: 10, +}; + +const recordingHotkeyFieldLabelStyle: CSSProperties = { + fontSize: 12, + color: 'var(--ol-ink-4)', + textAlign: 'right', + whiteSpace: 'nowrap', +}; + +const recordingHotkeyStatusStyle: CSSProperties = { + marginLeft: 74, + fontSize: 12, + lineHeight: 1.3, +}; + +const hotkeyRecorderLabelStyle: CSSProperties = { + minWidth: 0, + overflow: 'hidden', + textOverflow: 'ellipsis', + whiteSpace: 'nowrap', +}; + +const hotkeyClearButtonStyle: CSSProperties = { + width: 18, + height: 18, + borderRadius: 999, + display: 'inline-flex', + alignItems: 'center', + justifyContent: 'center', + flexShrink: 0, + background: 'rgba(0,0,0,0.2)', + color: '#fff', +}; + const iconBtnStyle: CSSProperties = { width: 32, height: 32, border: '0.5px solid var(--ol-line-strong)', diff --git a/openless-all/app/src/pages/Translation.tsx b/openless-all/app/src/pages/Translation.tsx index 74d9759c..70c1293a 100644 --- a/openless-all/app/src/pages/Translation.tsx +++ b/openless-all/app/src/pages/Translation.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import { Card, PageHeader } from './_atoms'; import { SUPPORTED_LANGUAGES } from '../lib/types'; import { useHotkeySettings } from '../state/HotkeySettingsContext'; -import { getHotkeyTriggerLabel } from '../lib/hotkey'; +import { getHotkeyBindingLabel } from '../lib/hotkey'; export function Translation() { const { t } = useTranslation(); @@ -40,7 +40,7 @@ export function Translation() { const onTargetChange = (translationTargetLanguage: string) => savePrefs({ ...prefs, translationTargetLanguage }); - const triggerLabel = getHotkeyTriggerLabel(hotkey?.trigger); + const triggerLabel = getHotkeyBindingLabel(hotkey); const enabled = prefs.translationTargetLanguage.trim() !== ''; return ( From e7625c5eb51ff18db42923101c242584ea4df4fb Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 19:03:23 +0800 Subject: [PATCH 2/9] fix: address hotkey review feedback --- openless-all/app/src-tauri/src/coordinator.rs | 173 ++++++++++++++-- openless-all/app/src-tauri/src/hotkey.rs | 193 ++++++++++++------ 2 files changed, 289 insertions(+), 77 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 0b860916..c95ed14c 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -5,6 +5,7 @@ //! insertion, persists history, emits `capsule:state` events to the capsule //! window. +use std::collections::BTreeSet; use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc; use std::sync::Arc; @@ -19,7 +20,7 @@ use crate::asr::{ DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR, WhisperBatchASR, }; -use crate::hotkey::{HotkeyEvent, HotkeyMonitor}; +use crate::hotkey::{binding_matches_pressed_codes, HotkeyEvent, HotkeyMonitor}; use crate::insertion::TextInserter; use crate::persistence::{ CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore, @@ -130,6 +131,7 @@ struct Inner { hotkey_status: Mutex, hotkey_trigger_held: AtomicBool, hotkey_last_click_at: Mutex>, + window_hotkey_pressed_codes: Mutex>, /// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在 /// Listening / Starting 阶段看到 Shift down 边沿时 set true。 /// end_session 在调 polish/translate 前读这个 flag + translation_target_language @@ -226,6 +228,7 @@ impl Coordinator { hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), hotkey_last_click_at: Mutex::new(None), + window_hotkey_pressed_codes: Mutex::new(BTreeSet::new()), translation_modifier_seen: AtomicBool::new(false), qa_hotkey: Mutex::new(None), qa_state: Mutex::new(QaSessionState::default()), @@ -374,6 +377,7 @@ impl Coordinator { } pub fn update_hotkey_binding(&self) { + self.inner.window_hotkey_pressed_codes.lock().clear(); if let Some(monitor) = self.inner.hotkey.lock().as_ref() { monitor.update_binding(self.inner.prefs.get().hotkey); } @@ -700,11 +704,14 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { } async fn handle_pressed_edge(inner: &Arc) { - let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); + let was_held = inner.hotkey_trigger_held.load(Ordering::SeqCst); if !was_held { if !should_accept_pressed_edge(inner) { return; } + if inner.hotkey_trigger_held.swap(true, Ordering::SeqCst) { + return; + } // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 let panel_visible = inner.qa_state.lock().panel_visible; if panel_visible { @@ -908,22 +915,41 @@ async fn handle_window_hotkey_event( return Ok(()); } - let binding = inner.prefs.get().hotkey; - if !window_key_matches_binding(&binding, &key, &code) { - return Ok(()); - } - match event_type.as_str() { "keydown" => { if repeat { return Ok(()); } - log::info!("[window-hotkey] pressed code={code} repeat={repeat}"); - handle_pressed_edge(inner).await; + let binding = inner.prefs.get().hotkey; + let Some((was_active, is_active)) = update_window_hotkey_pressed_codes( + &mut inner.window_hotkey_pressed_codes.lock(), + &binding, + &key, + &code, + true, + ) else { + return Ok(()); + }; + if is_active && !was_active { + log::info!("[window-hotkey] pressed code={code} repeat={repeat}"); + handle_pressed_edge(inner).await; + } } "keyup" => { - log::info!("[window-hotkey] released code={code}"); - handle_released_edge(inner).await; + let binding = inner.prefs.get().hotkey; + let Some((was_active, is_active)) = update_window_hotkey_pressed_codes( + &mut inner.window_hotkey_pressed_codes.lock(), + &binding, + &key, + &code, + false, + ) else { + return Ok(()); + }; + if was_active && !is_active { + log::info!("[window-hotkey] released code={code}"); + handle_released_edge(inner).await; + } } _ => {} } @@ -935,6 +961,31 @@ fn window_hotkey_fallback_enabled() -> bool { crate::types::HotkeyCapability::current().explicit_fallback_available } +#[cfg(any(target_os = "windows", test))] +fn update_window_hotkey_pressed_codes( + pressed_codes: &mut BTreeSet, + binding: &HotkeyBinding, + key: &str, + code: &str, + pressed: bool, +) -> Option<(bool, bool)> { + if !window_key_matches_binding(binding, key, code) { + return None; + } + + let normalized = normalize_window_hotkey_code(key, code); + let was_active = binding_matches_pressed_codes(binding, pressed_codes); + if pressed { + pressed_codes.insert(normalized); + } else { + pressed_codes.remove(&normalized); + } + Some(( + was_active, + binding_matches_pressed_codes(binding, pressed_codes), + )) +} + #[cfg(any(target_os = "windows", test))] fn window_key_matches_binding(binding: &HotkeyBinding, key: &str, code: &str) -> bool { let normalized = normalize_window_hotkey_code(key, code); @@ -2607,16 +2658,85 @@ mod tests { trigger: HotkeyTrigger::RightControl, ..Default::default() }; - assert!(window_key_matches_binding(&legacy, "Control", "ControlRight")); - assert!(!window_key_matches_binding(&legacy, "Control", "ControlLeft")); + assert!(window_key_matches_binding( + &legacy, + "Control", + "ControlRight" + )); + assert!(!window_key_matches_binding( + &legacy, + "Control", + "ControlLeft" + )); let caps_lock = HotkeyBinding { trigger: HotkeyTrigger::RightControl, keys: Some(vec![HotkeyKey::new("CapsLock")]), ..Default::default() }; - assert!(window_key_matches_binding(&caps_lock, "CapsLock", "CapsLock")); - assert!(!window_key_matches_binding(&caps_lock, "Control", "ControlRight")); + assert!(window_key_matches_binding( + &caps_lock, "CapsLock", "CapsLock" + )); + assert!(!window_key_matches_binding( + &caps_lock, + "Control", + "ControlRight" + )); + } + + #[test] + fn window_hotkey_fallback_requires_full_combo_before_activating() { + let binding = HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + keys: Some(vec![ + HotkeyKey::new("ControlLeft"), + HotkeyKey::new("AltLeft"), + HotkeyKey::new("Mouse4"), + ]), + ..Default::default() + }; + let mut pressed_codes = std::collections::BTreeSet::new(); + + assert_eq!( + update_window_hotkey_pressed_codes( + &mut pressed_codes, + &binding, + "Control", + "ControlLeft", + true, + ), + Some((false, false)) + ); + assert_eq!( + update_window_hotkey_pressed_codes( + &mut pressed_codes, + &binding, + "Alt", + "AltLeft", + true + ), + Some((false, false)) + ); + assert_eq!( + update_window_hotkey_pressed_codes( + &mut pressed_codes, + &binding, + "Mouse4", + "Mouse4", + true, + ), + Some((false, true)) + ); + assert_eq!( + update_window_hotkey_pressed_codes( + &mut pressed_codes, + &binding, + "Alt", + "AltLeft", + false + ), + Some((true, false)) + ); } #[test] @@ -2732,6 +2852,29 @@ mod tests { assert_eq!(state.session_id, 41); } + #[tokio::test] + async fn rejected_double_click_press_does_not_mark_trigger_held() { + let coordinator = Coordinator::new(); + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + ..Default::default() + }, + ..Default::default() + }) + .unwrap(); + + handle_pressed_edge(&coordinator.inner).await; + + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + handle_released_edge(&coordinator.inner).await; + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + } + #[test] fn double_click_mode_requires_second_press_within_window() { let coordinator = Coordinator::new(); diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index c7ff68bc..f6315f1a 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -11,7 +11,7 @@ //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 use std::collections::BTreeSet; -use std::sync::atomic::AtomicBool; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Sender}; use std::sync::Arc; use std::time::Duration; @@ -166,7 +166,48 @@ fn dispatch_hotkey_code(shared: &Shared, tx: &Sender, code: &str, p } } -fn binding_matches_pressed_codes(binding: &HotkeyBinding, pressed_codes: &BTreeSet) -> bool { +fn dispatch_translation_modifier_code( + shared: &Shared, + tx: &Sender, + code: &str, + pressed: bool, +) { + if !is_shift_hotkey_code(code) { + return; + } + + let shift_is_hotkey = shared + .binding + .read() + .effective_codes() + .iter() + .any(|candidate| candidate == code); + if shift_is_hotkey { + return; + } + + if pressed { + let was_held = shared + .translation_modifier_held + .swap(true, Ordering::SeqCst); + if !was_held { + send_or_log(tx, HotkeyEvent::TranslationModifierPressed); + } + } else { + shared + .translation_modifier_held + .store(false, Ordering::SeqCst); + } +} + +fn is_shift_hotkey_code(code: &str) -> bool { + matches!(code, "ShiftLeft" | "ShiftRight") +} + +pub(crate) fn binding_matches_pressed_codes( + binding: &HotkeyBinding, + pressed_codes: &BTreeSet, +) -> bool { let codes = binding.effective_codes(); !codes.is_empty() && codes @@ -179,13 +220,13 @@ fn binding_matches_pressed_codes(binding: &HotkeyBinding, pressed_codes: &BTreeS #[cfg(target_os = "macos")] mod platform { use std::ffi::c_void; - use std::sync::atomic::Ordering; use std::sync::mpsc::Sender; use std::sync::Arc; use super::{ - dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, - update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, + is_shift_hotkey_code, send_or_log, start_listener_thread, update_shared_binding, + HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -392,23 +433,17 @@ mod platform { fn handle_flags_changed(ctx: &CallbackContext, event: CgEventRef) { let flags = unsafe { CGEventGetFlags(event) }; - - // Shift 是翻译模式修饰键 — 与触发键的 keycode 检查独立,任何时刻按 Shift 都生效。 - let shift_active = (flags & FLAG_MASK_SHIFT) != 0; - let shift_was_held = ctx.shared.translation_modifier_held.load(Ordering::SeqCst); - if shift_active && !shift_was_held { - ctx.shared - .translation_modifier_held - .store(true, Ordering::SeqCst); - send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); - } else if !shift_active && shift_was_held { - ctx.shared - .translation_modifier_held - .store(false, Ordering::SeqCst); - } - let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if let Some(code) = mac_keycode_to_hotkey_code(keycode) { + if is_shift_hotkey_code(code) { + // Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。 + dispatch_translation_modifier_code( + &ctx.shared, + &ctx.tx, + code, + (flags & FLAG_MASK_SHIFT) != 0, + ); + } if let Some(mask) = mac_keycode_flag_mask(keycode) { let family_active = (flags & mask) != 0; let code_was_pressed = ctx.shared.pressed_codes.read().contains(code); @@ -578,11 +613,67 @@ mod platform { } } +#[cfg(test)] +mod tests { + use super::*; + use crate::types::{HotkeyKey, HotkeyMode, HotkeyTrigger}; + + fn shared_with_binding(binding: HotkeyBinding) -> Shared { + Shared { + binding: RwLock::new(binding), + pressed_codes: RwLock::new(BTreeSet::new()), + trigger_held: AtomicBool::new(false), + translation_modifier_held: AtomicBool::new(false), + } + } + + #[test] + fn shift_hotkey_press_does_not_emit_translation_modifier() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("ShiftLeft")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true); + assert!(rx.try_recv().is_err()); + + dispatch_hotkey_code(&shared, &tx, "ShiftLeft", true); + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); + } + + #[test] + fn unbound_shift_press_still_emits_translation_modifier_once_per_hold() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("ControlRight")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true); + assert!(matches!( + rx.try_recv(), + Ok(HotkeyEvent::TranslationModifierPressed) + )); + + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true); + assert!(rx.try_recv().is_err()); + + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", false); + dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true); + assert!(matches!( + rx.try_recv(), + Ok(HotkeyEvent::TranslationModifierPressed) + )); + } +} + // ─────────────────────────── Windows implementation ─────────────────────────── #[cfg(target_os = "windows")] mod platform { - use std::sync::atomic::Ordering; use std::sync::atomic::{AtomicPtr, Ordering as AtomicOrdering}; use std::sync::mpsc::Sender; use std::sync::Arc; @@ -591,13 +682,14 @@ mod platform { use windows::Win32::System::Threading::GetCurrentThreadId; use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, DispatchMessageW, GetMessageW, PostThreadMessageW, SetWindowsHookExW, - TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, - MSLLHOOKSTRUCT, MSG, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_QUIT, + TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, + MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_QUIT, }; use super::{ - dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, - update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, send_or_log, + start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, + StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -830,28 +922,11 @@ mod platform { return; } - // Shift(任一侧)= 翻译模式修饰键。在录音过程中任意时刻按下都生效。详见 issue #4。 - if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) { - match message { - WM_KEYDOWN | WM_SYSKEYDOWN => { - let was_held = ctx - .shared - .translation_modifier_held - .swap(true, Ordering::SeqCst); - if !was_held { - send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed); - } - } - WM_KEYUP | WM_SYSKEYUP => { - ctx.shared - .translation_modifier_held - .store(false, Ordering::SeqCst); - } - _ => {} - } - } - if let Some(code) = vk_to_hotkey_code(vk_code) { + if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) { + // Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。 + dispatch_translation_modifier_code(&ctx.shared, &ctx.tx, code, is_down); + } if is_down { dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, true); } else if is_up { @@ -1008,8 +1083,9 @@ mod platform { use rdev::{listen, Button, Event, EventType, Key}; use super::{ - dispatch_hotkey_code, install_error, send_or_log, start_listener_thread, - update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, send_or_log, + start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, + StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -1082,26 +1158,19 @@ mod platform { send_or_log(tx, HotkeyEvent::Cancelled); return; } - // Shift(任一侧)= 翻译模式修饰键。详见 issue #4。 - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - let was_held = shared - .translation_modifier_held - .swap(true, Ordering::SeqCst); - if !was_held { - send_or_log(tx, HotkeyEvent::TranslationModifierPressed); - } - } if let Some(code) = rdev_key_to_hotkey_code(key) { + if matches!(key, Key::ShiftLeft | Key::ShiftRight) { + // Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。 + dispatch_translation_modifier_code(shared, tx, code, true); + } dispatch_hotkey_code(shared, tx, code, true); } } EventType::KeyRelease(key) => { - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - shared - .translation_modifier_held - .store(false, Ordering::SeqCst); - } if let Some(code) = rdev_key_to_hotkey_code(key) { + if matches!(key, Key::ShiftLeft | Key::ShiftRight) { + dispatch_translation_modifier_code(shared, tx, code, false); + } dispatch_hotkey_code(shared, tx, code, false); } } From b8607b21569383f7548cb95f3d4a79a687d5bea6 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 19:05:48 +0800 Subject: [PATCH 3/9] refactor: clarify window hotkey fallback matching --- openless-all/app/src-tauri/src/coordinator.rs | 35 ++++++------------- 1 file changed, 10 insertions(+), 25 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index c95ed14c..6750fa88 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -969,11 +969,11 @@ fn update_window_hotkey_pressed_codes( code: &str, pressed: bool, ) -> Option<(bool, bool)> { - if !window_key_matches_binding(binding, key, code) { + let normalized = normalize_window_hotkey_code(key, code); + if !window_code_belongs_to_binding(binding, &normalized) { return None; } - let normalized = normalize_window_hotkey_code(key, code); let was_active = binding_matches_pressed_codes(binding, pressed_codes); if pressed { pressed_codes.insert(normalized); @@ -987,13 +987,12 @@ fn update_window_hotkey_pressed_codes( } #[cfg(any(target_os = "windows", test))] -fn window_key_matches_binding(binding: &HotkeyBinding, key: &str, code: &str) -> bool { - let normalized = normalize_window_hotkey_code(key, code); - !normalized.is_empty() +fn window_code_belongs_to_binding(binding: &HotkeyBinding, code: &str) -> bool { + !code.is_empty() && binding .effective_codes() .iter() - .any(|candidate| candidate == &normalized) + .any(|candidate| candidate == code) } #[cfg(any(target_os = "windows", test))] @@ -2653,35 +2652,21 @@ mod tests { } #[test] - fn window_key_matcher_accepts_legacy_and_configured_codes() { + fn window_code_filter_accepts_legacy_and_configured_codes() { let legacy = HotkeyBinding { trigger: HotkeyTrigger::RightControl, ..Default::default() }; - assert!(window_key_matches_binding( - &legacy, - "Control", - "ControlRight" - )); - assert!(!window_key_matches_binding( - &legacy, - "Control", - "ControlLeft" - )); + assert!(window_code_belongs_to_binding(&legacy, "ControlRight")); + assert!(!window_code_belongs_to_binding(&legacy, "ControlLeft")); let caps_lock = HotkeyBinding { trigger: HotkeyTrigger::RightControl, keys: Some(vec![HotkeyKey::new("CapsLock")]), ..Default::default() }; - assert!(window_key_matches_binding( - &caps_lock, "CapsLock", "CapsLock" - )); - assert!(!window_key_matches_binding( - &caps_lock, - "Control", - "ControlRight" - )); + assert!(window_code_belongs_to_binding(&caps_lock, "CapsLock")); + assert!(!window_code_belongs_to_binding(&caps_lock, "ControlRight")); } #[test] From 95a86fddd55b36e2a01881215196ef12cedbe76f Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 19:23:45 +0800 Subject: [PATCH 4/9] fix: preserve QA hotkey edge semantics --- openless-all/app/src-tauri/src/coordinator.rs | 45 ++++++++++++++++++- 1 file changed, 43 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 6750fa88..abc1a7f2 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -378,6 +378,7 @@ impl Coordinator { pub fn update_hotkey_binding(&self) { self.inner.window_hotkey_pressed_codes.lock().clear(); + *self.inner.hotkey_last_click_at.lock() = None; if let Some(monitor) = self.inner.hotkey.lock().as_ref() { monitor.update_binding(self.inner.prefs.get().hotkey); } @@ -706,14 +707,17 @@ fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver) { async fn handle_pressed_edge(inner: &Arc) { let was_held = inner.hotkey_trigger_held.load(Ordering::SeqCst); if !was_held { - if !should_accept_pressed_edge(inner) { + let panel_visible = inner.qa_state.lock().panel_visible; + if !panel_visible && !should_accept_pressed_edge(inner) { return; } + if panel_visible { + *inner.hotkey_last_click_at.lock() = None; + } if inner.hotkey_trigger_held.swap(true, Ordering::SeqCst) { return; } // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 - let panel_visible = inner.qa_state.lock().panel_visible; if panel_visible { handle_qa_option_edge(inner).await; } else { @@ -2860,6 +2864,43 @@ mod tests { assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); } + #[tokio::test] + async fn qa_panel_press_bypasses_double_click_gate() { + let coordinator = Coordinator::new(); + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + ..Default::default() + }, + ..Default::default() + }) + .unwrap(); + { + let mut qa_state = coordinator.inner.qa_state.lock(); + qa_state.panel_visible = true; + qa_state.phase = QaPhase::Processing; + } + + handle_pressed_edge(&coordinator.inner).await; + + assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + assert!(coordinator.inner.hotkey_last_click_at.lock().is_none()); + } + + #[test] + fn hotkey_binding_update_resets_double_click_state() { + let coordinator = Coordinator::new(); + *coordinator.inner.hotkey_last_click_at.lock() = Some(Instant::now()); + + coordinator.update_hotkey_binding(); + + assert!(coordinator.inner.hotkey_last_click_at.lock().is_none()); + } + #[test] fn double_click_mode_requires_second_press_within_window() { let coordinator = Coordinator::new(); From 983b4126d7ac662868bc45b89063a37bb8261e03 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 20:00:36 +0800 Subject: [PATCH 5/9] fix: stabilize hotkey recorder combos --- .../app/scripts/check-hotkey-recorder.mjs | 22 +++++ .../app/src/lib/hotkeyRecorder.test.ts | 85 +++++++++++++++++++ openless-all/app/src/lib/hotkeyRecorder.ts | 70 +++++++++++++++ openless-all/app/src/pages/Settings.tsx | 67 ++++++--------- 4 files changed, 203 insertions(+), 41 deletions(-) create mode 100644 openless-all/app/scripts/check-hotkey-recorder.mjs create mode 100644 openless-all/app/src/lib/hotkeyRecorder.test.ts create mode 100644 openless-all/app/src/lib/hotkeyRecorder.ts diff --git a/openless-all/app/scripts/check-hotkey-recorder.mjs b/openless-all/app/scripts/check-hotkey-recorder.mjs new file mode 100644 index 00000000..f14aff3a --- /dev/null +++ b/openless-all/app/scripts/check-hotkey-recorder.mjs @@ -0,0 +1,22 @@ +import * as esbuild from 'esbuild'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const tmp = await mkdtemp(join(tmpdir(), 'openless-hotkey-recorder-')); +const outfile = join(tmp, 'hotkey-recorder-test.mjs'); + +try { + await esbuild.build({ + entryPoints: [fileURLToPath(new URL('../src/lib/hotkeyRecorder.test.ts', import.meta.url))], + outfile, + bundle: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + }); + await import(pathToFileURL(outfile).href); +} finally { + await rm(tmp, { recursive: true, force: true }); +} diff --git a/openless-all/app/src/lib/hotkeyRecorder.test.ts b/openless-all/app/src/lib/hotkeyRecorder.test.ts new file mode 100644 index 00000000..a1a5841a --- /dev/null +++ b/openless-all/app/src/lib/hotkeyRecorder.test.ts @@ -0,0 +1,85 @@ +import { + createHotkeyRecorderState, + orderHotkeyCodes, + updateHotkeyRecorderState, +} from './hotkeyRecorder'; + +function assertEqual(actual: T, expected: T, name: string) { + if (actual !== expected) { + throw new Error(`${name}: expected ${expected}, got ${actual}`); + } +} + +function assertDeepEqual(actual: unknown, expected: unknown, name: string) { + const actualJson = JSON.stringify(actual); + const expectedJson = JSON.stringify(expected); + if (actualJson !== expectedJson) { + throw new Error(`${name}: expected ${expectedJson}, got ${actualJson}`); + } +} + +function apply( + state = createHotkeyRecorderState(), + code: string, + pressed: boolean, +) { + const next = updateHotkeyRecorderState(state, code, pressed); + return next; +} + +{ + let result = apply(undefined, 'ControlLeft', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft'], 'tracks first pressed key'); + assertEqual(result.commitCodes, null, 'does not commit until release'); + + result = apply(result.state, 'ControlLeft', false); + assertDeepEqual(result.commitCodes, ['ControlLeft'], 'commits single key on release'); + + result = apply(createHotkeyRecorderState(), 'KeyK', true); + assertDeepEqual(result.state.draftCodes, ['KeyK'], 'starts a new recording state cleanly'); + assertEqual(result.commitCodes, null, 'new keydown does not include old released keys'); +} + +{ + let result = apply(undefined, 'ControlLeft', true); + result = apply(result.state, 'KeyK', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'KeyK'], 'records keyboard combo draft'); + + result = apply(result.state, 'ControlLeft', false); + assertEqual(result.commitCodes, null, 'keyboard combo waits for final release'); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'KeyK'], 'released combo member stays in draft only'); + + result = apply(result.state, 'KeyK', false); + assertDeepEqual(result.commitCodes, ['ControlLeft', 'KeyK'], 'keyboard combo commits after all keys release'); + assertDeepEqual(result.state, createHotkeyRecorderState(), 'state resets after commit'); +} + +{ + let result = apply(undefined, 'Mouse4', true); + assertDeepEqual(result.state.draftCodes, ['Mouse4'], 'tracks mouse button as draft'); + assertEqual(result.commitCodes, null, 'mouse button does not commit on mousedown'); + + result = apply(result.state, 'ControlLeft', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse4'], 'records keyboard plus mouse combo'); + assertEqual(result.commitCodes, null, 'combo does not commit while inputs remain pressed'); + + result = apply(result.state, 'Mouse4', false); + assertEqual(result.commitCodes, null, 'releasing one combo member does not commit early'); + + result = apply(result.state, 'ControlLeft', false); + assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse4'], 'commits combo after final release'); +} + +{ + let result = apply(undefined, 'ControlLeft', true); + result = apply(result.state, 'Mouse5', true); + assertDeepEqual(result.state.draftCodes, ['ControlLeft', 'Mouse5'], 'records mouse button pressed after keyboard'); + + result = apply(result.state, 'ControlLeft', false); + assertEqual(result.commitCodes, null, 'mouse combo keeps waiting while mouse remains pressed'); + + result = apply(result.state, 'Mouse5', false); + assertDeepEqual(result.commitCodes, ['ControlLeft', 'Mouse5'], 'commits mouse-last combo after mouse release'); +} + +assertDeepEqual(orderHotkeyCodes(['Mouse4', 'ControlLeft']), ['ControlLeft', 'Mouse4'], 'orders mouse after modifiers'); diff --git a/openless-all/app/src/lib/hotkeyRecorder.ts b/openless-all/app/src/lib/hotkeyRecorder.ts new file mode 100644 index 00000000..8ab692f5 --- /dev/null +++ b/openless-all/app/src/lib/hotkeyRecorder.ts @@ -0,0 +1,70 @@ +export interface HotkeyRecorderState { + pressedCodes: string[]; + draftCodes: string[]; +} + +export interface HotkeyRecorderUpdate { + state: HotkeyRecorderState; + commitCodes: string[] | null; +} + +export function createHotkeyRecorderState(): HotkeyRecorderState { + return { + pressedCodes: [], + draftCodes: [], + }; +} + +export function updateHotkeyRecorderState( + state: HotkeyRecorderState, + code: string, + pressed: boolean, +): HotkeyRecorderUpdate { + const active = new Set(state.pressedCodes); + if (pressed) { + active.add(code); + } else { + active.delete(code); + } + + const pressedCodes = orderHotkeyCodes([...active]); + const draftCodes = pressed ? pressedCodes : state.draftCodes; + const shouldCommit = !pressed && pressedCodes.length === 0 && draftCodes.length > 0; + + return { + state: shouldCommit ? createHotkeyRecorderState() : { pressedCodes, draftCodes }, + commitCodes: shouldCommit ? draftCodes : null, + }; +} + +export function orderHotkeyCodes(codes: string[]): string[] { + const seen = new Set(); + return codes + .filter(code => { + if (!code || seen.has(code)) return false; + seen.add(code); + return true; + }) + .sort((a, b) => hotkeyCodeRank(a) - hotkeyCodeRank(b)); +} + +function hotkeyCodeRank(code: string): number { + const index = HOTKEY_CODE_ORDER.indexOf(code); + if (index >= 0) return index; + if (/^Key[A-Z]$/.test(code)) return 100 + code.charCodeAt(3); + if (/^Digit[0-9]$/.test(code)) return 200 + Number(code.slice(5)); + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return 300 + Number(code.slice(1)); + if (/^Numpad[0-9]$/.test(code)) return 400 + Number(code.slice(6)); + return 1000; +} + +const HOTKEY_CODE_ORDER = [ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', + 'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause', + 'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', + 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', + 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd', + 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', + 'Mouse4', 'Mouse5', +]; diff --git a/openless-all/app/src/pages/Settings.tsx b/openless-all/app/src/pages/Settings.tsx index a925ccee..50609408 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -9,6 +9,7 @@ import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoU import { APP_VERSION_LABEL } from '../lib/appVersion'; import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration'; import { getHotkeyBindingCodes, getHotkeyBindingLabel, getHotkeyCodeLabel, getHotkeyStartStopLabel } from '../lib/hotkey'; +import { createHotkeyRecorderState, orderHotkeyCodes, updateHotkeyRecorderState } from '../lib/hotkeyRecorder'; import { checkAccessibilityPermission, checkMicrophonePermission, @@ -303,12 +304,12 @@ function HotkeyRecorder({ const { t } = useTranslation(); const [recording, setRecording] = useState(false); const [draftCodes, setDraftCodes] = useState([]); - const pressedRef = useRef>(new Set()); + const recorderStateRef = useRef(createHotkeyRecorderState()); const recordingRef = useRef(false); const resetRecording = () => { recordingRef.current = false; - pressedRef.current.clear(); + recorderStateRef.current = createHotkeyRecorderState(); setDraftCodes([]); setRecording(false); }; @@ -321,7 +322,7 @@ function HotkeyRecorder({ const startRecording = () => { recordingRef.current = true; - pressedRef.current.clear(); + recorderStateRef.current = createHotkeyRecorderState(); setDraftCodes([]); setRecording(true); }; @@ -334,6 +335,14 @@ function HotkeyRecorder({ event.stopPropagation(); }; + const applyHotkeyCode = (code: string, pressed: boolean) => { + if (!recordingRef.current) return; + const next = updateHotkeyRecorderState(recorderStateRef.current, code, pressed); + recorderStateRef.current = next.state; + setDraftCodes(next.state.draftCodes); + if (next.commitCodes) commitCodes(next.commitCodes); + }; + const onKeyDown = (event: KeyboardEvent) => { stopEvent(event); if (event.key === 'Escape' || event.code === 'Escape') { @@ -342,8 +351,7 @@ function HotkeyRecorder({ } const code = normalizeKeyboardHotkeyCode(event); if (!code) return; - pressedRef.current.add(code); - setDraftCodes(orderHotkeyCodes([...pressedRef.current])); + applyHotkeyCode(code, true); }; const onKeyUp = (event: KeyboardEvent) => { @@ -353,25 +361,34 @@ function HotkeyRecorder({ resetRecording(); return; } - const codes = orderHotkeyCodes([...pressedRef.current]); - if (codes.length > 0) commitCodes(codes); + const code = normalizeKeyboardHotkeyCode(event); + if (!code) return; + applyHotkeyCode(code, false); }; const onMouseDown = (event: MouseEvent) => { const code = mouseButtonToHotkeyCode(event.button); if (!code) return; stopEvent(event); - pressedRef.current.add(code); - commitCodes([...pressedRef.current]); + applyHotkeyCode(code, true); + }; + + const onMouseUp = (event: MouseEvent) => { + const code = mouseButtonToHotkeyCode(event.button); + if (!code) return; + stopEvent(event); + applyHotkeyCode(code, false); }; window.addEventListener('keydown', onKeyDown, true); window.addEventListener('keyup', onKeyUp, true); window.addEventListener('mousedown', onMouseDown, true); + window.addEventListener('mouseup', onMouseUp, true); return () => { window.removeEventListener('keydown', onKeyDown, true); window.removeEventListener('keyup', onKeyUp, true); window.removeEventListener('mousedown', onMouseDown, true); + window.removeEventListener('mouseup', onMouseUp, true); }; }, [recording]); @@ -448,27 +465,6 @@ function mouseButtonToHotkeyCode(button: number): string | null { return null; } -function orderHotkeyCodes(codes: string[]): string[] { - const seen = new Set(); - return codes - .filter(code => { - if (!code || seen.has(code)) return false; - seen.add(code); - return true; - }) - .sort((a, b) => hotkeyCodeRank(a) - hotkeyCodeRank(b)); -} - -function hotkeyCodeRank(code: string): number { - const index = HOTKEY_CODE_ORDER.indexOf(code); - if (index >= 0) return index; - if (/^Key[A-Z]$/.test(code)) return 100 + code.charCodeAt(3); - if (/^Digit[0-9]$/.test(code)) return 200 + Number(code.slice(5)); - if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return 300 + Number(code.slice(1)); - if (/^Numpad[0-9]$/.test(code)) return 400 + Number(code.slice(6)); - return 1000; -} - const SUPPORTED_HOTKEY_CODES = new Set([ 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', 'MetaLeft', 'MetaRight', 'CapsLock', 'ScrollLock', 'Pause', 'PrintScreen', @@ -480,17 +476,6 @@ const SUPPORTED_HOTKEY_CODES = new Set([ 'Fn', 'FnLock', ]); -const HOTKEY_CODE_ORDER = [ - 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', - 'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause', - 'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', - 'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', - 'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight', - 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd', - 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter', - 'Mouse4', 'Mouse5', -]; - function AutostartRow() { const { t } = useTranslation(); const [enabled, setEnabled] = useState(false); From 6d3af7056d77342098f0e50ba17085563336fdb4 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 20:27:05 +0800 Subject: [PATCH 6/9] fix: forward fallback hotkey edges --- .../scripts/check-window-hotkey-fallback.mjs | 22 +++++++++ openless-all/app/src-tauri/src/coordinator.rs | 5 -- openless-all/app/src-tauri/src/hotkey.rs | 48 +++++++++++++++++-- openless-all/app/src/App.tsx | 30 +++++++----- .../app/src/lib/windowHotkeyFallback.test.ts | 42 ++++++++++++++++ .../app/src/lib/windowHotkeyFallback.ts | 27 +++++++++++ 6 files changed, 153 insertions(+), 21 deletions(-) create mode 100644 openless-all/app/scripts/check-window-hotkey-fallback.mjs create mode 100644 openless-all/app/src/lib/windowHotkeyFallback.test.ts create mode 100644 openless-all/app/src/lib/windowHotkeyFallback.ts diff --git a/openless-all/app/scripts/check-window-hotkey-fallback.mjs b/openless-all/app/scripts/check-window-hotkey-fallback.mjs new file mode 100644 index 00000000..0891d4b2 --- /dev/null +++ b/openless-all/app/scripts/check-window-hotkey-fallback.mjs @@ -0,0 +1,22 @@ +import * as esbuild from 'esbuild'; +import { mkdtemp, rm } from 'node:fs/promises'; +import { tmpdir } from 'node:os'; +import { join } from 'node:path'; +import { fileURLToPath, pathToFileURL } from 'node:url'; + +const tmp = await mkdtemp(join(tmpdir(), 'openless-window-hotkey-fallback-')); +const outfile = join(tmp, 'window-hotkey-fallback-test.mjs'); + +try { + await esbuild.build({ + entryPoints: [fileURLToPath(new URL('../src/lib/windowHotkeyFallback.test.ts', import.meta.url))], + outfile, + bundle: true, + platform: 'node', + format: 'esm', + logLevel: 'silent', + }); + await import(pathToFileURL(outfile).href); +} finally { + await rm(tmp, { recursive: true, force: true }); +} diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index abc1a7f2..03b5f163 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -911,11 +911,6 @@ async fn handle_window_hotkey_event( #[cfg(target_os = "windows")] { if !window_hotkey_fallback_enabled() { - if event_type == "keydown" && !repeat { - log::info!( - "[window-hotkey] ignored because Windows lifecycle owner is the low-level hook" - ); - } return Ok(()); } diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index f6315f1a..2932553c 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -204,6 +204,17 @@ fn is_shift_hotkey_code(code: &str) -> bool { matches!(code, "ShiftLeft" | "ShiftRight") } +#[cfg(any(target_os = "macos", test))] +fn dispatch_mac_caps_lock_edge(shared: &Shared, tx: &Sender) { + dispatch_hotkey_code(shared, tx, "CapsLock", true); + dispatch_hotkey_code(shared, tx, "CapsLock", false); +} + +#[cfg(any(target_os = "macos", test))] +fn mac_keycode_uses_modifier_flags(keycode: i64) -> bool { + matches!(keycode, 54 | 55 | 56 | 58 | 59 | 60 | 61 | 62 | 63) +} + pub(crate) fn binding_matches_pressed_codes( binding: &HotkeyBinding, pressed_codes: &BTreeSet, @@ -224,9 +235,10 @@ mod platform { use std::sync::Arc; use super::{ - dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, - is_shift_hotkey_code, send_or_log, start_listener_thread, update_shared_binding, - HotkeyAdapter, HotkeyEvent, Shared, StartupTx, + dispatch_hotkey_code, dispatch_mac_caps_lock_edge, dispatch_translation_modifier_code, + install_error, is_shift_hotkey_code, mac_keycode_uses_modifier_flags, send_or_log, + start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, + StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -305,7 +317,6 @@ mod platform { const MOUSE_EVENT_BUTTON_NUMBER: CgEventField = 3; const KEYBOARD_EVENT_KEYCODE: CgEventField = 9; - const FLAG_MASK_ALPHA_SHIFT: CgEventFlags = 0x0001_0000; const FLAG_MASK_SHIFT: CgEventFlags = 0x0002_0000; const FLAG_MASK_CONTROL: CgEventFlags = 0x0004_0000; const FLAG_MASK_ALTERNATE: CgEventFlags = 0x0008_0000; @@ -434,6 +445,10 @@ mod platform { fn handle_flags_changed(ctx: &CallbackContext, event: CgEventRef) { let flags = unsafe { CGEventGetFlags(event) }; let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + if keycode == 57 { + dispatch_mac_caps_lock_edge(&ctx.shared, &ctx.tx); + return; + } if let Some(code) = mac_keycode_to_hotkey_code(keycode) { if is_shift_hotkey_code(code) { // Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。 @@ -490,10 +505,12 @@ mod platform { } fn mac_keycode_flag_mask(keycode: i64) -> Option { + if !mac_keycode_uses_modifier_flags(keycode) { + return None; + } match keycode { 54 | 55 => Some(FLAG_MASK_COMMAND), 56 | 60 => Some(FLAG_MASK_SHIFT), - 57 => Some(FLAG_MASK_ALPHA_SHIFT), 58 | 61 => Some(FLAG_MASK_ALTERNATE), 59 | 62 => Some(FLAG_MASK_CONTROL), 63 => Some(FLAG_MASK_SECONDARY_FN), @@ -668,6 +685,27 @@ mod tests { Ok(HotkeyEvent::TranslationModifierPressed) )); } + + #[test] + fn mac_caps_lock_flags_changed_dispatches_single_key_edge() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("CapsLock")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_mac_caps_lock_edge(&shared, &tx); + + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Released))); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn mac_caps_lock_is_not_a_modifier_flag_code() { + assert!(!mac_keycode_uses_modifier_flags(57)); + } } // ─────────────────────────── Windows implementation ─────────────────────────── diff --git a/openless-all/app/src/App.tsx b/openless-all/app/src/App.tsx index b1baac39..fda671cf 100644 --- a/openless-all/app/src/App.tsx +++ b/openless-all/app/src/App.tsx @@ -10,6 +10,10 @@ import { handleWindowHotkeyEvent, isTauri, } from './lib/ipc'; +import { + isWindowHotkeyKeyboardCandidate, + windowMouseHotkeyCode, +} from './lib/windowHotkeyFallback'; import { QaPanel } from './pages/QaPanel'; import { HotkeySettingsProvider } from './state/HotkeySettingsContext'; @@ -110,7 +114,7 @@ export function App({ isCapsule, isQa }: AppProps) { useEffect(() => { if (!isTauri || os !== 'win') return; const forwardKey = (event: KeyboardEvent) => { - if (!isWindowHotkeyCandidate(event)) return; + if (!isWindowHotkeyKeyboardCandidate(event)) return; void handleWindowHotkeyEvent( event.type as 'keydown' | 'keyup', event.key, @@ -118,11 +122,25 @@ export function App({ isCapsule, isQa }: AppProps) { event.repeat, ).catch(error => console.warn('[window-hotkey] forward failed', error)); }; + const forwardMouse = (event: MouseEvent) => { + const code = windowMouseHotkeyCode(event.button); + if (!code) return; + void handleWindowHotkeyEvent( + event.type === 'mousedown' ? 'keydown' : 'keyup', + code, + code, + false, + ).catch(error => console.warn('[window-hotkey] mouse forward failed', error)); + }; window.addEventListener('keydown', forwardKey, true); window.addEventListener('keyup', forwardKey, true); + window.addEventListener('mousedown', forwardMouse, true); + window.addEventListener('mouseup', forwardMouse, true); return () => { window.removeEventListener('keydown', forwardKey, true); window.removeEventListener('keyup', forwardKey, true); + window.removeEventListener('mousedown', forwardMouse, true); + window.removeEventListener('mouseup', forwardMouse, true); }; }, [os]); @@ -136,16 +154,6 @@ export function App({ isCapsule, isQa }: AppProps) { ); } -function isWindowHotkeyCandidate(event: KeyboardEvent): boolean { - return ( - event.key === 'Escape' || - event.code === 'ControlRight' || - event.code === 'ControlLeft' || - event.code === 'AltRight' || - event.code === 'MetaRight' - ); -} - function StartupShell() { return (
(actual: T, expected: T, name: string) { + if (actual !== expected) { + throw new Error(`${name}: expected ${expected}, got ${actual}`); + } +} + +function keyboardEvent(code: string, key = code): KeyboardEvent { + return { code, key } as KeyboardEvent; +} + +assertEqual( + isWindowHotkeyKeyboardCandidate(keyboardEvent('KeyK', 'k')), + true, + 'fallback forwards letter hotkeys', +); + +assertEqual( + isWindowHotkeyKeyboardCandidate(keyboardEvent('CapsLock')), + true, + 'fallback forwards CapsLock hotkeys', +); + +assertEqual( + isWindowHotkeyKeyboardCandidate(keyboardEvent('F12')), + true, + 'fallback forwards function key hotkeys', +); + +assertEqual( + isWindowHotkeyKeyboardCandidate(keyboardEvent('Numpad7')), + true, + 'fallback forwards numpad digit hotkeys', +); + +assertEqual(windowMouseHotkeyCode(3), 'Mouse4', 'fallback maps Mouse4'); +assertEqual(windowMouseHotkeyCode(4), 'Mouse5', 'fallback maps Mouse5'); +assertEqual(windowMouseHotkeyCode(0), null, 'fallback ignores primary mouse button'); diff --git a/openless-all/app/src/lib/windowHotkeyFallback.ts b/openless-all/app/src/lib/windowHotkeyFallback.ts new file mode 100644 index 00000000..52a7804f --- /dev/null +++ b/openless-all/app/src/lib/windowHotkeyFallback.ts @@ -0,0 +1,27 @@ +export function isWindowHotkeyKeyboardCandidate(event: KeyboardEvent): boolean { + const code = event.code; + if (event.key === 'Escape' || code === 'Escape') return true; + if (SUPPORTED_WINDOW_HOTKEY_CODES.has(code)) return true; + if (/^Key[A-Z]$/.test(code)) return true; + if (/^Digit[0-9]$/.test(code)) return true; + if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return true; + if (/^Numpad[0-9]$/.test(code)) return true; + return false; +} + +export function windowMouseHotkeyCode(button: number): string | null { + if (button === 3) return 'Mouse4'; + if (button === 4) return 'Mouse5'; + return null; +} + +const SUPPORTED_WINDOW_HOTKEY_CODES = new Set([ + 'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight', + 'MetaLeft', 'MetaRight', 'CapsLock', 'ScrollLock', 'Pause', 'PrintScreen', + 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', 'End', + 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', + 'ContextMenu', 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', + 'NumpadDecimal', 'NumpadEnter', 'Backquote', 'Minus', 'Equal', 'BracketLeft', + 'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', + 'Fn', 'FnLock', +]); From edcc1eff6ee5cf92cdb3ab5c2648e19090a0e9a6 Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 20:38:50 +0800 Subject: [PATCH 7/9] fix: reset held hotkey state on binding update --- openless-all/app/src-tauri/src/coordinator.rs | 16 ++++++++++++++++ 1 file changed, 16 insertions(+) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 03b5f163..6ba4dbe4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -379,6 +379,9 @@ impl Coordinator { pub fn update_hotkey_binding(&self) { self.inner.window_hotkey_pressed_codes.lock().clear(); *self.inner.hotkey_last_click_at.lock() = None; + self.inner + .hotkey_trigger_held + .store(false, Ordering::SeqCst); if let Some(monitor) = self.inner.hotkey.lock().as_ref() { monitor.update_binding(self.inner.prefs.get().hotkey); } @@ -2896,6 +2899,19 @@ mod tests { assert!(coordinator.inner.hotkey_last_click_at.lock().is_none()); } + #[test] + fn hotkey_binding_update_resets_held_trigger_state() { + let coordinator = Coordinator::new(); + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + + coordinator.update_hotkey_binding(); + + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + } + #[test] fn double_click_mode_requires_second_press_within_window() { let coordinator = Coordinator::new(); From 47c0da299b45b5b51ad0dd607170645f2226921a Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 21:08:36 +0800 Subject: [PATCH 8/9] fix: preserve CapsLock hold state on macOS --- openless-all/app/src-tauri/src/hotkey.rs | 69 ++++++++++++++++++++---- 1 file changed, 59 insertions(+), 10 deletions(-) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 2932553c..30e699c9 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -205,9 +205,17 @@ fn is_shift_hotkey_code(code: &str) -> bool { } #[cfg(any(target_os = "macos", test))] -fn dispatch_mac_caps_lock_edge(shared: &Shared, tx: &Sender) { - dispatch_hotkey_code(shared, tx, "CapsLock", true); - dispatch_hotkey_code(shared, tx, "CapsLock", false); +fn dispatch_mac_caps_lock_flags_changed( + shared: &Shared, + tx: &Sender, + alpha_shift_active: bool, +) { + if shared.binding.read().mode == crate::types::HotkeyMode::Hold { + dispatch_hotkey_code(shared, tx, "CapsLock", alpha_shift_active); + } else { + dispatch_hotkey_code(shared, tx, "CapsLock", true); + dispatch_hotkey_code(shared, tx, "CapsLock", false); + } } #[cfg(any(target_os = "macos", test))] @@ -235,10 +243,10 @@ mod platform { use std::sync::Arc; use super::{ - dispatch_hotkey_code, dispatch_mac_caps_lock_edge, dispatch_translation_modifier_code, - install_error, is_shift_hotkey_code, mac_keycode_uses_modifier_flags, send_or_log, - start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, - StartupTx, + dispatch_hotkey_code, dispatch_mac_caps_lock_flags_changed, + dispatch_translation_modifier_code, install_error, is_shift_hotkey_code, + mac_keycode_uses_modifier_flags, send_or_log, start_listener_thread, update_shared_binding, + HotkeyAdapter, HotkeyEvent, Shared, StartupTx, }; use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; @@ -317,6 +325,7 @@ mod platform { const MOUSE_EVENT_BUTTON_NUMBER: CgEventField = 3; const KEYBOARD_EVENT_KEYCODE: CgEventField = 9; + const FLAG_MASK_ALPHA_SHIFT: CgEventFlags = 0x0001_0000; const FLAG_MASK_SHIFT: CgEventFlags = 0x0002_0000; const FLAG_MASK_CONTROL: CgEventFlags = 0x0004_0000; const FLAG_MASK_ALTERNATE: CgEventFlags = 0x0008_0000; @@ -446,7 +455,11 @@ mod platform { let flags = unsafe { CGEventGetFlags(event) }; let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if keycode == 57 { - dispatch_mac_caps_lock_edge(&ctx.shared, &ctx.tx); + dispatch_mac_caps_lock_flags_changed( + &ctx.shared, + &ctx.tx, + (flags & FLAG_MASK_ALPHA_SHIFT) != 0, + ); return; } if let Some(code) = mac_keycode_to_hotkey_code(keycode) { @@ -687,7 +700,7 @@ mod tests { } #[test] - fn mac_caps_lock_flags_changed_dispatches_single_key_edge() { + fn mac_caps_lock_toggle_mode_dispatches_click_edge_per_toggle() { let shared = shared_with_binding(HotkeyBinding { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, @@ -695,13 +708,49 @@ mod tests { }); let (tx, rx) = mpsc::channel(); - dispatch_mac_caps_lock_edge(&shared, &tx); + dispatch_mac_caps_lock_flags_changed(&shared, &tx, true); assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Released))); assert!(rx.try_recv().is_err()); } + #[test] + fn mac_caps_lock_double_click_mode_dispatches_click_edge_per_toggle() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::DoubleClick, + keys: Some(vec![HotkeyKey::new("CapsLock")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_mac_caps_lock_flags_changed(&shared, &tx, true); + + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Released))); + assert!(rx.try_recv().is_err()); + } + + #[test] + fn mac_caps_lock_hold_mode_tracks_toggle_state() { + let shared = shared_with_binding(HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Hold, + keys: Some(vec![HotkeyKey::new("CapsLock")]), + }); + let (tx, rx) = mpsc::channel(); + + dispatch_mac_caps_lock_flags_changed(&shared, &tx, true); + + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed))); + assert!(rx.try_recv().is_err()); + + dispatch_mac_caps_lock_flags_changed(&shared, &tx, false); + + assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Released))); + assert!(rx.try_recv().is_err()); + } + #[test] fn mac_caps_lock_is_not_a_modifier_flag_code() { assert!(!mac_keycode_uses_modifier_flags(57)); From ce64e2e45015f03c39d0b5df9ed0f386bdff685b Mon Sep 17 00:00:00 2001 From: millionart Date: Mon, 4 May 2026 21:27:06 +0800 Subject: [PATCH 9/9] fix: preserve legacy Fn hotkey on Windows --- openless-all/app/src-tauri/src/types.rs | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index e861f0ee..4819437b 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -346,6 +346,9 @@ fn legacy_trigger_code(trigger: HotkeyTrigger) -> &'static str { HotkeyTrigger::RightControl => "ControlRight", HotkeyTrigger::LeftControl => "ControlLeft", HotkeyTrigger::RightCommand => "MetaRight", + #[cfg(target_os = "windows")] + HotkeyTrigger::Fn => "ControlRight", + #[cfg(not(target_os = "windows"))] HotkeyTrigger::Fn => "Fn", } } @@ -660,6 +663,15 @@ mod tests { assert_eq!(binding.display_label(), "右 Control"); } + #[cfg(target_os = "windows")] + #[test] + fn legacy_fn_trigger_uses_windows_control_right_alias() { + let binding: HotkeyBinding = + serde_json::from_str(r#"{"trigger":"fn","mode":"toggle"}"#).unwrap(); + + assert_eq!(binding.effective_codes(), vec!["ControlRight".to_string()]); + } + #[test] fn hotkey_binding_supports_combo_side_keys_mouse_and_double_click_mode() { let binding = HotkeyBinding {