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); } }