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/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/commands.rs b/openless-all/app/src-tauri/src/commands.rs index 9aaa6976..f4c3ef2e 100644 --- a/openless-all/app/src-tauri/src/commands.rs +++ b/openless-all/app/src-tauri/src/commands.rs @@ -642,6 +642,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..6ba4dbe4 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -5,10 +5,11 @@ //! 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; -use std::time::Instant; +use std::time::{Duration, Instant}; use chrono::Utc; use parking_lot::Mutex; @@ -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, @@ -30,8 +31,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 +52,8 @@ enum SessionPhase { Inserting, } +const HOTKEY_DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450); + enum ActiveAsr { Volcengine(Arc), Whisper(Arc), @@ -127,6 +130,8 @@ struct Inner { hotkey: Mutex>, 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 @@ -222,6 +227,8 @@ 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), + 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()), @@ -370,6 +377,11 @@ 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); } @@ -696,10 +708,19 @@ 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 { - // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 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。 if panel_visible { handle_qa_option_edge(inner).await; } else { @@ -708,15 +729,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 +760,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"); } _ => {} @@ -878,16 +914,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(()); - } - - let trigger = inner.prefs.get().hotkey.trigger; - if !window_key_matches_trigger(trigger, &key, &code) { return Ok(()); } @@ -896,14 +922,36 @@ async fn handle_window_hotkey_event( if repeat { return Ok(()); } - log::info!( - "[window-hotkey] pressed trigger={trigger:?} 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 trigger={trigger:?} 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; + } } _ => {} } @@ -916,18 +964,58 @@ 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 update_window_hotkey_pressed_codes( + pressed_codes: &mut BTreeSet, + binding: &HotkeyBinding, + key: &str, + code: &str, + pressed: bool, +) -> Option<(bool, bool)> { + let normalized = normalize_window_hotkey_code(key, code); + if !window_code_belongs_to_binding(binding, &normalized) { + return None; + } - match trigger { - HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight", - HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft", - HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => { - (key == "Alt" || key == "AltGraph") && code == "AltRight" + 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_code_belongs_to_binding(binding: &HotkeyBinding, code: &str) -> bool { + !code.is_empty() + && binding + .effective_codes() + .iter() + .any(|candidate| candidate == code) +} + +#[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()) } - HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight", - HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight", - HotkeyTrigger::Fn => key == "Control" && code == "ControlRight", + other if other.len() == 1 && other.as_bytes()[0].is_ascii_digit() => { + format!("Digit{other}") + } + other => other.into(), } } @@ -2548,7 +2636,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 +2654,76 @@ 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_code_filter_accepts_legacy_and_configured_codes() { + let legacy = HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + ..Default::default() + }; + assert!(window_code_belongs_to_binding(&legacy, "ControlRight")); + assert!(!window_code_belongs_to_binding(&legacy, "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_code_belongs_to_binding(&caps_lock, "CapsLock")); + assert!(!window_code_belongs_to_binding(&caps_lock, "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] @@ -2710,6 +2839,104 @@ 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)); + } + + #[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 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(); + 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 +2947,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..30e699c9 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -10,7 +10,8 @@ //! //! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。 -use std::sync::atomic::AtomicBool; +use std::collections::BTreeSet; +use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::mpsc::{self, Sender}; use std::sync::Arc; use std::time::Duration; @@ -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,25 +136,119 @@ 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 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") +} + +#[cfg(any(target_os = "macos", test))] +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))] +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, +) -> 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")] mod platform { use std::ffi::c_void; - use std::sync::atomic::Ordering; use std::sync::mpsc::Sender; use std::sync::Arc; use super::{ - install_error, 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, HotkeyTrigger}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; pub fn start_adapter( binding: HotkeyBinding, @@ -218,12 +315,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 +379,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 +443,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 @@ -344,37 +453,35 @@ 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) }; - let trigger = ctx.shared.binding.read().trigger; - let expected_keycode = trigger_to_keycode(trigger); - if keycode != expected_keycode { + if keycode == 57 { + dispatch_mac_caps_lock_flags_changed( + &ctx.shared, + &ctx.tx, + (flags & FLAG_MASK_ALPHA_SHIFT) != 0, + ); 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 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); + dispatch_hotkey_code( + &ctx.shared, + &ctx.tx, + code, + family_active && !code_was_pressed, + ); + } } } @@ -382,37 +489,278 @@ 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 { + if !mac_keycode_uses_modifier_flags(keycode) { + return None; + } + match keycode { + 54 | 55 => Some(FLAG_MASK_COMMAND), + 56 | 60 => Some(FLAG_MASK_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, } } } +#[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) + )); + } + + #[test] + fn mac_caps_lock_toggle_mode_dispatches_click_edge_per_toggle() { + 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_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)); + } +} + // ─────────────────────────── 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; @@ -422,28 +770,72 @@ mod platform { use windows::Win32::UI::WindowsAndMessaging::{ CallNextHookEx, DispatchMessageW, GetMessageW, PostThreadMessageW, SetWindowsHookExW, TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG, - WH_KEYBOARD_LL, WM_QUIT, + MSLLHOOKSTRUCT, 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, 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, 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 +885,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 +897,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 +906,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 +920,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 +948,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 +976,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,71 +1001,155 @@ 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; } - // 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); } - 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 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 +1167,14 @@ 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, 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, HotkeyTrigger}; + use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError}; pub fn start_adapter( binding: HotkeyBinding, @@ -730,56 +1239,155 @@ 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。 - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - let was_held = shared - .translation_modifier_held - .swap(true, Ordering::SeqCst); - if !was_held { - let _ = tx.send(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); } - return; + dispatch_hotkey_code(shared, tx, code, true); } - 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); + } + EventType::KeyRelease(key) => { + 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); } } - EventType::KeyRelease(key) => { - if matches!(key, Key::ShiftLeft | Key::ShiftRight) { - shared - .translation_modifier_held - .store(false, Ordering::SeqCst); - return; + EventType::ButtonPress(button) => { + if let Some(code) = rdev_button_to_hotkey_code(button) { + dispatch_hotkey_code(shared, tx, code, true); } - 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); - } + } + 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 f608d44b..4819437b 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -274,6 +274,7 @@ impl HotkeyTrigger { pub enum HotkeyMode { Toggle, Hold, + DoubleClick, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] @@ -294,11 +295,134 @@ 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", + #[cfg(target_os = "windows")] + HotkeyTrigger::Fn => "ControlRight", + #[cfg(not(target_os = "windows"))] + 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)] @@ -350,7 +474,7 @@ impl HotkeyCapability { supports_side_specific_modifiers: true, explicit_fallback_available: false, status_hint: Some( - "默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。" + "默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。" .into(), ), }; @@ -443,6 +567,7 @@ impl Default for HotkeyBinding { Self { trigger: HotkeyTrigger::RightControl, mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("ControlRight")]), } } @@ -451,6 +576,7 @@ impl Default for HotkeyBinding { Self { trigger: HotkeyTrigger::RightOption, mode: HotkeyMode::Toggle, + keys: Some(vec![HotkeyKey::new("AltRight")]), } } } @@ -527,4 +653,57 @@ 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"); + } + + #[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 { + 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/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 (
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/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/lib/ipc.ts b/openless-all/app/src/lib/ipc.ts index 4ae8cd79..4313b5ef 100644 --- a/openless-all/app/src/lib/ipc.ts +++ b/openless-all/app/src/lib/ipc.ts @@ -40,7 +40,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, @@ -62,7 +62,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 a8577fd1..d71a561b 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -50,11 +50,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/lib/windowHotkeyFallback.test.ts b/openless-all/app/src/lib/windowHotkeyFallback.test.ts new file mode 100644 index 00000000..3cee5692 --- /dev/null +++ b/openless-all/app/src/lib/windowHotkeyFallback.test.ts @@ -0,0 +1,42 @@ +import { + isWindowHotkeyKeyboardCandidate, + windowMouseHotkeyCode, +} from './windowHotkeyFallback'; + +function assertEqual(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', +]); diff --git a/openless-all/app/src/pages/History.tsx b/openless-all/app/src/pages/History.tsx index 4ec8e88d..2299461b 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 98794751..4a97ee1a 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'; @@ -117,7 +117,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 959b6e87..e90c0812 100644 --- a/openless-all/app/src/pages/QaPanel.tsx +++ b/openless-all/app/src/pages/QaPanel.tsx @@ -14,7 +14,7 @@ import { useEffect, useMemo, useRef, useState, type CSSProperties } from 'react' import { useTranslation } from 'react-i18next'; 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'; import { renderQaMarkdown, renderQaPlainText } from '../lib/qaMarkdown'; const SELECTION_PREVIEW_MAX = 60; @@ -121,7 +121,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(); @@ -172,7 +172,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 88cf7d0a..862d74a3 100644 --- a/openless-all/app/src/pages/Settings.tsx +++ b/openless-all/app/src/pages/Settings.tsx @@ -8,7 +8,8 @@ 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 { createHotkeyRecorderState, orderHotkeyCodes, updateHotkeyRecorderState } from '../lib/hotkeyRecorder'; import { checkAccessibilityPermission, checkMicrophonePermission, @@ -28,6 +29,7 @@ import { } from '../lib/ipc'; import type { HotkeyCapability, + HotkeyBinding, HotkeyMode, HotkeyStatus, HotkeyTrigger, @@ -165,10 +167,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) => @@ -179,6 +188,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') @@ -208,39 +218,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')} +
@@ -275,6 +295,188 @@ 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 recorderStateRef = useRef(createHotkeyRecorderState()); + const recordingRef = useRef(false); + + const resetRecording = () => { + recordingRef.current = false; + recorderStateRef.current = createHotkeyRecorderState(); + setDraftCodes([]); + setRecording(false); + }; + + const commitCodes = (codes: string[]) => { + const ordered = orderHotkeyCodes(codes); + resetRecording(); + onCommit(ordered); + }; + + const startRecording = () => { + recordingRef.current = true; + recorderStateRef.current = createHotkeyRecorderState(); + setDraftCodes([]); + setRecording(true); + }; + + useEffect(() => { + if (!recording) return undefined; + + const stopEvent = (event: Event) => { + event.preventDefault(); + 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') { + resetRecording(); + return; + } + const code = normalizeKeyboardHotkeyCode(event); + if (!code) return; + applyHotkeyCode(code, true); + }; + + const onKeyUp = (event: KeyboardEvent) => { + stopEvent(event); + if (!recordingRef.current) return; + if (event.key === 'Escape' || event.code === 'Escape') { + resetRecording(); + return; + } + const code = normalizeKeyboardHotkeyCode(event); + if (!code) return; + applyHotkeyCode(code, false); + }; + + const onMouseDown = (event: MouseEvent) => { + const code = mouseButtonToHotkeyCode(event.button); + if (!code) return; + stopEvent(event); + 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]); + + 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; +} + +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', +]); + function AutostartRow() { const { t } = useTranslation(); const [enabled, setEnabled] = useState(false); @@ -863,6 +1065,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 (