diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 868f8c97..02e5a7aa 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -96,7 +96,7 @@ pub(super) fn qa_event_target() -> &'static str { use dictation::dictation_error_code; use dictation::{ begin_session, begin_session_as, cancel_session, end_session, handle_pressed_edge, - handle_released_edge, request_stop_during_starting, + handle_released_edge, handle_trigger_combined, request_stop_during_starting, }; #[cfg(any(debug_assertions, test))] use dictation::{handle_pressed, handle_released}; @@ -476,6 +476,10 @@ struct Inner { hotkey: Mutex>, hotkey_status: Mutex, hotkey_trigger_held: AtomicBool, + /// 本次热键按下是否真的「开出了」一个会话。TriggerCombined(触发键被当修饰键用) + /// 只撤销这一次按下开出来的会话:若这次按下其实是 toggle 停止 / 被冷却拦下 / + /// 路由给了 QA,就没有可撤销的东西,绝不能顺手取消正在转写的上一条。 + hotkey_press_began_session: AtomicBool, /// 防抖时间戳:handle_pressed_edge 入口检查与本字段的距离,< 250ms 的边沿直接 /// 丢弃(误触双击 / 微动开关回弹 / 用户连点过快造成的空转写报错)。 /// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防 @@ -672,6 +676,7 @@ impl Coordinator { hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), + hotkey_press_began_session: AtomicBool::new(false), last_hotkey_dispatch_at: Mutex::new(None), hotkey_press_at: Mutex::new(None), session_cooldown_until: Mutex::new(None), @@ -774,6 +779,7 @@ impl Coordinator { hotkey: Mutex::new(None), hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), + hotkey_press_began_session: AtomicBool::new(false), last_hotkey_dispatch_at: Mutex::new(None), hotkey_press_at: Mutex::new(None), session_cooldown_until: Mutex::new(None), @@ -3550,6 +3556,66 @@ mod tests { assert!(coordinator.inner.hotkey_press_at.lock().is_none()); } + // Option+任意字母/数字键:这次按下开出来的会话必须被撤销,且随后的松手边沿不能再被当成 + // Auto 短按锁存(否则录音一直开着,正是用户报的「按 Option+其他键唤起听写」)。 + #[tokio::test] + async fn trigger_combined_cancels_session_started_by_this_press() { + let coordinator = Coordinator::new(); + set_auto_mode(&coordinator); + coordinator.inner.state.lock().phase = SessionPhase::Listening; + let pressed_at = std::time::Instant::now(); + *coordinator.inner.hotkey_press_at.lock() = Some(pressed_at); + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + coordinator + .inner + .hotkey_press_began_session + .store(true, Ordering::SeqCst); + + handle_trigger_combined(&coordinator.inner); + + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + assert!(coordinator.inner.hotkey_press_at.lock().is_none()); + // 组合键误触不算「刚用完一次听写」:不留冷却,否则紧接着真想说话的按下被吞。 + assert!(coordinator.inner.session_cooldown_until.lock().is_none()); + + handle_released_edge( + &coordinator.inner, + pressed_at + std::time::Duration::from_millis(80), + ) + .await; + + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + } + + // 这次按下是 toggle 停止(没开出会话)时,组合键撤销不能顺手取消正在跑的会话 —— + // 那条录音是上一次按下锁存的,取消 = 用户白说一段。 + #[tokio::test] + async fn trigger_combined_leaves_session_it_did_not_start() { + let coordinator = Coordinator::new(); + set_auto_mode(&coordinator); + coordinator.inner.state.lock().phase = SessionPhase::Listening; + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + coordinator + .inner + .hotkey_press_began_session + .store(false, Ordering::SeqCst); + + handle_trigger_combined(&coordinator.inner); + + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); + } + #[test] fn enabling_shortcut_recording_clears_dictation_hold_latch() { let coordinator = Coordinator::new(); diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index f396df07..2d3fea56 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -19,6 +19,16 @@ const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(25 /// 时长以热键事件产生时携带的时间戳计算,避免串行 bridge 的排队延迟改变用户的物理按住时长。 /// 350ms 是「点一下 vs 明显按住」的自然分界。 const AUTO_HOLD_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(350); +/// modifier-only 触发键(Option / 右 Ctrl…)按下后的「组合键仲裁窗口」。 +/// +/// 按下这一刻还分不清用户是想说话,还是要打 Option+任意字母/数字键:修饰键的按下边沿两者完全一样。 +/// 所以先等这么久再开会话——期间监听器若报告叠加了普通键,这次按下整条作废,麦克风 +/// 不开、胶囊不闪、也不烧一次 ASR 建连。代价是听写起录晚这么多,取 150ms:足以覆盖 +/// 绝大多数组合键的「修饰键→普通键」间隔,又低于人从按键到开口的反应时间(>250ms), +/// 不会吃掉首字。窗口没盖住的慢速组合键(按住 Option 半秒再按 Tab)由 TriggerCombined +/// 事后撤销兜底,见 handle_trigger_combined。 +pub(super) const COMBO_ARBITRATION_GRACE: std::time::Duration = + std::time::Duration::from_millis(150); const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12); #[cfg(target_os = "macos")] @@ -735,6 +745,12 @@ pub(super) async fn handle_pressed_edge(inner: &Arc, pressed_at: std::tim return; } + // 新的一次按下:先假定它什么会话都没开出来,由下面的分支在真正 begin_session + // 时置 true。TriggerCombined 靠这个标志判断有没有东西要撤销。 + inner + .hotkey_press_began_session + .store(false, Ordering::SeqCst); + // 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。 // 例外:dictation session 已经在跑(Starting / Listening / Processing / Inserting), // 即使 QA 浮窗被打开了,这条边沿也必须先走 dictation。否则 begin_qa_session 会 @@ -797,13 +813,13 @@ pub(super) async fn handle_pressed(inner: &Arc, pressed_at: std::time::In } } } - let _ = begin_session(inner).await; + begin_session_from_press(inner).await; } (HotkeyMode::Toggle, SessionPhase::Listening) => { let _ = end_session(inner).await; } (HotkeyMode::Hold, SessionPhase::Idle) => { - let _ = begin_session(inner).await; + begin_session_from_press(inner).await; } // Toggle 模式 Starting 阶段第二次按 → 用户想停。 // 不能直接 end_session(ASR session 还没建好),存边沿,握手完成后立即触发。 @@ -831,7 +847,7 @@ pub(super) async fn handle_pressed(inner: &Arc, pressed_at: std::time::In } } *inner.hotkey_press_at.lock() = Some(pressed_at); - let _ = begin_session(inner).await; + begin_session_from_press(inner).await; } // Auto 模式已因上一次「短按」锁存为切换态,再次按下 → 用户想停。 (HotkeyMode::Auto, SessionPhase::Listening) => { @@ -845,6 +861,76 @@ pub(super) async fn handle_pressed(inner: &Arc, pressed_at: std::time::In } } +/// 由「这一次热键按下」开一条会话,并记下这个事实。TriggerCombined 只撤销带着这个 +/// 标记的会话(见 handle_trigger_combined)。 +/// +/// 开录之前先过一遍组合键仲裁窗口:命中就当这次按下没发生过——不开麦、不弹胶囊。 +async fn begin_session_from_press(inner: &Arc) { + if press_resolves_to_combo(inner).await { + // 按住态一并清掉:随后必然到来的 Released 会被 handle_released_edge 的 + // was_held 检查吞掉,不会走 Auto 短按锁存。 + inner.hotkey_trigger_held.store(false, Ordering::SeqCst); + *inner.hotkey_press_at.lock() = None; + return; + } + inner + .hotkey_press_began_session + .store(true, Ordering::SeqCst); + let _ = begin_session(inner).await; +} + +/// 组合键仲裁:等 COMBO_ARBITRATION_GRACE,再问监听器这次按住有没有叠加普通键。 +/// +/// 只对 modifier-only 触发键等待 —— 自定义组合键(Cmd+Shift+D 之类)本身就没有歧义, +/// 让它白等这一下纯粹是掉延迟。等待放在防抖 / 冷却判定之后,那些判定用的仍是未被本 +/// 窗口推迟的时刻(尤其别把「排队接力」窗口挤掉,见 is_queued_chain_press)。 +async fn press_resolves_to_combo(inner: &Arc) -> bool { + let binding = inner.prefs.get().dictation_hotkey; + if crate::shortcut_binding::legacy_modifier_trigger(&binding).is_none() { + return false; + } + tokio::time::sleep(COMBO_ARBITRATION_GRACE).await; + let combined = inner + .hotkey + .lock() + .as_ref() + .is_some_and(|monitor| monitor.trigger_combined_since_press()); + if combined { + log::info!( + "[coord] 触发键在 {}ms 仲裁窗口内叠加了其他键 —— 本次按下作废,不开录音", + COMBO_ARBITRATION_GRACE.as_millis() + ); + } + combined +} + +/// 触发键(modifier-only 热键)按住期间又按了普通键 —— 用户在打 Option+任意字母/数字键这类组合键, +/// 不是想说话。撤销这次按下: +/// +/// 1. 清掉按住态。后面必然到来的 Released 会被 handle_released_edge 的 `was_held` +/// 检查吞掉,不会再走 Hold 松手结束 / Auto 短按锁存那套判定 —— 否则 Auto 模式下 +/// 「Option+组合键快速松手」正是被判成短按锁存,录音一直开着停不下来。 +/// 2. 只有这次按下真的开出了会话才取消它。按下时是 toggle 停止 / 被冷却拦下 / +/// 路由给 QA 的,什么都不动(尤其不能取消正在转写的上一条)。 +/// +/// 组合键误触不算「刚用完一次听写」,所以顺带清掉冷却与防抖时间戳:否则紧接着那次 +/// 真想说话的按下会被 #545 冷却 / 250ms 防抖静默吞掉,用户以为热键坏了。 +pub(super) fn handle_trigger_combined(inner: &Arc) { + let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst); + *inner.hotkey_press_at.lock() = None; + let began_session = inner + .hotkey_press_began_session + .swap(false, Ordering::SeqCst); + if !was_held || !began_session { + log::info!("[coord] hotkey combined with another key (本次按下没开出会话,无需撤销)"); + return; + } + log::info!("[coord] hotkey combined with another key —— 取消本次按下开出的会话"); + cancel_session(inner); + *inner.session_cooldown_until.lock() = None; + *inner.last_hotkey_dispatch_at.lock() = None; +} + pub(super) async fn handle_released_edge(inner: &Arc, released_at: std::time::Instant) { let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst); if was_held { @@ -3730,6 +3816,49 @@ mod tests { }; use uuid::Uuid; + fn coordinator_with_dictation_hotkey( + binding: crate::types::ShortcutBinding, + ) -> super::super::Coordinator { + let coordinator = super::super::Coordinator::new(); + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + dictation_hotkey: binding, + ..Default::default() + }) + .unwrap(); + coordinator + } + + // modifier-only 触发键:按下后必须先过仲裁窗口,才能知道这是说话还是 + // Option+任意字母/数字键。 + #[tokio::test] + async fn modifier_only_press_waits_out_the_arbitration_window() { + let coordinator = coordinator_with_dictation_hotkey(crate::types::ShortcutBinding { + primary: "LeftOption".into(), + modifiers: vec![], + }); + + let started = std::time::Instant::now(); + // 测试里没装监听器(inner.hotkey = None)→ 读不到叠加标志,按「不是组合键」放行。 + assert!(!super::press_resolves_to_combo(&coordinator.inner).await); + assert!(started.elapsed() >= super::COMBO_ARBITRATION_GRACE); + } + + // 自定义组合键(Cmd+Shift+D)没有歧义 —— 白等这一下就是纯掉延迟。 + #[tokio::test] + async fn custom_combo_press_skips_the_arbitration_window() { + let coordinator = coordinator_with_dictation_hotkey(crate::types::ShortcutBinding { + primary: "D".into(), + modifiers: vec!["cmd".into(), "shift".into()], + }); + + let started = std::time::Instant::now(); + assert!(!super::press_resolves_to_combo(&coordinator.inner).await); + assert!(started.elapsed() < super::COMBO_ARBITRATION_GRACE); + } + #[test] fn silent_retry_replaces_initial_asr_attribution() { let mut label = Some(super::AsrCallLabel::new( diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 9cca912c..08116f35 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -363,11 +363,30 @@ pub(super) fn less_computer_modifier_bridge_loop(inner: Arc, rx: mpsc::Re }); } HotkeyEvent::Cancelled => cancel_session(&inner_cloned), + HotkeyEvent::TriggerCombined => cancel_less_computer_press(&inner_cloned), HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {} } } } +/// Less Computer 触发键被当修饰键用(Option+任意字母/数字键之类):撤销这次按下开出的语音会话。 +/// handle_less_computer_pressed 只在 Idle 时开会话,所以此刻还在跑的 voice_agent +/// 会话必然就是这次按下开出来的;其他情况(按下被忽略)什么都不动。 +fn cancel_less_computer_press(inner: &Arc) { + let (phase, voice_agent) = { + let state = inner.state.lock(); + (state.phase, state.voice_agent) + }; + if !voice_agent || !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) { + return; + } + log::info!("[less-computer] 触发键与其他键组合按下 —— 取消本次按下开出的会话"); + cancel_session(inner); + if let Some(app) = inner.app.lock().clone() { + crate::hide_less_computer_glow(&app); + } +} + pub(super) fn less_computer_combo_bridge_loop(inner: Arc, rx: mpsc::Receiver) { while let Ok(evt) = rx.recv() { if inner.shortcut_recording_active.load(Ordering::SeqCst) { @@ -1039,6 +1058,9 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { cancel_session(&inner_cloned); } + HotkeyEvent::TriggerCombined => { + handle_trigger_combined(&inner_cloned); + } HotkeyEvent::TranslationModifierPressed => { let translation_hotkey = inner_cloned.prefs.get().translation_hotkey; if is_builtin_translation_shift(&translation_hotkey) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index abc66fb9..06d1db18 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -24,6 +24,10 @@ pub enum HotkeyEvent { Pressed { at: Instant }, Released { at: Instant }, Cancelled, + /// 触发键(modifier-only 热键)按住期间又按下了普通键 —— 用户是在打 Option+任意字母/数字键 + /// 这类组合键,不是想说话。上层据此撤销这次按下的副作用。同一次按住只发一次, + /// 之后仍会照常发 Released(上层的 held latch 会把它吞掉)。 + TriggerCombined, /// Shift(或未来配置项指定的修饰键)按下边沿。可在录音过程中任何时刻产生; /// 上层据此切换到翻译输出管线。详见 issue #4。 TranslationModifierPressed, @@ -39,6 +43,7 @@ mod tests { Shared { binding: RwLock::new(HotkeyBinding::default()), trigger_held: AtomicBool::new(true), + trigger_companion_seen: AtomicBool::new(false), qa_trigger: RwLock::new(None), qa_trigger_held: AtomicBool::new(true), translation_trigger: RwLock::new(None), @@ -107,6 +112,12 @@ pub trait HotkeyAdapter: Send + Sync { translation_trigger: Option, ); fn reset_held_state(&self); + /// 本次按住期间,监听器是否已经看到触发键被叠加了普通键。上层的「仲裁窗口」 + /// 按下后先等一小会儿再读它,命中就整条按下作废(麦克风都不用开)。 + /// 没有键盘监听器的平台(Linux/fcitx5)恒为 false。 + fn trigger_combined_since_press(&self) -> bool { + false + } fn shutdown(&self) {} } @@ -114,6 +125,9 @@ struct Shared { binding: RwLock, /// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。 trigger_held: AtomicBool, + /// 本次按住期间是否已经发过 TriggerCombined。每次 Pressed 边沿重置为 false, + /// 保证「按住触发键连按好几个普通键」只撤销一次。 + trigger_companion_seen: AtomicBool, qa_trigger: RwLock>, qa_trigger_held: AtomicBool, translation_trigger: RwLock>, @@ -161,6 +175,10 @@ impl HotkeyMonitor { self.adapter.reset_held_state(); } + pub fn trigger_combined_since_press(&self) -> bool { + self.adapter.trigger_combined_since_press() + } + pub fn capability() -> HotkeyCapability { HotkeyCapability::current() } @@ -206,6 +224,7 @@ where let shared = Arc::new(Shared { binding: RwLock::new(binding), trigger_held: AtomicBool::new(false), + trigger_companion_seen: AtomicBool::new(false), qa_trigger: RwLock::new(None), qa_trigger_held: AtomicBool::new(false), translation_trigger: RwLock::new(None), @@ -350,6 +369,12 @@ mod platform { reset_shared_held_state(&self.shared); } + fn trigger_combined_since_press(&self) -> bool { + self.shared + .trigger_companion_seen + .load(Ordering::SeqCst) + } + fn shutdown(&self) { // 顺序:先 disable tap 让 OS 不再向我们派发事件,然后 stop runloop // 让 listener 线程从 CFRunLoopRun() 返回退出。take() 保证幂等。 @@ -598,6 +623,9 @@ mod platform { if is_active && !was_held { ctx.shared.trigger_held.store(true, Ordering::SeqCst); + ctx.shared + .trigger_companion_seen + .store(false, Ordering::SeqCst); send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: std::time::Instant::now() }); } else if !is_active && was_held { ctx.shared.trigger_held.store(false, Ordering::SeqCst); @@ -633,7 +661,30 @@ mod platform { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; if keycode == ESC_KEYCODE { send_or_log(&ctx.tx, HotkeyEvent::Cancelled); + return; } + note_companion_key_down(ctx); + } + + /// 触发键按住期间按下任意普通键 = 用户在打组合键(Option+任意字母/数字键、Option+Tab…), + /// 不是想说话 —— 发一次 TriggerCombined 让上层撤销这次按下。 + /// + /// macOS 的修饰键走 FLAGS_CHANGED、不会进 KEY_DOWN,所以叠加 Shift(翻译修饰键) + /// 或 Cmd 不会被误判成组合键;只有真正的字符 / 功能键才算。OS 自动重复与「按住 + /// 触发键连按多个键」由 companion latch 收敛成一次。 + fn note_companion_key_down(ctx: &CallbackContext) { + if !ctx.shared.trigger_held.load(Ordering::SeqCst) { + return; + } + if ctx + .shared + .trigger_companion_seen + .swap(true, Ordering::SeqCst) + { + return; + } + log::info!("[hotkey] 触发键与其他键组合按下 —— 撤销本次触发"); + send_or_log(&ctx.tx, HotkeyEvent::TriggerCombined); } fn trigger_to_keycode(trigger: HotkeyTrigger) -> i64 { @@ -681,6 +732,7 @@ mod platform { keys: None, }), trigger_held: AtomicBool::new(false), + trigger_companion_seen: AtomicBool::new(false), qa_trigger: RwLock::new(None), qa_trigger_held: AtomicBool::new(false), translation_trigger: RwLock::new(None), @@ -762,6 +814,32 @@ mod platform { ] ); } + + // Option+任意字母/数字键这类组合键:按住期间的普通键按下只撤销一次, + // 且必须真的按住了触发键。 + #[test] + fn mac_companion_key_down_aborts_trigger_once_per_hold() { + let shared = shared(HotkeyTrigger::LeftOption); + let (ctx, rx) = callback_context(Arc::clone(&shared)); + + // 没按住触发键时的普通打字:与听写无关,不发事件。 + note_companion_key_down(&ctx); + assert!(drain(&rx).is_empty()); + + shared.trigger_held.store(true, Ordering::SeqCst); + // OS 自动重复 / 按住触发键连按多个键,都只撤销一次。 + note_companion_key_down(&ctx); + note_companion_key_down(&ctx); + assert_eq!(drain(&rx), vec![HotkeyEvent::TriggerCombined]); + + // 下一次 Pressed 边沿会重置 latch(handle_flags_changed 里做),下一轮组合键 + // 才能再次撤销 —— 否则第二次组合键会被当成正常听写。 + shared + .trigger_companion_seen + .store(false, Ordering::SeqCst); + note_companion_key_down(&ctx); + assert_eq!(drain(&rx), vec![HotkeyEvent::TriggerCombined]); + } } } @@ -796,6 +874,9 @@ mod platform { const VK_ESCAPE: u32 = 0x1B; const VK_SHIFT: u32 = 0x10; + const VK_CONTROL: u32 = 0x11; + const VK_MENU: u32 = 0x12; + const VK_CAPITAL: u32 = 0x14; const VK_LSHIFT: u32 = 0xA0; const VK_RSHIFT: u32 = 0xA1; const VK_LCONTROL: u32 = 0xA2; @@ -853,6 +934,12 @@ mod platform { reset_shared_held_state(&self.shared); } + fn trigger_combined_since_press(&self) -> bool { + self.shared + .trigger_companion_seen + .load(Ordering::SeqCst) + } + fn shutdown(&self) { unsafe { if let Err(err) = PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0)) @@ -960,6 +1047,10 @@ mod platform { let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN); crate::side_aware_combo::platform::dispatch_vk(vk_code, pressed); + if pressed && !is_modifier_vk(vk_code) { + note_companion_key_down(ctx); + } + // Shift(任一侧)= 翻译模式修饰键。在录音过程中任意时刻按下都生效。详见 issue #4。 if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) { match message { @@ -1011,6 +1102,9 @@ mod platform { WM_KEYDOWN | WM_SYSKEYDOWN => { let was_held = ctx.shared.trigger_held.swap(true, Ordering::SeqCst); if !was_held { + ctx.shared + .trigger_companion_seen + .store(false, Ordering::SeqCst); log::info!("[hotkey] Windows trigger pressed vk={vk_code}"); send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: std::time::Instant::now() }); } @@ -1027,6 +1121,43 @@ mod platform { true } + /// 触发键按住期间按下任意非修饰键 = 用户在打组合键(Alt+Tab / Alt+F4…),不是想 + /// 说话 —— 发一次 TriggerCombined 让上层撤销这次按下。修饰键本身不算「其他键」, + /// 与 macOS 侧(修饰键走 FLAGS_CHANGED、不进 KEY_DOWN)保持同一语义:叠加 Shift + /// (翻译修饰键)或 Ctrl 不会撤销听写。 + fn note_companion_key_down(ctx: &CallbackContext) { + if !ctx.shared.trigger_held.load(Ordering::SeqCst) { + return; + } + if ctx + .shared + .trigger_companion_seen + .swap(true, Ordering::SeqCst) + { + return; + } + log::info!("[hotkey] Windows 触发键与其他键组合按下 —— 撤销本次触发"); + send_or_log(&ctx.tx, HotkeyEvent::TriggerCombined); + } + + fn is_modifier_vk(vk_code: u32) -> bool { + matches!( + vk_code, + VK_SHIFT + | VK_LSHIFT + | VK_RSHIFT + | VK_CONTROL + | VK_LCONTROL + | VK_RCONTROL + | VK_MENU + | VK_LMENU + | VK_RMENU + | VK_LWIN + | VK_RWIN + | VK_CAPITAL + ) + } + fn handle_optional_modifier_trigger( ctx: &CallbackContext, vk_code: u32, @@ -1093,6 +1224,7 @@ mod platform { keys: None, }), trigger_held: AtomicBool::new(false), + trigger_companion_seen: AtomicBool::new(false), qa_trigger: RwLock::new(None), qa_trigger_held: AtomicBool::new(false), translation_trigger: RwLock::new(None), @@ -1229,6 +1361,22 @@ mod platform { assert_eq!(edge_names(drain(&right_alt_rx)), vec!["pressed"]); } + // Alt+任意字母/数字键这类组合键:普通键按下撤销本次触发; + // 修饰键叠加(Shift = 翻译模式)不算。 + #[test] + fn windows_companion_key_down_aborts_trigger_but_modifiers_do_not() { + let shared = shared(HotkeyTrigger::LeftOption); + let (ctx, rx) = callback_context(shared); + + dispatch_keyboard_event(&ctx, VK_LMENU, WM_KEYDOWN); + dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN); + assert!(!drain(&rx).contains(&HotkeyEvent::TriggerCombined)); + + dispatch_keyboard_event(&ctx, 0x41, WM_KEYDOWN); // A + dispatch_keyboard_event(&ctx, 0x41, WM_KEYDOWN); // OS 自动重复 + assert_eq!(drain(&rx), vec![HotkeyEvent::TriggerCombined]); + } + #[test] fn windows_shift_side_combo_receives_pressed_via_dispatch_keyboard_event() { use crate::combo_hotkey::ComboHotkeyEvent; diff --git a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs index ff2b44b8..3c6edee1 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs @@ -12,6 +12,7 @@ pub enum HotkeyEvent { Pressed { at: Instant }, Released { at: Instant }, Cancelled, + TriggerCombined, TranslationModifierPressed, QaShortcutPressed, } @@ -44,6 +45,12 @@ impl HotkeyMonitor { pub fn reset_held_state(&self) {} + /// 移动端没有键盘监听器,永远「没看到叠加的普通键」——组合键仲裁窗口在这里 + /// 恒等于放行(`press_resolves_to_combo` 也拿不到 monitor,双保险)。 + pub fn trigger_combined_since_press(&self) -> bool { + false + } + pub fn capability() -> HotkeyCapability { HotkeyCapability::current() }