From 35491aa0c3b145f00af496fff16d4bb61d514f9b Mon Sep 17 00:00:00 2001 From: jisongniu <529058747@qq.com> Date: Sun, 12 Jul 2026 16:28:59 +0800 Subject: [PATCH 1/5] =?UTF-8?q?feat(hotkey):=20=E5=BD=95=E9=9F=B3=E6=96=B9?= =?UTF-8?q?=E5=BC=8F=E6=96=B0=E5=A2=9E=E3=80=8C=E8=87=AA=E5=8A=A8=E3=80=8D?= =?UTF-8?q?=E2=80=94=E2=80=94=E7=9F=AD=E6=8C=89=3D=E5=88=87=E6=8D=A2?= =?UTF-8?q?=E5=BC=8F=EF=BC=8C=E9=95=BF=E6=8C=89=3D=E6=8C=89=E4=BD=8F?= =?UTF-8?q?=E8=AF=B4=E8=AF=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 在「切换式 / 按住说话」外新增第三种录音方式 Auto:按下即开录, 松手时按按住时长判定语义 —— 短按(< 350ms)锁存为切换式(保持 录音,下次按下再停),长按(>= 350ms)当作按住说话(松手即停)。 已锁存后再次按下即停。 实现要点: - HotkeyMode 新增 Auto 变体(前后端类型同步) - Coordinator 在 handle_pressed/handle_released 解释边沿:按下记录 press_at,松手用 elapsed() 判定短/长按 - 判定在松手处理时测量,会被 begin_session 阻塞 bridge 的时长抬高; 350ms 阈值下仅当冷启动 >= 350ms 时短按可能误判为长按(可恢复), 常见快速启动下判定正确。该权衡避免改动跨平台的 P0 串行化热键路径 - 设置页新增第三个分段选项;标签/使用提示与 5 种语言 i18n 补齐 - 新增 2 个单元测试覆盖短按锁存 / 长按结束 Co-Authored-By: Claude Opus 4.8 --- openless-all/app/src-tauri/src/coordinator.rs | 62 +++++++++++++++++++ .../src-tauri/src/coordinator/dictation.rs | 60 ++++++++++++++++++ openless-all/app/src-tauri/src/types.rs | 3 + openless-all/app/src/i18n/en.ts | 3 + openless-all/app/src/i18n/ja.ts | 3 + openless-all/app/src/i18n/ko.ts | 3 + openless-all/app/src/i18n/zh-CN.ts | 3 + openless-all/app/src/i18n/zh-TW.ts | 3 + openless-all/app/src/lib/hotkey.ts | 56 ++++++++--------- openless-all/app/src/lib/types.ts | 2 +- .../pages/settings/RecordingInputSection.tsx | 1 + 11 files changed, 169 insertions(+), 30 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 99b07d868..649e6f013 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -279,6 +279,9 @@ struct Inner { /// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防 /// press-release-press 三连过快。 last_hotkey_dispatch_at: Mutex>, + /// Auto 模式下这次会话「按下」的时刻。松手时用 elapsed() 判定短按(Toggle 锁存) + /// 还是长按(Hold 松手即停)。见 dictation.rs 的 AUTO_HOLD_THRESHOLD。 + hotkey_press_at: Mutex>, /// end_session 成功收尾后将 phase 设为 Idle 时记录的时间戳 + POST_SESSION_COOLDOWN_MS。 /// handle_pressed 在 (Toggle, Idle) 分支检查此字段:未过期则忽略该次按键, /// 防止胶囊离场动画期间误激活新听写(issue #545)。 @@ -417,6 +420,7 @@ impl Coordinator { hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), last_hotkey_dispatch_at: Mutex::new(None), + hotkey_press_at: Mutex::new(None), session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), @@ -517,6 +521,7 @@ impl Coordinator { hotkey_status: Mutex::new(HotkeyStatus::default()), hotkey_trigger_held: AtomicBool::new(false), last_hotkey_dispatch_at: Mutex::new(None), + hotkey_press_at: Mutex::new(None), session_cooldown_until: Mutex::new(None), shortcut_recording_active: AtomicBool::new(false), combo_hotkey: Mutex::new(None), @@ -2694,6 +2699,63 @@ mod tests { assert!(coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst)); } + fn set_auto_mode(coordinator: &Coordinator) { + coordinator + .inner + .prefs + .set(crate::types::UserPreferences { + hotkey: crate::types::HotkeyBinding { + trigger: HotkeyTrigger::RightControl, + mode: HotkeyMode::Auto, + keys: None, + }, + ..Default::default() + }) + .unwrap(); + } + + // Auto 模式短按:松手时按住时长 < 阈值 → 锁存为切换态,保持 Listening(不结束会话)。 + #[tokio::test] + async fn auto_short_tap_release_latches_recording() { + let coordinator = Coordinator::new(); + set_auto_mode(&coordinator); + coordinator.inner.state.lock().phase = SessionPhase::Listening; + // 刚按下(elapsed ≈ 0 < 350ms)→ 短按。 + *coordinator.inner.hotkey_press_at.lock() = Some(std::time::Instant::now()); + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + + handle_released_edge(&coordinator.inner).await; + + // 短按松手不结束录音,等下一次按下再停。 + assert_eq!( + coordinator.inner.state.lock().phase, + SessionPhase::Listening + ); + } + + // Auto 模式长按:松手时按住时长 >= 阈值 → 按住说话语义,结束会话(Listening → Idle)。 + #[tokio::test] + async fn auto_long_hold_release_ends_session() { + let coordinator = Coordinator::new(); + set_auto_mode(&coordinator); + coordinator.inner.state.lock().phase = SessionPhase::Listening; + // 按住已超过阈值 → 长按。 + *coordinator.inner.hotkey_press_at.lock() = std::time::Instant::now() + .checked_sub(std::time::Duration::from_millis(500)); + coordinator + .inner + .hotkey_trigger_held + .store(true, Ordering::SeqCst); + + handle_released_edge(&coordinator.inner).await; + + // 无 recorder / ASR 的测试会话下,end_session 直接收尾到 Idle。 + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + } + #[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 01384017b..7f50ed144 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -12,6 +12,13 @@ use super::*; /// 同一个 hotkey 边沿之间的最小间隔。低于此阈值的连按整体作为误触丢弃 —— /// 避免微动开关回弹 / 用户手抖双击造成的空转写报错和 ASR session 抢资源。 const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(250); +/// Auto 模式下区分「短按 = 切换式」与「长按 = 按住说话」的按住时长阈值。 +/// 松手时若按住 < 此值判为短按(锁存,保持录音),>= 此值判为长按(松手即停)。 +/// 注意:本时长在 handle_released 处理时用 press_at.elapsed() 测量,会被 begin_session +/// 阻塞 hotkey bridge 的时长 B 抬高(见 hotkey_loops.rs 的串行化注释)。因此当冷启动 +/// 使 B >= 本阈值时,一次真实短按可能被误判为长按(录音提前停止,用户再按一次即可)。 +/// 常见快速启动(B≈100–200ms)下测量正确。350ms 是「点一下 vs 明显按住」的自然分界。 +const AUTO_HOLD_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(350); const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12); #[cfg(target_os = "macos")] @@ -765,6 +772,37 @@ pub(super) async fn handle_pressed(inner: &Arc) { (HotkeyMode::Toggle, SessionPhase::Starting) => { request_stop_during_starting(inner, "toggle stop edge"); } + // Auto 模式:按下即开录(与 Hold 一样不丢首字)。是短按还是长按要到松手时才知道, + // 所以这里只负责「开始」并记下按下时刻,语义交给 handle_released 判定。 + (HotkeyMode::Auto, SessionPhase::Idle) => { + // 复用 Toggle 的冷却 / 排队接力检查:#545 离场动画期间误触保护。 + let now = std::time::Instant::now(); + let cooldown_until = *inner.session_cooldown_until.lock(); + if let Some(deadline) = cooldown_until { + if now < deadline { + if is_queued_chain_press(now, deadline) { + log::info!( + "[coord] queued-chain activation (auto): 识别中按下,会话收尾后接力开录下一条" + ); + } else { + log::info!( + "[coord] auto activation blocked by cooldown (session still winding down)" + ); + return; + } + } + } + *inner.hotkey_press_at.lock() = Some(now); + let _ = begin_session(inner).await; + } + // Auto 模式已因上一次「短按」锁存为切换态,再次按下 → 用户想停。 + (HotkeyMode::Auto, SessionPhase::Listening) => { + let _ = end_session(inner).await; + } + // Auto 模式锁存后仍在 Starting 时第二次按 → 想停,同 Toggle 存边沿。 + (HotkeyMode::Auto, SessionPhase::Starting) => { + request_stop_during_starting(inner, "auto stop edge"); + } _ => {} } } @@ -805,6 +843,28 @@ pub(super) async fn handle_released(inner: &Arc) { _ => {} } } + if mode == HotkeyMode::Auto { + // 按住时长判定短按 / 长按。press_at 为空(理论上不会)时保守当作短按 → 锁存。 + let held_long = (*inner.hotkey_press_at.lock()) + .map(|t| t.elapsed() >= AUTO_HOLD_THRESHOLD) + .unwrap_or(false); + match phase { + // 长按松手 = 按住说话,松手即停;短按 = 切换式,锁存保持录音,下次按下再停。 + SessionPhase::Listening if held_long => { + let _ = end_session(inner).await; + } + // 仍在握手就松手,且判为长按 → 用户按住说话想停,存边沿握手完成后再 end。 + SessionPhase::Starting if held_long => { + request_stop_during_starting(inner, "auto hold release edge"); + } + SessionPhase::Listening | SessionPhase::Starting => { + log::info!( + "[coord] auto short-tap latched (toggle semantics); next press stops" + ); + } + _ => {} + } + } } /// Less Computer 收尾:把转写当作指令交给无头 Claude,结果以胶囊展示(不插入到光标)。 diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index cd0a69ad0..c6830099b 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -2237,6 +2237,9 @@ pub enum HotkeyMode { Toggle, Hold, DoubleClick, + /// 自动识别:按下即开录;松手时按「按住时长」决定语义 —— 短按(< AUTO_HOLD_THRESHOLD) + /// 当作 Toggle(锁存,保持录音,下次按下再停),长按当作 Hold(松手即停)。 + Auto, } #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] diff --git a/openless-all/app/src/i18n/en.ts b/openless-all/app/src/i18n/en.ts index 200bd48f1..80d3f818d 100644 --- a/openless-all/app/src/i18n/en.ts +++ b/openless-all/app/src/i18n/en.ts @@ -677,6 +677,7 @@ export const en: typeof zhCN = { modeDesc: 'Toggle = tap once to start, again to stop. Push-to-talk = hold to record.', modeToggle: 'Toggle', modeHold: 'Push-to-talk', + modeAuto: 'Auto', migrationNoticeTitle: 'Default recording mode is now Toggle', migrationNoticeDesc: 'This update changes the default; if you prefer push-to-talk, switch it back here.', microphoneLabel: 'Preferred microphone', @@ -1171,8 +1172,10 @@ export const en: typeof zhCN = { fallback: 'Global hotkey', modeHoldSuffix: ' (push-to-talk)', modeToggleSuffix: ' (start / stop)', + modeAutoSuffix: ' (auto-detect)', usageHold: 'Hold {{trigger}} to talk, release to stop.', usageToggle: 'Press {{trigger}} to start, press again to stop.', + usageAuto: 'Tap {{trigger}} to start / stop; hold it to talk and release to stop.', adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows low-level keyboard hook', diff --git a/openless-all/app/src/i18n/ja.ts b/openless-all/app/src/i18n/ja.ts index 771421679..35aa47e2e 100644 --- a/openless-all/app/src/i18n/ja.ts +++ b/openless-all/app/src/i18n/ja.ts @@ -679,6 +679,7 @@ export const ja: typeof zhCN = { modeDesc: 'トグル式 = 1 回押して開始、もう 1 回押して終了;押し続けて話す = 押している間だけ録音。', modeToggle: 'トグル式', modeHold: '押し続けて話す', + modeAuto: '自動', migrationNoticeTitle: 'デフォルトがトグル式に変更されました', migrationNoticeDesc: '以前にトリガー方式を変更していた場合は、ここで再度確認してください。今回のアップデートではショートカット方式のデフォルト値と読み込みロジックが変更されています。「押し続けて話す」が好みであれば再度切り替えてください。', microphoneLabel: '優先マイク', @@ -1139,8 +1140,10 @@ export const ja: typeof zhCN = { fallback: 'グローバルショートカット', modeHoldSuffix: '(押し続けて話す)', modeToggleSuffix: '(開始 / 停止)', + modeAutoSuffix: '(自動判別)', usageHold: '{{trigger}} を押し続けて話し、離して終了。', usageToggle: '{{trigger}} で録音開始、もう 1 回押して終了。', + usageAuto: '{{trigger}} を短く押すと開始 / 停止、押し続けると話し終えて離すと停止。', adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低レベルキーボードフック', diff --git a/openless-all/app/src/i18n/ko.ts b/openless-all/app/src/i18n/ko.ts index 699966d40..fed68543f 100644 --- a/openless-all/app/src/i18n/ko.ts +++ b/openless-all/app/src/i18n/ko.ts @@ -679,6 +679,7 @@ export const ko: typeof zhCN = { modeDesc: '토글 방식 = 한 번 누르면 시작, 다시 누르면 종료; 눌러서 말하기 = 누르고 있는 동안만 녹음.', modeToggle: '토글 방식', modeHold: '눌러서 말하기', + modeAuto: '자동', migrationNoticeTitle: '기본값이 토글 방식으로 변경됨', migrationNoticeDesc: '이전에 트리거 방식을 변경했다면 여기서 다시 한 번 확인해 주세요. 이번 업데이트는 단축키 방식의 기본값과 읽기 로직을 조정했습니다. "눌러서 말하기"가 더 익숙하다면 다시 전환할 수 있습니다.', microphoneLabel: '기본 선택 마이크', @@ -1139,8 +1140,10 @@ export const ko: typeof zhCN = { fallback: '전역 단축키', modeHoldSuffix: '(눌러서 말하기)', modeToggleSuffix: '(시작 / 정지)', + modeAutoSuffix: '(자동 인식)', usageHold: '{{trigger}} 를 누르고 말한 후 떼면 종료.', usageToggle: '{{trigger}} 로 녹음 시작, 다시 누르면 종료.', + usageAuto: '{{trigger}} 를 짧게 누르면 시작 / 정지, 길게 누르면 말한 뒤 떼면 종료.', adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 저수준 키보드 후크', diff --git a/openless-all/app/src/i18n/zh-CN.ts b/openless-all/app/src/i18n/zh-CN.ts index 04b5a8df7..411b01ff3 100644 --- a/openless-all/app/src/i18n/zh-CN.ts +++ b/openless-all/app/src/i18n/zh-CN.ts @@ -675,6 +675,7 @@ export const zhCN = { modeDesc: '切换式按一次开始、再按一次结束;按住说话按下保持、松开结束。', modeToggle: '切换式', modeHold: '按住说话', + modeAuto: '自动', migrationNoticeTitle: '默认已改为切换式说话', migrationNoticeDesc: '本次更新调整了默认值,如果习惯按住说话,请在此处切回。', microphoneLabel: '首选麦克风', @@ -1169,8 +1170,10 @@ export const zhCN = { fallback: '全局快捷键', modeHoldSuffix: '(按住说话)', modeToggleSuffix: '(开始 / 停止)', + modeAutoSuffix: '(自动识别)', usageHold: '按住 {{trigger}} 说话,松开结束。', usageToggle: '按 {{trigger}} 开始录音,再按一次结束。', + usageAuto: '短按 {{trigger}} 切换开始 / 停止,按住则说完松开即停。', adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低层键盘 hook', diff --git a/openless-all/app/src/i18n/zh-TW.ts b/openless-all/app/src/i18n/zh-TW.ts index 0e316d295..dde97434a 100644 --- a/openless-all/app/src/i18n/zh-TW.ts +++ b/openless-all/app/src/i18n/zh-TW.ts @@ -677,6 +677,7 @@ export const zhTW: typeof zhCN = { modeDesc: '切換式 = 按一次開始、再按一次結束;按住說話 = 按住開始、鬆開結束。', modeToggle: '切換式', modeHold: '按住說話', + modeAuto: '自動', migrationNoticeTitle: '默認已改爲切換式說話', migrationNoticeDesc: '如果你之前改過快捷鍵觸發方式,請在這裏手動確認一次。本次更新調整了快捷鍵方式的默認值與讀取邏輯;如果你更習慣按住說話,可以重新切回“按住說話”。', comboRecordLabel: '錄製快捷鍵', @@ -1137,8 +1138,10 @@ export const zhTW: typeof zhCN = { fallback: '全局快捷鍵', modeHoldSuffix: '(按住說話)', modeToggleSuffix: '(開始 / 停止)', + modeAutoSuffix: '(自動識別)', usageHold: '按住 {{trigger}} 說話,鬆開結束。', usageToggle: '按 {{trigger}} 開始錄音,再按一次結束。', + usageAuto: '短按 {{trigger}} 切換開始 / 停止,按住則說完鬆開即停。', adapter: { macEventTap: 'macOS Event Tap', windowsLowLevel: 'Windows 低層鍵盤 hook', diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index b749055a0..c7d1b25db 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -1,5 +1,5 @@ import i18n from '../i18n'; -import type { ComboBinding, HotkeyBinding, HotkeyTrigger, QaHotkeyBinding, ShortcutBinding } from './types'; +import type { ComboBinding, HotkeyBinding, HotkeyMode, HotkeyTrigger, QaHotkeyBinding, ShortcutBinding } from './types'; export function defaultQaShortcut(): ShortcutBinding { return { @@ -32,31 +32,39 @@ export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined) return i18n.t(`hotkey.triggers.${trigger}`); } +/** 根据录音方式返回追加在触发键标签后的语义后缀(如「(按住说话)」)。 */ +function hotkeyModeSuffix(mode: HotkeyMode | null | undefined): string { + switch (mode) { + case 'hold': return i18n.t('hotkey.modeHoldSuffix'); + case 'auto': return i18n.t('hotkey.modeAutoSuffix'); + case 'doubleClick': return i18n.t('hotkey.modeDoubleClickSuffix'); + default: return i18n.t('hotkey.modeToggleSuffix'); + } +} + +/** 根据录音方式返回使用说明(含触发键占位符 trigger)。 */ +function hotkeyModeUsage(mode: HotkeyMode | null | undefined, trigger: string): string { + switch (mode) { + case 'hold': return i18n.t('hotkey.usageHold', { trigger }); + case 'auto': return i18n.t('hotkey.usageAuto', { trigger }); + case 'doubleClick': return i18n.t('hotkey.usageDoubleClick', { trigger }); + default: return i18n.t('hotkey.usageToggle', { trigger }); + } +} + export function getHotkeyStartStopLabel( binding: HotkeyBinding | null | undefined, comboBinding?: ComboBinding | null, shortcutBinding?: ShortcutBinding | null, ): string { if (shortcutBinding) { - const suffix = binding?.mode === 'hold' - ? i18n.t('hotkey.modeHoldSuffix') - : i18n.t('hotkey.modeToggleSuffix'); - return `${formatComboLabel(shortcutBinding)}${suffix}`; + return `${formatComboLabel(shortcutBinding)}${hotkeyModeSuffix(binding?.mode)}`; } if (binding?.trigger === 'custom' && comboBinding) { - const combo = formatComboLabel(comboBinding); - const suffix = binding.mode === 'hold' - ? i18n.t('hotkey.modeHoldSuffix') - : i18n.t('hotkey.modeToggleSuffix'); - return `${combo}${suffix}`; + return `${formatComboLabel(comboBinding)}${hotkeyModeSuffix(binding.mode)}`; } const trigger = getHotkeyTriggerLabel(binding?.trigger); - const suffix = binding?.mode === 'hold' - ? i18n.t('hotkey.modeHoldSuffix') - : binding?.mode === 'doubleClick' - ? i18n.t('hotkey.modeDoubleClickSuffix') - : i18n.t('hotkey.modeToggleSuffix'); - return `${trigger}${suffix}`; + return `${trigger}${hotkeyModeSuffix(binding?.mode)}`; } export function getHotkeyUsageHint( @@ -65,23 +73,13 @@ export function getHotkeyUsageHint( shortcutBinding?: ShortcutBinding | null, ): string { if (shortcutBinding) { - const combo = formatComboLabel(shortcutBinding); - return binding?.mode === 'hold' - ? i18n.t('hotkey.usageHold', { trigger: combo }) - : i18n.t('hotkey.usageToggle', { trigger: combo }); + return hotkeyModeUsage(binding?.mode, formatComboLabel(shortcutBinding)); } if (binding?.trigger === 'custom' && comboBinding) { - const combo = formatComboLabel(comboBinding); - return binding.mode === 'hold' - ? i18n.t('hotkey.usageHold', { trigger: combo }) - : i18n.t('hotkey.usageToggle', { trigger: combo }); + return hotkeyModeUsage(binding.mode, formatComboLabel(comboBinding)); } const trigger = getHotkeyTriggerLabel(binding?.trigger); - return binding?.mode === 'hold' - ? i18n.t('hotkey.usageHold', { trigger }) - : binding?.mode === 'doubleClick' - ? i18n.t('hotkey.usageDoubleClick', { trigger }) - : i18n.t('hotkey.usageToggle', { trigger }); + return hotkeyModeUsage(binding?.mode, trigger); } export function getHotkeyBindingCodes(binding: HotkeyBinding | null | undefined): string[] { diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index d9d0007d1..1ca73ceb0 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -95,7 +95,7 @@ export type HotkeyTrigger = | 'mediaPlayPause' | 'custom'; -export type HotkeyMode = 'toggle' | 'hold' | 'doubleClick'; +export type HotkeyMode = 'toggle' | 'hold' | 'doubleClick' | 'auto'; export interface HotkeyKey { code: string; diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index e6281eb1f..3dfe4b8ce 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -169,6 +169,7 @@ export function RecordingInputSection() { const choices: Array<[HotkeyMode, string]> = [ ['toggle', t('settings.recording.modeToggle')], ['hold', t('settings.recording.modeHold')], + ['auto', t('settings.recording.modeAuto')], ]; const preferredMicrophoneAvailable = Boolean( prefs.microphoneDeviceName From 0ed29c247250a4cefcc32b2302c8b1b1d728fb9d Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 12 Jul 2026 18:38:39 +0800 Subject: [PATCH 2/5] fix(hotkey): preserve auto press duration across queueing --- .../app/src-tauri/src/combo_hotkey.rs | 14 +++-- openless-all/app/src-tauri/src/coordinator.rs | 44 ++++++++++--- .../src-tauri/src/coordinator/dictation.rs | 26 ++++---- .../src-tauri/src/coordinator/hotkey_loops.rs | 32 +++++----- openless-all/app/src-tauri/src/hotkey.rs | 44 +++++++------ openless-all/app/src-tauri/src/linux_fcitx.rs | 7 +-- .../app/src-tauri/src/mobile_stubs/hotkey.rs | 5 +- .../app/src-tauri/src/side_aware_combo.rs | 61 ++++++------------- 8 files changed, 119 insertions(+), 114 deletions(-) diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs index cc3c39778..0e1d0772c 100644 --- a/openless-all/app/src-tauri/src/combo_hotkey.rs +++ b/openless-all/app/src-tauri/src/combo_hotkey.rs @@ -11,6 +11,7 @@ use std::sync::mpsc::{Receiver, Sender}; use std::sync::Arc; +use std::time::Instant; use global_hotkey::{GlobalHotKeyEvent, HotKeyState}; use parking_lot::Mutex; @@ -22,9 +23,9 @@ use crate::types::ShortcutBinding; #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum ComboHotkeyEvent { /// 用户按下了配置的组合键。 - Pressed, + Pressed { at: Instant }, /// 用户松开了配置的组合键(用于 Hold 模式结束录音)。 - Released, + Released { at: Instant }, } #[derive(Debug, thiserror::Error)] @@ -129,9 +130,10 @@ fn forward_loop(hotkey_id: u32, rx: Receiver, tx: Sender ComboHotkeyEvent::Pressed, - HotKeyState::Released => ComboHotkeyEvent::Released, + HotKeyState::Pressed => ComboHotkeyEvent::Pressed { at }, + HotKeyState::Released => ComboHotkeyEvent::Released { at }, }; if let Err(e) = tx.send(combo_event) { log::warn!("[combo-hotkey] 事件投递失败: {e}"); @@ -263,8 +265,8 @@ mod tests { forward_loop(8, event_rx, out_tx); - assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Released)); - assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Pressed)); + assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Released { .. })); + assert!(matches!(out_rx.recv().unwrap(), ComboHotkeyEvent::Pressed { .. })); assert!(out_rx.try_recv().is_err()); } } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 649e6f013..b7203414b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1489,8 +1489,8 @@ impl Coordinator { #[cfg(any(debug_assertions, test))] pub async fn inject_hotkey_click_for_dev(&self) -> Result<(), String> { log::info!("[coord] dev hotkey injection started"); - handle_pressed(&self.inner).await; - handle_released(&self.inner).await; + handle_pressed(&self.inner, std::time::Instant::now()).await; + handle_released(&self.inner, std::time::Instant::now()).await; cancel_session(&self.inner); Ok(()) } @@ -2662,7 +2662,7 @@ mod tests { state.session_id = session_id(41); } - handle_pressed_edge(&coordinator.inner).await; + handle_pressed_edge(&coordinator.inner, std::time::Instant::now()).await; let state = coordinator.inner.state.lock(); assert_eq!(state.phase, SessionPhase::Inserting); @@ -2690,7 +2690,7 @@ mod tests { .hotkey_trigger_held .store(true, Ordering::SeqCst); - handle_pressed_edge(&coordinator.inner).await; + handle_pressed_edge(&coordinator.inner, std::time::Instant::now()).await; assert_eq!( coordinator.inner.state.lock().phase, @@ -2721,13 +2721,14 @@ mod tests { set_auto_mode(&coordinator); coordinator.inner.state.lock().phase = SessionPhase::Listening; // 刚按下(elapsed ≈ 0 < 350ms)→ 短按。 - *coordinator.inner.hotkey_press_at.lock() = Some(std::time::Instant::now()); + 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); - handle_released_edge(&coordinator.inner).await; + handle_released_edge(&coordinator.inner, pressed_at + std::time::Duration::from_millis(100)).await; // 短按松手不结束录音,等下一次按下再停。 assert_eq!( @@ -2736,6 +2737,30 @@ mod tests { ); } + #[tokio::test] + async fn auto_short_tap_stays_latched_when_bridge_handles_release_late() { + 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); + + // 模拟上一条会话阻塞 bridge:处理发生在物理松手很久之后。 + tokio::time::sleep(std::time::Duration::from_millis(400)).await; + handle_released_edge( + &coordinator.inner, + pressed_at + std::time::Duration::from_millis(100), + ) + .await; + + assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Listening); + assert!(coordinator.inner.hotkey_press_at.lock().is_none()); + } + // Auto 模式长按:松手时按住时长 >= 阈值 → 按住说话语义,结束会话(Listening → Idle)。 #[tokio::test] async fn auto_long_hold_release_ends_session() { @@ -2743,17 +2768,18 @@ mod tests { set_auto_mode(&coordinator); coordinator.inner.state.lock().phase = SessionPhase::Listening; // 按住已超过阈值 → 长按。 - *coordinator.inner.hotkey_press_at.lock() = std::time::Instant::now() - .checked_sub(std::time::Duration::from_millis(500)); + 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); - handle_released_edge(&coordinator.inner).await; + handle_released_edge(&coordinator.inner, pressed_at + std::time::Duration::from_millis(500)).await; // 无 recorder / ASR 的测试会话下,end_session 直接收尾到 Idle。 assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle); + assert!(coordinator.inner.hotkey_press_at.lock().is_none()); } #[test] diff --git a/openless-all/app/src-tauri/src/coordinator/dictation.rs b/openless-all/app/src-tauri/src/coordinator/dictation.rs index 7f50ed144..fc13c0448 100644 --- a/openless-all/app/src-tauri/src/coordinator/dictation.rs +++ b/openless-all/app/src-tauri/src/coordinator/dictation.rs @@ -14,10 +14,8 @@ use super::*; const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(250); /// Auto 模式下区分「短按 = 切换式」与「长按 = 按住说话」的按住时长阈值。 /// 松手时若按住 < 此值判为短按(锁存,保持录音),>= 此值判为长按(松手即停)。 -/// 注意:本时长在 handle_released 处理时用 press_at.elapsed() 测量,会被 begin_session -/// 阻塞 hotkey bridge 的时长 B 抬高(见 hotkey_loops.rs 的串行化注释)。因此当冷启动 -/// 使 B >= 本阈值时,一次真实短按可能被误判为长按(录音提前停止,用户再按一次即可)。 -/// 常见快速启动(B≈100–200ms)下测量正确。350ms 是「点一下 vs 明显按住」的自然分界。 +/// 时长以热键事件产生时携带的时间戳计算,避免串行 bridge 的排队延迟改变用户的物理按住时长。 +/// 350ms 是「点一下 vs 明显按住」的自然分界。 const AUTO_HOLD_THRESHOLD: std::time::Duration = std::time::Duration::from_millis(350); const STREAMING_INSERT_FLUSH_INTERVAL: std::time::Duration = std::time::Duration::from_millis(12); @@ -674,7 +672,7 @@ fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option) { +pub(super) async fn handle_pressed_edge(inner: &Arc, pressed_at: std::time::Instant) { let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst); if !was_held { // 防抖:相邻 < HOTKEY_DEBOUNCE 的边沿直接丢弃,记到 log 方便排查。 @@ -708,7 +706,7 @@ pub(super) async fn handle_pressed_edge(inner: &Arc) { if panel_visible && !dictation_active { handle_qa_option_edge(inner).await; } else { - handle_pressed(inner).await; + handle_pressed(inner, pressed_at).await; } } } @@ -733,7 +731,7 @@ fn is_queued_chain_press(now: std::time::Instant, cooldown_until: std::time::Ins .unwrap_or(false) } -pub(super) async fn handle_pressed(inner: &Arc) { +pub(super) async fn handle_pressed(inner: &Arc, pressed_at: std::time::Instant) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; log::info!("[coord] hotkey pressed (mode={mode:?}, phase={phase:?})"); @@ -792,7 +790,7 @@ pub(super) async fn handle_pressed(inner: &Arc) { } } } - *inner.hotkey_press_at.lock() = Some(now); + *inner.hotkey_press_at.lock() = Some(pressed_at); let _ = begin_session(inner).await; } // Auto 模式已因上一次「短按」锁存为切换态,再次按下 → 用户想停。 @@ -807,7 +805,7 @@ pub(super) async fn handle_pressed(inner: &Arc) { } } -pub(super) async fn handle_released_edge(inner: &Arc) { +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 { // QA 浮窗可见时,Option 行为是 press-toggle(不分 hold/release),release 边沿忽略。 @@ -819,11 +817,11 @@ pub(super) async fn handle_released_edge(inner: &Arc) { if panel_visible && !dictation_active { return; } - handle_released(inner).await; + handle_released(inner, released_at).await; } } -pub(super) async fn handle_released(inner: &Arc) { +pub(super) async fn handle_released(inner: &Arc, released_at: std::time::Instant) { let mode = inner.prefs.get().hotkey.mode; let phase = inner.state.lock().phase; log::info!("[coord] hotkey released (mode={mode:?}, phase={phase:?})"); @@ -844,9 +842,9 @@ pub(super) async fn handle_released(inner: &Arc) { } } if mode == HotkeyMode::Auto { - // 按住时长判定短按 / 长按。press_at 为空(理论上不会)时保守当作短按 → 锁存。 - let held_long = (*inner.hotkey_press_at.lock()) - .map(|t| t.elapsed() >= AUTO_HOLD_THRESHOLD) + // 使用物理按下/松开的事件时刻,避免 bridge 排队时把处理延迟误算为按住时长。 + let held_long = inner.hotkey_press_at.lock().take() + .map(|pressed_at| released_at.saturating_duration_since(pressed_at) >= AUTO_HOLD_THRESHOLD) .unwrap_or(false); match phase { // 长按松手 = 按住说话,松手即停;短按 = 切换式,锁存保持录音,下次按下再停。 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 be8267941..9cca912cb 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -352,12 +352,12 @@ pub(super) fn less_computer_modifier_bridge_loop(inner: Arc, rx: mpsc::Re } let inner_cloned = Arc::clone(&inner); match evt { - HotkeyEvent::Pressed => { + HotkeyEvent::Pressed { .. } => { async_runtime::block_on(async { handle_less_computer_pressed(&inner_cloned).await }); } - HotkeyEvent::Released => { + HotkeyEvent::Released { .. } => { async_runtime::block_on(async { handle_less_computer_released(&inner_cloned).await }); @@ -375,12 +375,12 @@ pub(super) fn less_computer_combo_bridge_loop(inner: Arc, rx: mpsc::Recei } let inner_cloned = Arc::clone(&inner); match evt { - ComboHotkeyEvent::Pressed => { + ComboHotkeyEvent::Pressed { .. } => { async_runtime::block_on(async { handle_less_computer_pressed(&inner_cloned).await }); } - ComboHotkeyEvent::Released => { + ComboHotkeyEvent::Released { .. } => { async_runtime::block_on(async { handle_less_computer_released(&inner_cloned).await }); @@ -601,14 +601,14 @@ pub(super) fn combo_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { + ComboHotkeyEvent::Pressed { at } => { async_runtime::block_on(async { - handle_pressed_edge(&inner_cloned).await; + handle_pressed_edge(&inner_cloned, at).await; }); } - ComboHotkeyEvent::Released => { + ComboHotkeyEvent::Released { at } => { async_runtime::block_on(async { - handle_released_edge(&inner_cloned).await; + handle_released_edge(&inner_cloned, at).await; }); } } @@ -711,7 +711,7 @@ pub(super) fn translation_hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiv if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } - if matches!(evt, ComboHotkeyEvent::Pressed) { + if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { mark_translation_modifier_seen(&inner); } } @@ -807,7 +807,7 @@ pub(super) fn action_hotkey_bridge_loop( if inner.shortcut_recording_active.load(Ordering::SeqCst) { continue; } - if matches!(evt, ComboHotkeyEvent::Pressed) { + if matches!(evt, ComboHotkeyEvent::Pressed { .. }) { handle_action_hotkey_pressed(&inner, kind); } } @@ -1026,14 +1026,14 @@ pub(super) fn hotkey_bridge_loop(inner: Arc, rx: mpsc::Receiver { + HotkeyEvent::Pressed { at } => { async_runtime::block_on(async { - handle_pressed_edge(&inner_cloned).await; + handle_pressed_edge(&inner_cloned, at).await; }); } - HotkeyEvent::Released => { + HotkeyEvent::Released { at } => { async_runtime::block_on(async { - handle_released_edge(&inner_cloned).await; + handle_released_edge(&inner_cloned, at).await; }); } HotkeyEvent::Cancelled => { @@ -1161,11 +1161,11 @@ pub(super) async fn handle_window_hotkey_event( log::info!( "[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}" ); - handle_pressed_edge(inner).await; + handle_pressed_edge(inner, std::time::Instant::now()).await; } "keyup" => { log::info!("[window-hotkey] released trigger={trigger:?} code={code}"); - handle_released_edge(inner).await; + handle_released_edge(inner, std::time::Instant::now()).await; } _ => {} } diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index b686f19e1..c25a44471 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -12,7 +12,7 @@ use std::sync::atomic::AtomicBool; use std::sync::mpsc::{self, Sender}; use std::sync::Arc; -use std::time::Duration; +use std::time::{Duration, Instant}; use parking_lot::RwLock; @@ -21,8 +21,8 @@ use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyIns #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HotkeyEvent { - Pressed, - Released, + Pressed { at: Instant }, + Released { at: Instant }, Cancelled, /// Shift(或未来配置项指定的修饰键)按下边沿。可在录音过程中任何时刻产生; /// 上层据此切换到翻译输出管线。详见 issue #4。 @@ -598,10 +598,10 @@ mod platform { if is_active && !was_held { ctx.shared.trigger_held.store(true, Ordering::SeqCst); - send_or_log(&ctx.tx, HotkeyEvent::Pressed); + send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: Instant::now() }); } else if !is_active && was_held { ctx.shared.trigger_held.store(false, Ordering::SeqCst); - send_or_log(&ctx.tx, HotkeyEvent::Released); + send_or_log(&ctx.tx, HotkeyEvent::Released { at: Instant::now() }); } } @@ -708,6 +708,14 @@ mod platform { rx.try_iter().collect() } + fn edge_names(events: Vec) -> Vec<&'static str> { + events.into_iter().filter_map(|event| match event { + HotkeyEvent::Pressed { .. } => Some("pressed"), + HotkeyEvent::Released { .. } => Some("released"), + _ => None, + }).collect() + } + #[test] fn mac_optional_modifier_edges_are_deduped_from_mock_flags() { let shared = shared(HotkeyTrigger::RightControl); @@ -1004,14 +1012,14 @@ mod platform { 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); + send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: Instant::now() }); } } 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); + send_or_log(&ctx.tx, HotkeyEvent::Released { at: Instant::now() }); } } _ => {} @@ -1120,8 +1128,8 @@ mod platform { assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYUP)); assert_eq!( - drain(&rx), - vec![HotkeyEvent::Pressed, HotkeyEvent::Released] + edge_names(drain(&rx)), + vec!["pressed", "released"] ); } @@ -1137,12 +1145,8 @@ mod platform { assert!(dispatch_keyboard_event(&ctx, VK_RCONTROL, WM_KEYDOWN)); assert_eq!( - drain(&rx), - vec![ - HotkeyEvent::Pressed, - HotkeyEvent::Released, - HotkeyEvent::Pressed - ] + edge_names(drain(&rx)), + vec!["pressed", "released", "pressed"] ); } @@ -1181,8 +1185,8 @@ mod platform { assert!(dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYDOWN)); assert!(dispatch_keyboard_event(&left_ctx, VK_LMENU, WM_KEYUP)); assert_eq!( - drain(&left_rx), - vec![HotkeyEvent::Pressed, HotkeyEvent::Released] + edge_names(drain(&left_rx)), + vec!["pressed", "released"] ); let right_option_shared = shared(HotkeyTrigger::RightOption); @@ -1197,7 +1201,7 @@ mod platform { VK_RMENU, WM_KEYDOWN )); - assert_eq!(drain(&right_option_rx), vec![HotkeyEvent::Pressed]); + assert_eq!(edge_names(drain(&right_option_rx)), vec!["pressed"]); let right_alt_shared = shared(HotkeyTrigger::RightAlt); let (right_alt_ctx, right_alt_rx) = callback_context(right_alt_shared); @@ -1211,7 +1215,7 @@ mod platform { VK_RMENU, WM_KEYDOWN )); - assert_eq!(drain(&right_alt_rx), vec![HotkeyEvent::Pressed]); + assert_eq!(edge_names(drain(&right_alt_rx)), vec!["pressed"]); } #[test] @@ -1233,7 +1237,7 @@ mod platform { dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN); dispatch_keyboard_event(&ctx, 0x44, WM_KEYDOWN); - assert_eq!(combo_rx.recv().unwrap(), ComboHotkeyEvent::Pressed); + assert!(matches!(combo_rx.recv().unwrap(), ComboHotkeyEvent::Pressed { .. })); assert!( hotkey_rx .try_iter() diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index 73430ab91..f487bab5f 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -317,11 +317,8 @@ pub fn start_dictation_signal_listener( ); if let Some(member) = member { if member == "DictationKeyEvent" { - let event = if is_press { - crate::hotkey::HotkeyEvent::Pressed - } else { - crate::hotkey::HotkeyEvent::Released - }; + let at = std::time::Instant::now(); + let event = if is_press { crate::hotkey::HotkeyEvent::Pressed { at } } else { crate::hotkey::HotkeyEvent::Released { at } }; let _ = tx.send(event); } else if member == "QaShortcutEvent" { if is_press { 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 59c6c7e76..ff2b44b88 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/hotkey.rs @@ -1,6 +1,7 @@ //! Mobile stub — global hotkeys are unavailable on Android/iOS. use std::sync::mpsc::Sender; +use std::time::Instant; use crate::types::{ HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError, HotkeyTrigger, @@ -8,8 +9,8 @@ use crate::types::{ #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum HotkeyEvent { - Pressed, - Released, + Pressed { at: Instant }, + Released { at: Instant }, Cancelled, TranslationModifierPressed, QaShortcutPressed, diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index f6ed065aa..1b67945cf 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -5,6 +5,7 @@ use std::sync::mpsc::Sender; use std::sync::{OnceLock, RwLock}; +use std::time::Instant; use parking_lot::Mutex; @@ -118,7 +119,7 @@ impl SideAwareComboState { // already reflecting an active combo and swallow this edge. if !self.combo_active { self.combo_active = true; - return Some(ComboHotkeyEvent::Pressed); + return Some(ComboHotkeyEvent::Pressed { at: Instant::now() }); } return None; } @@ -128,14 +129,14 @@ impl SideAwareComboState { // the terminal `Released` now so the recording latch cannot stick. if self.combo_active { self.combo_active = false; - return Some(ComboHotkeyEvent::Released); + return Some(ComboHotkeyEvent::Released { at: Instant::now() }); } return None; } // Primary key up is the absolute termination signal for the combo. if self.combo_active { self.combo_active = false; - return Some(ComboHotkeyEvent::Released); + return Some(ComboHotkeyEvent::Released { at: Instant::now() }); } None } @@ -146,7 +147,7 @@ impl SideAwareComboState { // once the required side-modifier set is broken, the combo is over. if self.combo_active && !self.modifiers_match() { self.combo_active = false; - return Some(ComboHotkeyEvent::Released); + return Some(ComboHotkeyEvent::Released { at: Instant::now() }); } None } @@ -567,7 +568,7 @@ mod tests { state.set_side(SideModifier::CmdLeft, true); assert!(state.modifiers_match()); let evt = state.on_primary("D", true); - assert_eq!(evt, Some(ComboHotkeyEvent::Pressed)); + assert!(matches!(evt, Some(ComboHotkeyEvent::Pressed { .. }))); } #[test] @@ -581,10 +582,7 @@ mod tests { state.set_side(SideModifier::ShiftLeft, false); state.set_side(SideModifier::ShiftRight, true); assert!(state.modifiers_match()); - assert_eq!( - state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed) - ); + assert!(matches!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed { .. }))); } #[test] @@ -620,10 +618,7 @@ mod tests { }); state.set_side(SideModifier::CmdLeft, true); assert!(state.modifiers_match()); - assert_eq!( - state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed) - ); + assert!(matches!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed { .. }))); } #[test] @@ -661,12 +656,9 @@ mod tests { fn normal_press_then_release_is_paired() { let mut state = cmd_left_d_state(); state.set_side(SideModifier::CmdLeft, true); - assert_eq!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed)); + assert!(matches!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed { .. }))); // Primary key up terminates the combo with exactly one Released. - assert_eq!( - state.on_primary("D", false), - Some(ComboHotkeyEvent::Released) - ); + assert!(matches!(state.on_primary("D", false), Some(ComboHotkeyEvent::Released { .. }))); // No trailing events; a second key-up must not emit anything. assert_eq!(state.on_primary("D", false), None); assert!(!state.combo_active); @@ -676,12 +668,9 @@ mod tests { fn modifier_release_after_press_emits_paired_released() { let mut state = cmd_left_d_state(); state.set_side(SideModifier::CmdLeft, true); - assert_eq!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed)); + assert!(matches!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed { .. }))); // Modifier lifts while primary is still down -> combo terminates once. - assert_eq!( - state.on_modifier_release(SideModifier::CmdLeft), - Some(ComboHotkeyEvent::Released) - ); + assert!(matches!(state.on_modifier_release(SideModifier::CmdLeft), Some(ComboHotkeyEvent::Released { .. }))); assert!(!state.combo_active); // A now-orphaned primary key-up must NOT emit a second Released. assert_eq!(state.on_primary("D", false), None); @@ -694,13 +683,10 @@ mod tests { // key-up (absolute termination) must still emit the paired Released. let mut state = cmd_left_d_state(); state.set_side(SideModifier::CmdLeft, true); - assert_eq!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed)); + assert!(matches!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed { .. }))); // Modifier physically released but the release event never arrived, so the // side flag is still set here. Primary up is the fallback terminator. - assert_eq!( - state.on_primary("D", false), - Some(ComboHotkeyEvent::Released) - ); + assert!(matches!(state.on_primary("D", false), Some(ComboHotkeyEvent::Released { .. }))); assert!(!state.combo_active); } @@ -714,7 +700,7 @@ mod tests { state.combo_active = true; // stale flag from a dropped Released assert!(!state.modifiers_match()); // cmd-left is not held let evt = state.on_primary("D", true); - assert_eq!(evt, Some(ComboHotkeyEvent::Released)); + assert!(matches!(evt, Some(ComboHotkeyEvent::Released { .. }))); assert!(!state.combo_active); } @@ -729,19 +715,13 @@ mod tests { // Releasing the required side-modifier breaks the match, so the stale latch // self-heals by emitting the terminal Released here (pairing the Pressed whose // Released was dropped). Either way combo_active must end up cleared. - assert_eq!( - state.on_modifier_release(SideModifier::CmdLeft), - Some(ComboHotkeyEvent::Released) - ); + assert!(matches!(state.on_modifier_release(SideModifier::CmdLeft), Some(ComboHotkeyEvent::Released { .. }))); assert!(!state.combo_active); // Fresh, clean press cycle now behaves normally. state.set_side(SideModifier::CmdLeft, true); - assert_eq!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed)); - assert_eq!( - state.on_primary("D", false), - Some(ComboHotkeyEvent::Released) - ); + assert!(matches!(state.on_primary("D", true), Some(ComboHotkeyEvent::Pressed { .. }))); + assert!(matches!(state.on_primary("D", false), Some(ComboHotkeyEvent::Released { .. }))); } #[test] @@ -755,10 +735,7 @@ mod tests { assert!(state.modifiers_match()); assert_eq!(state.on_primary("D", true), None); // The real terminator (primary up) still yields exactly one Released. - assert_eq!( - state.on_primary("D", false), - Some(ComboHotkeyEvent::Released) - ); + assert!(matches!(state.on_primary("D", false), Some(ComboHotkeyEvent::Released { .. }))); } // ---- Fix 2: macOS race-free FLAGS_CHANGED side classification ---- From ffd422c60599549d6d0d8570819f6a5f55f4891c Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 12 Jul 2026 18:51:08 +0800 Subject: [PATCH 3/5] fix(hotkey): compile timestamped edges on all targets --- openless-all/app/src-tauri/src/hotkey.rs | 8 ++++---- .../app/src-tauri/src/mobile_stubs/combo_hotkey.rs | 5 +++-- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index c25a44471..929083432 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -598,10 +598,10 @@ mod platform { if is_active && !was_held { ctx.shared.trigger_held.store(true, Ordering::SeqCst); - send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: Instant::now() }); + 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); - send_or_log(&ctx.tx, HotkeyEvent::Released { at: Instant::now() }); + send_or_log(&ctx.tx, HotkeyEvent::Released { at: std::time::Instant::now() }); } } @@ -1012,14 +1012,14 @@ mod platform { 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 { at: Instant::now() }); + send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: std::time::Instant::now() }); } } 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 { at: Instant::now() }); + send_or_log(&ctx.tx, HotkeyEvent::Released { at: std::time::Instant::now() }); } } _ => {} diff --git a/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs b/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs index 3a7907efa..4b1c3d75e 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/combo_hotkey.rs @@ -1,13 +1,14 @@ //! Mobile stub — combo hotkeys are unavailable on Android/iOS. use std::sync::mpsc::Sender; +use std::time::Instant; use crate::types::ShortcutBinding; #[derive(Clone, Copy, Debug, PartialEq, Eq)] pub enum ComboHotkeyEvent { - Pressed, - Released, + Pressed { at: Instant }, + Released { at: Instant }, } #[derive(Debug, thiserror::Error)] From 42c67c6761f926685168c10b269771d9e86318f8 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 12 Jul 2026 19:01:45 +0800 Subject: [PATCH 4/5] test(hotkey): add Windows edge assertion helper --- openless-all/app/src-tauri/src/hotkey.rs | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 929083432..abc66fb9a 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -1117,6 +1117,17 @@ mod platform { rx.try_iter().collect() } + fn edge_names(events: Vec) -> Vec<&'static str> { + events + .into_iter() + .filter_map(|event| match event { + HotkeyEvent::Pressed { .. } => Some("pressed"), + HotkeyEvent::Released { .. } => Some("released"), + _ => None, + }) + .collect() + } + #[test] fn windows_modifier_edges_are_deduped_from_mock_hook_events() { let shared = shared(HotkeyTrigger::RightControl); From e06e93ec827b1436019b6817bd727224175a4b53 Mon Sep 17 00:00:00 2001 From: Chris233 Date: Sun, 12 Jul 2026 19:43:54 +0800 Subject: [PATCH 5/5] fix(hotkey): show auto mode in shortcuts settings --- openless-all/app/src-tauri/src/coordinator.rs | 5 +++-- openless-all/app/src/lib/hotkey.ts | 2 +- .../app/src/pages/settings/ShortcutsSection.tsx | 10 ++++++++-- 3 files changed, 12 insertions(+), 5 deletions(-) diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index b7203414b..25d4b2330 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -279,8 +279,9 @@ struct Inner { /// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防 /// press-release-press 三连过快。 last_hotkey_dispatch_at: Mutex>, - /// Auto 模式下这次会话「按下」的时刻。松手时用 elapsed() 判定短按(Toggle 锁存) - /// 还是长按(Hold 松手即停)。见 dictation.rs 的 AUTO_HOLD_THRESHOLD。 + /// Auto 模式下这次会话「按下」的事件时刻。松手时用按下/松开的事件时间戳差值 + /// 判定短按(Toggle 锁存)还是长按(Hold 松手即停)。见 dictation.rs 的 + /// AUTO_HOLD_THRESHOLD。 hotkey_press_at: Mutex>, /// end_session 成功收尾后将 phase 设为 Idle 时记录的时间戳 + POST_SESSION_COOLDOWN_MS。 /// handle_pressed 在 (Toggle, Idle) 分支检查此字段:未过期则忽略该次按键, diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index c7d1b25db..8f5ec75ce 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -33,7 +33,7 @@ export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined) } /** 根据录音方式返回追加在触发键标签后的语义后缀(如「(按住说话)」)。 */ -function hotkeyModeSuffix(mode: HotkeyMode | null | undefined): string { +export function hotkeyModeSuffix(mode: HotkeyMode | null | undefined): string { switch (mode) { case 'hold': return i18n.t('hotkey.modeHoldSuffix'); case 'auto': return i18n.t('hotkey.modeAutoSuffix'); diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index 063d14b4d..b57d9cde7 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -4,7 +4,13 @@ import { useEffect, useState } from 'react'; import type { CSSProperties } from 'react'; import { useTranslation } from 'react-i18next'; import { ShortcutRecorder } from '../../components/ShortcutRecorder'; -import { defaultLessComputerShortcut, defaultOpenAppShortcut, defaultQaShortcut, defaultSwitchStyleShortcut } from '../../lib/hotkey'; +import { + defaultLessComputerShortcut, + defaultOpenAppShortcut, + defaultQaShortcut, + defaultSwitchStyleShortcut, + hotkeyModeSuffix, +} from '../../lib/hotkey'; import { setDictationHotkey, setOpenAppHotkey, @@ -74,7 +80,7 @@ export function ShortcutsSection() { }} />
- {hotkey.mode === 'hold' ? t('hotkey.modeHoldSuffix') : t('hotkey.modeToggleSuffix')} + {hotkeyModeSuffix(hotkey.mode)}