From 096b1df5b9a7154383b9054a42bb0476d89ede4f Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Fri, 24 Jul 2026 17:23:12 +0800 Subject: [PATCH 1/3] =?UTF-8?q?fix(hotkey):=20=E4=BF=AE=E9=A5=B0=E9=94=AE?= =?UTF-8?q?=E7=83=AD=E9=94=AE=E8=A2=AB=E5=BD=93=E7=BB=84=E5=90=88=E9=94=AE?= =?UTF-8?q?=E7=94=A8=E6=97=B6=E4=B8=8D=E5=86=8D=E5=94=A4=E8=B5=B7=E5=90=AC?= =?UTF-8?q?=E5=86=99?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit modifier-only 触发键(如 Option)按住期间只要按下普通键,就说明用户在打 Option+任意字母/数字键这类组合键、不是想说话。此前 tap/hook 只看修饰键自身的边 沿:按下即 Pressed → 开录音,Auto 模式下快速松手还会被判成「短按锁存」,录音一直 开着停不下来。 - hotkey.rs:macOS CGEventTap / Windows WH_KEYBOARD_LL 在触发键按住期间收到普通 键 KEY_DOWN 时发一次新的 TriggerCombined 边沿(每次按住只发一次;修饰键叠加不 算,Shift 翻译修饰键行为不变),之后仍照常发 Released。 - coordinator:TriggerCombined 清掉按住态(随后的 Released 被 was_held 吞掉,不 再走 Hold 松手 / Auto 短按锁存),并只取消「这一次按下开出来的」会话 —— toggle 停止 / 被冷却拦下 / 路由给 QA 的按下什么都不动,绝不误杀正在转写的上一 条。同时清掉冷却与防抖时间戳,紧接着那次真想说话的按下不会被静默吞掉。 - Less Computer 的 modifier 触发键走同一条撤销路径(顺带熄灭整屏描边)。 --- openless-all/app/src-tauri/src/coordinator.rs | 68 +++++++++- .../src-tauri/src/coordinator/dictation.rs | 48 ++++++- .../src-tauri/src/coordinator/hotkey_loops.rs | 22 ++++ openless-all/app/src-tauri/src/hotkey.rs | 124 ++++++++++++++++++ 4 files changed, 258 insertions(+), 4 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 868f8c97c..02e5a7aa5 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 f396df076..2ed8560a0 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -735,6 +735,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 +803,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 +837,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 +851,42 @@ 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) { + inner + .hotkey_press_began_session + .store(true, Ordering::SeqCst); + let _ = begin_session(inner).await; +} + +/// 触发键(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 { 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 9cca912cb..08116f351 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 abc66fb9a..f110dc299 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), @@ -114,6 +119,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>, @@ -206,6 +214,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), @@ -598,6 +607,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 +645,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 +716,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 +798,31 @@ 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 +857,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; @@ -960,6 +1024,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 +1079,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 +1098,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 +1201,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 +1338,21 @@ 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; From 9fa9f63444b58afe51daab94bb8f4ee01db850c1 Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Fri, 24 Jul 2026 17:55:32 +0800 Subject: [PATCH 2/3] =?UTF-8?q?feat(hotkey):=20=E4=BF=AE=E9=A5=B0=E9=94=AE?= =?UTF-8?q?=E7=83=AD=E9=94=AE=E5=8A=A0=20150ms=20=E7=BB=84=E5=90=88?= =?UTF-8?q?=E9=94=AE=E4=BB=B2=E8=A3=81=E7=AA=97=E5=8F=A3=E2=80=94=E2=80=94?= =?UTF-8?q?=E7=BB=84=E5=90=88=E9=94=AE=E4=B8=8D=E5=86=8D=E9=97=AA=E4=B8=80?= =?UTF-8?q?=E4=B8=8B=E8=83=B6=E5=9B=8A?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 上一提交是「事后撤销」:Option+任意字母/数字键仍会真的开麦、弹胶囊、烧一次 ASR 建连,然后被取消——功能对了,观感上闪一下。 改为按下后先等 150ms 再开会话,期间监听器若已报告叠加了普通键,这次按下整条作废: 麦克风不开、胶囊不弹。等待只加在 modifier-only 触发键的「开录」分支上: - 自定义组合键(Cmd+Shift+D)本身没有歧义,不等; - toggle 停止 / QA 路由 / 防抖与冷却判定都在窗口之前,时序不变(尤其不挤占 is_queued_chain_press 的 120ms 排队接力窗口); - 窗口没盖住的慢速组合键(按住 Option 半秒再按 Tab)仍由 TriggerCombined 事后撤销 兜底,两条路径互补。 150ms 覆盖绝大多数组合键的「修饰键→普通键」间隔,又低于人从按键到开口的反应时间, 不吃首字。 --- .../src-tauri/src/coordinator/dictation.rs | 87 +++++++++++++++++++ openless-all/app/src-tauri/src/hotkey.rs | 28 +++++- 2 files changed, 113 insertions(+), 2 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 2ed8560a0..2d3fea569 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")] @@ -853,13 +863,47 @@ 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+任意字母/数字键这类组合键, /// 不是想说话。撤销这次按下: /// @@ -3772,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/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index f110dc299..06d1db185 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -112,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) {} } @@ -169,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() } @@ -359,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() 保证幂等。 @@ -799,7 +815,8 @@ mod platform { ); } - // Option+任意字母/数字键这类组合键:按住期间的普通键按下只撤销一次,且必须真的按住了触发键。 + // Option+任意字母/数字键这类组合键:按住期间的普通键按下只撤销一次, + // 且必须真的按住了触发键。 #[test] fn mac_companion_key_down_aborts_trigger_once_per_hold() { let shared = shared(HotkeyTrigger::LeftOption); @@ -917,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)) @@ -1338,7 +1361,8 @@ mod platform { assert_eq!(edge_names(drain(&right_alt_rx)), vec!["pressed"]); } - // Alt+任意字母/数字键这类组合键:普通键按下撤销本次触发;修饰键叠加(Shift = 翻译模式)不算。 + // Alt+任意字母/数字键这类组合键:普通键按下撤销本次触发; + // 修饰键叠加(Shift = 翻译模式)不算。 #[test] fn windows_companion_key_down_aborts_trigger_but_modifiers_do_not() { let shared = shared(HotkeyTrigger::LeftOption); From 195d9bc9855627db6378ad137c0c39818659484b Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Fri, 24 Jul 2026 20:35:22 +0800 Subject: [PATCH 3/3] =?UTF-8?q?fix(hotkey):=20=E8=A1=A5=E4=B8=8A=E7=A7=BB?= =?UTF-8?q?=E5=8A=A8=E7=AB=AF=20hotkey=20stub=20=E7=9A=84=20TriggerCombine?= =?UTF-8?q?d=20/=20trigger=5Fcombined=5Fsince=5Fpress?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Android CI(cargo check --target android)红:`mobile_stubs/hotkey.rs` 是移动端的 HotkeyEvent / HotkeyMonitor 替身,桌面端新增的枚举变体与方法必须同步补上,否则 coordinator 里那两条 match 臂和仲裁窗口的调用在移动端找不到符号。 移动端没有键盘监听器,`trigger_combined_since_press` 恒为 false(仲裁窗口等价于 直接放行)。 --- openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs | 7 +++++++ 1 file changed, 7 insertions(+) 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 ff2b44b88..3c6edee1d 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() }