fix(hotkey): 修饰键热键被当组合键用时不再误唤起听写(150ms 仲裁 + 即时撤销,胶囊不再赖着) (#858)

* fix(hotkey): 修饰键热键被当组合键用时不再唤起听写

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 触发键走同一条撤销路径(顺带熄灭整屏描边)。

* feat(hotkey): 修饰键热键加 150ms 组合键仲裁窗口——组合键不再闪一下胶囊

上一提交是「事后撤销」:Option+任意字母/数字键仍会真的开麦、弹胶囊、烧一次 ASR
建连,然后被取消——功能对了,观感上闪一下。

改为按下后先等 150ms 再开会话,期间监听器若已报告叠加了普通键,这次按下整条作废:
麦克风不开、胶囊不弹。等待只加在 modifier-only 触发键的「开录」分支上:

- 自定义组合键(Cmd+Shift+D)本身没有歧义,不等;
- toggle 停止 / QA 路由 / 防抖与冷却判定都在窗口之前,时序不变(尤其不挤占
  is_queued_chain_press 的 120ms 排队接力窗口);
- 窗口没盖住的慢速组合键(按住 Option 半秒再按 Tab)仍由 TriggerCombined 事后撤销
  兜底,两条路径互补。

150ms 覆盖绝大多数组合键的「修饰键→普通键」间隔,又低于人从按键到开口的反应时间,
不吃首字。

* fix(hotkey): 补上移动端 hotkey stub 的 TriggerCombined / trigger_combined_since_press

Android CI(cargo check --target android)红:`mobile_stubs/hotkey.rs` 是移动端的
HotkeyEvent / HotkeyMonitor 替身,桌面端新增的枚举变体与方法必须同步补上,否则
coordinator 里那两条 match 臂和仲裁窗口的调用在移动端找不到符号。

移动端没有键盘监听器,`trigger_combined_since_press` 恒为 false(仲裁窗口等价于
直接放行)。

* fix(hotkey): 组合键撤销改走独立通道——按下第二个键的那一帧胶囊就消失

150ms 仲裁窗口只盖住了「修饰键→普通键」间隔够快的组合键;慢一点的(实测日志里
press→combined 中位数 428ms)仍然走事后撤销兜底,本该只是「闪一下」,实际却晚了
几百毫秒才收——观感上像录音真的起来了。

原因不是手速,是排队:TriggerCombined 和 Pressed/Released 挤同一条 channel,而
bridge 为修 #468/#475 的 latch 竞态改成了串行 block_on —— 这次按下自己的
begin_session(开麦 + ASR 握手)正卡在 bridge 线程上,撤销只能在队列里等它跑完。
日志里那个 428ms 因此是上界,真实键间隔被 begin_session 的耗时撑大了。

修法同 #853(Esc 取消):把撤销从 HotkeyEvent 枚举拆出,tap/hook 回调改发独立的
Sender<()>,由专用 combo-abort-bridge 线程消费。macOS CGEventTap 与 Windows
WH_KEYBOARD_LL 同步修改;Less Computer 的修饰键 monitor 走同一条通道,只是 handler
换成 cancel_less_computer_press。

并发面两处:
- 撤销与 Released 之间不再有先后保证。改为只看 hotkey_press_began_session
  (每个 Pressed 边沿都会重置)判断要不要撤销,不再看 hotkey_trigger_held ——
  否则 Released 抢先跑完清了按住态,撤销会认不出这条会话是自己开的、放着不管,
  Auto 模式下就是一条停不下来的录音。清 hotkey_trigger_held 只为吞掉后面的
  Released,与撤销与否无关。加了回归测试覆盖这个倒序。
- 撤销落在 begin_session 还在 await 的中途,由既有的
  startup_race_status_for_starting / CancelRaced 检查点接住(audit HIGH #1),
  与 Esc 取消同一条路径。

至此两条路径分工:仲裁窗口内的快组合键胶囊根本不出现;窗口没盖住的慢组合键胶囊
冒个头,按下第二个键即刻消失。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(hotkey): 取消先收胶囊再拆麦克风 + watchdog 碎觉——撤销不再拖着胶囊一秒

上一提交把撤销决策做到了「按下第二个键的那一帧」(tap → handle_trigger_combined
实测 0.14ms),但真机日志显示胶囊仍然晚 0.8~1 秒才消失。11 次组合键测试分成干净的
两组:这次按下已经把 recorder 开起来的 5 次是 769/900/931/965/986ms,还没开起来的
6 次是 0ms。

链路:cancel_session 先 stop_recorder_for_session 才 emit_capsule。
Recorder::stop() 要 join 音频线程,音频线程退出前要 join liveness watchdog,
watchdog 睡在 1000ms 的检查间隔里 —— 所以停采要等它当前那一觉睡完,胶囊排在后面。
按 Esc 取消走同一个函数,同样拖一秒。

两处一起改(缺一不可,见下):

- coordinator/dictation.rs:cancel_session 把 UI 收尾(finish_cancel_session_state
  → emit_capsule → schedule_capsule_idle → 熄描边)整块提到三个拆资源调用之前。
  emit_capsule 仍排在 finish_cancel_session_state 之后 —— 它要读 state.voice_agent /
  phase 拼 payload,再提前会发出「还在进行中」的那一帧。拆资源都按 session_id 取,
  不依赖 phase,挪到后面语义不变。
- recorder.rs:watchdog 的一个检查间隔切成 50ms 的碎觉来睡,每觉醒来重看 stop_flag。
  停采的等待从最坏 1000ms 降到 50ms。判据(回调静默 3s / 首帧 5s)用的是真实时间差
  而非醒来次数,灵敏度不变;代价是录音期间线程多醒几次,醒来只读一个时间戳。

为什么必须成对:UI 先收之后,胶囊消失到麦克风真正释放之间有一段窗口,其间旧
recorder 还占着设备。`Recorder` 没有 Drop 停采,recorder 槽被下一条会话覆盖后旧音频
线程会继续跑、抓着麦克风不放。只做第一处会把这段窗口留在 ~1 秒,紧接着那次真想说话
的按下(最快也要 250ms 防抖 + 150ms 仲裁)就可能撞上;watchdog 切碎后窗口降到几十
毫秒,够不着。

副作用:正常说完话松手结束(end_session 也走 Recorder::stop())同样少等将近一秒。
可观察的代价:胶囊消失后系统菜单栏的录音小圆点会多亮几十毫秒。

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>

* fix(hotkey): resolve modifier combo abort after beta merge

* test(hotkey): initialize mac companion press id

* fix(hotkey): clone Linux fcitx combo channel

---------

Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Co-authored-by: Chris233 <h-chris233@outlook.com>
This commit is contained in:
authored and GitHub committed 2026-08-01 18:01:11 +08:00
1 parent 52e6ef46fc
commit 93c6f6f448
8 files changed
+978 -53

No files matched your search

+165 -5
View File
@@ -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,14 @@ struct Inner {
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
hotkey_trigger_held: AtomicBool,
/// 当前主听写热键按下的代次。组合键撤销通道使用同一代次,避免迟到事件
/// 误取消下一次按下开启的会话。
hotkey_press_generation: AtomicU64,
/// 当前代次是否真的开出了会话;0 表示没有可撤销的会话。
hotkey_press_began_session: AtomicU64,
/// 组合键事件可能先于 Pressed 事件抵达协调器,暂存其代次供仲裁窗口消费。
/// 用队列而不是单个槽,避免主 bridge 忙于上一轮仲裁时覆盖连续按下的事件。
hotkey_combo_pending_presses: Mutex<std::collections::VecDeque<u64>>,
/// 防抖时间戳:handle_pressed_edge 入口检查与本字段的距离,< 250ms 的边沿直接
/// 丢弃(误触双击 / 微动开关回弹 / 用户连点过快造成的空转写报错)。
/// 与 `hotkey_trigger_held` 互补 —— held 防 press-without-release,本字段防
@@ -490,6 +498,9 @@ struct Inner {
/// 防止胶囊离场动画期间误激活新听写(issue #545)。
session_cooldown_until: Mutex<Option<std::time::Instant>>,
shortcut_recording_active: AtomicBool,
/// Less Computer modifier 热键的按下代次与待处理组合键事件。
less_computer_press_generation: AtomicU64,
less_computer_combo_pending_press: AtomicU64,
/// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
@@ -672,10 +683,15 @@ impl Coordinator {
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
hotkey_press_generation: AtomicU64::new(0),
hotkey_press_began_session: AtomicU64::new(0),
hotkey_combo_pending_presses: Mutex::new(std::collections::VecDeque::new()),
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),
less_computer_press_generation: AtomicU64::new(0),
less_computer_combo_pending_press: AtomicU64::new(0),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_hotkey: Mutex::new(None),
@@ -774,10 +790,15 @@ impl Coordinator {
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
hotkey_press_generation: AtomicU64::new(0),
hotkey_press_began_session: AtomicU64::new(0),
hotkey_combo_pending_presses: Mutex::new(std::collections::VecDeque::new()),
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),
less_computer_press_generation: AtomicU64::new(0),
less_computer_combo_pending_press: AtomicU64::new(0),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_hotkey: Mutex::new(None),
@@ -1444,7 +1465,10 @@ impl Coordinator {
#[cfg(target_os = "linux")]
let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone());
let cancel_tx = spawn_esc_cancel_bridge(&self.inner);
match HotkeyMonitor::start(binding, tx, cancel_tx) {
let combo_tx = spawn_combo_abort_bridge(&self.inner, handle_trigger_combined);
#[cfg(target_os = "linux")]
let combo_tx_for_fcitx = combo_tx.clone();
match HotkeyMonitor::start(binding, tx, cancel_tx, combo_tx) {
Ok(monitor) => {
let adapter = monitor.kind();
*self.inner.hotkey.lock() = Some(monitor);
@@ -1466,6 +1490,7 @@ impl Coordinator {
let custom_key = custom_dictation_key_string(&self.inner);
crate::linux_fcitx::start_dictation_signal_listener(
fcitx_tx,
combo_tx_for_fcitx,
fcitx_binding.clone(),
qa_trigger,
translation_trigger,
@@ -1766,7 +1791,7 @@ 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, std::time::Instant::now()).await;
handle_pressed(&self.inner, std::time::Instant::now(), 0).await;
handle_released(&self.inner, std::time::Instant::now()).await;
cancel_session(&self.inner);
Ok(())
@@ -3459,7 +3484,7 @@ mod tests {
state.session_id = session_id(41);
}
handle_pressed_edge(&coordinator.inner, std::time::Instant::now()).await;
handle_pressed_edge(&coordinator.inner, std::time::Instant::now(), 1).await;
let state = coordinator.inner.state.lock();
assert_eq!(state.phase, SessionPhase::Inserting);
@@ -3487,7 +3512,7 @@ mod tests {
.hotkey_trigger_held
.store(true, Ordering::SeqCst);
handle_pressed_edge(&coordinator.inner, std::time::Instant::now()).await;
handle_pressed_edge(&coordinator.inner, std::time::Instant::now(), 1).await;
assert_eq!(
coordinator.inner.state.lock().phase,
@@ -3579,6 +3604,141 @@ 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_generation
.store(1, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(1, Ordering::SeqCst);
handle_trigger_combined(&coordinator.inner, 1);
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_generation
.store(1, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(0, Ordering::SeqCst);
handle_trigger_combined(&coordinator.inner, 1);
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
// 组合键撤销通道独立于 Released;若正常松手已经把会话收尾到 Idle,迟到的撤销
// 不能清掉正常会话的冷却/防抖,否则下一次三连按会绕过 #545 的保护。
#[tokio::test]
async fn late_trigger_combined_does_not_clear_completed_session_guards() {
let coordinator = Coordinator::new();
set_auto_mode(&coordinator);
let now = std::time::Instant::now();
*coordinator.inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_secs(1));
*coordinator.inner.last_hotkey_dispatch_at.lock() = Some(now);
coordinator
.inner
.hotkey_press_generation
.store(1, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(1, Ordering::SeqCst);
handle_trigger_combined(&coordinator.inner, 1);
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
assert!(coordinator.inner.session_cooldown_until.lock().is_some());
assert!(coordinator.inner.last_hotkey_dispatch_at.lock().is_some());
}
// 撤销走独立线程后,它与 Pressed/Released 那条串行 bridge 之间没有先后保证。
// 万一 Released 抢先跑完(把按住态清了、Auto 还锁存成了切换态),撤销仍然必须认出
// 这条会话是自己那次按下开的并取消掉 —— 否则组合键会留下一条停不下来的录音,
// 正是本 PR 要修的老毛病换个形式复发。
#[tokio::test]
async fn trigger_combined_still_cancels_when_released_edge_wins_the_race() {
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_generation
.store(1, Ordering::SeqCst);
coordinator
.inner
.hotkey_press_began_session
.store(1, Ordering::SeqCst);
// 先跑 Released(短按 → Auto 锁存成切换态,录音继续),撤销后到。
handle_released_edge(
&coordinator.inner,
pressed_at + std::time::Duration::from_millis(80),
)
.await;
assert_eq!(
coordinator.inner.state.lock().phase,
SessionPhase::Listening
);
handle_trigger_combined(&coordinator.inner, 1);
assert_eq!(coordinator.inner.state.lock().phase, SessionPhase::Idle);
assert!(coordinator.inner.session_cooldown_until.lock().is_none());
}
#[test]
fn enabling_shortcut_recording_clears_dictation_hold_latch() {
let coordinator = Coordinator::new();
@@ -14,11 +14,22 @@ use super::*;
/// 同一个 hotkey 边沿之间的最小间隔。低于此阈值的连按整体作为误触丢弃 ——
/// 避免微动开关回弹 / 用户手抖双击造成的空转写报错和 ASR session 抢资源。
const HOTKEY_DEBOUNCE: std::time::Duration = std::time::Duration::from_millis(250);
const MAX_PENDING_COMBO_PRESSES: usize = 64;
/// Auto 模式下区分「短按 = 切换式」与「长按 = 按住说话」的按住时长阈值。
/// 松手时若按住 < 此值判为短按(锁存,保持录音),>= 此值判为长按(松手即停)。
/// 时长以热键事件产生时携带的时间戳计算,避免串行 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)由组合键撤销
/// 事后撤销兜底,见 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")]
@@ -712,9 +723,22 @@ fn default_done_message(status: InsertStatus, polish_failed: bool) -> Option<Str
}
}
pub(super) async fn handle_pressed_edge(inner: &Arc<Inner>, pressed_at: std::time::Instant) {
pub(super) async fn handle_pressed_edge(
inner: &Arc<Inner>,
pressed_at: std::time::Instant,
press_id: u64,
) {
let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst);
if !was_held {
// 先切换代次并清掉上一轮的会话标记,再做防抖。被防抖丢弃的按下也必须
// 让后续组合键撤销事件归属于自己,不能继承上一轮的 true。
inner
.hotkey_press_generation
.store(press_id, Ordering::SeqCst);
inner
.hotkey_press_began_session
.store(0, Ordering::SeqCst);
// 防抖:相邻 < HOTKEY_DEBOUNCE 的边沿直接丢弃,记到 log 方便排查。
// 与 `hotkey_trigger_held` 互补:held 防 press-without-release,本检查防
// press-release-press 三连过快。每个有效边沿都会更新时间戳。
@@ -746,7 +770,7 @@ pub(super) async fn handle_pressed_edge(inner: &Arc<Inner>, pressed_at: std::tim
if panel_visible && !dictation_active {
handle_qa_option_edge(inner).await;
} else {
handle_pressed(inner, pressed_at).await;
handle_pressed(inner, pressed_at, press_id).await;
}
}
}
@@ -771,7 +795,11 @@ 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<Inner>, pressed_at: std::time::Instant) {
pub(super) async fn handle_pressed(
inner: &Arc<Inner>,
pressed_at: std::time::Instant,
press_id: u64,
) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
log::info!("[coord] hotkey pressed (mode={mode:?}, phase={phase:?})");
@@ -797,13 +825,13 @@ pub(super) async fn handle_pressed(inner: &Arc<Inner>, pressed_at: std::time::In
}
}
}
let _ = begin_session(inner).await;
begin_session_from_press(inner, press_id).await;
}
(HotkeyMode::Toggle, SessionPhase::Listening) => {
let _ = end_session(inner).await;
}
(HotkeyMode::Hold, SessionPhase::Idle) => {
let _ = begin_session(inner).await;
begin_session_from_press(inner, press_id).await;
}
// Toggle 模式 Starting 阶段第二次按 → 用户想停。
// 不能直接 end_session(ASR session 还没建好),存边沿,握手完成后立即触发。
@@ -831,7 +859,7 @@ pub(super) async fn handle_pressed(inner: &Arc<Inner>, pressed_at: std::time::In
}
}
*inner.hotkey_press_at.lock() = Some(pressed_at);
let _ = begin_session(inner).await;
begin_session_from_press(inner, press_id).await;
}
// Auto 模式已因上一次「短按」锁存为切换态,再次按下 → 用户想停。
(HotkeyMode::Auto, SessionPhase::Listening) => {
@@ -845,6 +873,175 @@ pub(super) async fn handle_pressed(inner: &Arc<Inner>, pressed_at: std::time::In
}
}
/// 由「这一次热键按下」开一条会话,并记下这个事实。组合键撤销只撤销带着这个
/// 标记的会话(见 handle_trigger_combined)。
///
/// 开录之前先过一遍组合键仲裁窗口:命中就当这次按下没发生过——不开麦、不弹胶囊。
async fn begin_session_from_press(inner: &Arc<Inner>, press_id: u64) {
if press_resolves_to_combo(inner, press_id).await {
// 按住态一并清掉:随后必然到来的 Released 会被 handle_released_edge 的
// was_held 检查吞掉,不会走 Auto 短按锁存。
inner.hotkey_trigger_held.store(false, Ordering::SeqCst);
*inner.hotkey_press_at.lock() = None;
*inner.last_hotkey_dispatch_at.lock() = None;
return;
}
inner
.hotkey_press_began_session
.store(press_id, Ordering::SeqCst);
// 组合键事件可能刚好在仲裁窗口结束、但在上面的标记写入前抵达;再检查一次,
// 避免这种窄竞态把已判定为组合键的按下开成会话。
if combo_seen_for_press(inner, press_id) {
inner
.hotkey_press_began_session
.compare_exchange(press_id, 0, Ordering::SeqCst, Ordering::SeqCst)
.ok();
inner.hotkey_trigger_held.store(false, Ordering::SeqCst);
*inner.hotkey_press_at.lock() = None;
*inner.last_hotkey_dispatch_at.lock() = None;
return;
}
let _ = begin_session(inner).await;
// 组合键撤销走独立通道,可能恰好在上面的仲裁检查之后、会话启动之前抵达。
// 这种情况下撤销线程会留下 pending 标记,但在 phase=Idle 时无法取消;启动完成后
// 必须再消费一次,否则这次组合键会把会话误启动出来。
if inner.hotkey_press_generation.load(Ordering::SeqCst) == press_id
&& combo_seen_for_press(inner, press_id)
{
inner.hotkey_trigger_held.store(false, Ordering::SeqCst);
*inner.hotkey_press_at.lock() = None;
*inner.last_hotkey_dispatch_at.lock() = None;
inner
.hotkey_press_began_session
.compare_exchange(press_id, 0, Ordering::SeqCst, Ordering::SeqCst)
.ok();
cancel_combined_session_if_active(inner);
return;
}
if inner.hotkey_press_generation.load(Ordering::SeqCst) == press_id
&& inner.state.lock().phase == SessionPhase::Idle
{
inner
.hotkey_press_began_session
.compare_exchange(press_id, 0, Ordering::SeqCst, Ordering::SeqCst)
.ok();
}
}
/// 组合键仲裁:等 COMBO_ARBITRATION_GRACE,再问监听器这次按住有没有叠加普通键。
///
/// 只对 modifier-only 触发键等待 —— 自定义组合键(Cmd+Shift+D 之类)本身就没有歧义,
/// 让它白等这一下纯粹是掉延迟。等待放在防抖 / 冷却判定之后,那些判定用的仍是未被本
/// 窗口推迟的时刻(尤其别把「排队接力」窗口挤掉,见 is_queued_chain_press)。
async fn press_resolves_to_combo(inner: &Arc<Inner>, press_id: u64) -> 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 = combo_seen_for_press(inner, press_id);
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 防抖静默吞掉,用户以为热键坏了。
///
/// 本函数跑在 `combo_abort_bridge_loop` 的独立线程上,与 Pressed/Released 那条串行
/// bridge 并发 —— 这正是它能在按下 Q 的那一帧就撤掉胶囊的原因,但也意味着不能再假定
/// 「Released 一定排在自己后面」。所以撤不撤销只看 `hotkey_press_began_session`
/// (每个 Pressed 边沿都会重置它,见 handle_pressed_edge),不看 `hotkey_trigger_held`:
/// 万一 Released 抢先跑完把按住态清了,撤销仍然认得出这条会话是自己那次按下开的。
/// 清 `hotkey_trigger_held` 只为吞掉后面的 Released,与撤销与否无关。
///
/// 另一个并发面是撤销落在 `begin_session` 还在 await 的中途 —— 由 begin_session 里
/// 既有的 `startup_race_status_for_starting` / `CancelRaced` 检查点接住(audit HIGH #1),
/// 与 Esc 取消同一条路径。
fn combo_seen_for_press(inner: &Arc<Inner>, press_id: u64) -> bool {
// 自定义组合键和窗口回退路径没有 modifier-only 监听器,使用 0 表示没有代次。
// pending 的初始值也是 0,不能让 compare_exchange(0, 0) 把每次自定义组合键误判为
// 已发生组合撤销。
if press_id == 0 {
return false;
}
let pending = {
let mut pending_presses = inner.hotkey_combo_pending_presses.lock();
pending_presses
.iter()
.position(|pending_press| *pending_press == press_id)
.and_then(|index| pending_presses.remove(index))
.is_some()
};
let monitor_seen = inner
.hotkey
.lock()
.as_ref()
.is_some_and(|monitor| monitor.trigger_combined_since_press(press_id));
pending || monitor_seen
}
pub(super) fn handle_trigger_combined(inner: &Arc<Inner>, press_id: u64) {
if press_id == 0 {
return;
}
// 先记下代次:combo 事件可能早于 Pressed 事件被协调器线程取出,仲裁窗口会
// 在稍后消费这个待处理标记。若当前已进入下一代,则只记录旧事件,不能清掉
// 新按下的 held 状态。
{
let mut pending_presses = inner.hotkey_combo_pending_presses.lock();
if !pending_presses.contains(&press_id) {
pending_presses.push_back(press_id);
if pending_presses.len() > MAX_PENDING_COMBO_PRESSES {
pending_presses.pop_front();
}
}
}
if inner.hotkey_press_generation.load(Ordering::SeqCst) != press_id {
log::debug!("[coord] ignore stale combined hotkey press_id={press_id}");
return;
}
inner.hotkey_trigger_held.store(false, Ordering::SeqCst);
*inner.hotkey_press_at.lock() = None;
let began_session = inner
.hotkey_press_began_session
.compare_exchange(press_id, 0, Ordering::SeqCst, Ordering::SeqCst)
.is_ok();
if !began_session {
log::info!("[coord] hotkey combined with another key (本次按下没开出会话,无需撤销)");
return;
}
log::info!("[coord] hotkey combined with another key —— 取消本次按下开出的会话");
cancel_combined_session_if_active(inner);
}
/// 只取消仍处于可取消阶段的本次会话。
///
/// 组合键通道独立于 Pressed/Released,事件可能在正常松手收尾、phase 已回到 Idle 后才被
/// 消费。此时不能清掉正常会话留下的冷却和防抖时间戳,否则会重新打开 #545 的三连按窗口。
/// 若会话尚未进入可取消阶段,pending 标记由 `begin_session_from_press` 的收尾检查消费,
/// 防止「撤销先到、开录后到」的竞态。
fn cancel_combined_session_if_active(inner: &Arc<Inner>) {
if !cancel_session(inner) {
return;
}
*inner.session_cooldown_until.lock() = None;
*inner.last_hotkey_dispatch_at.lock() = None;
}
pub(super) async fn handle_released_edge(inner: &Arc<Inner>, released_at: std::time::Instant) {
let was_held = inner.hotkey_trigger_held.swap(false, Ordering::SeqCst);
if was_held {
@@ -3646,7 +3843,7 @@ pub(super) fn dictation_error_code(
}
}
pub(super) fn cancel_session(inner: &Arc<Inner>) {
pub(super) fn cancel_session(inner: &Arc<Inner>) -> bool {
let Some(decision) = ({
let mut state = inner.state.lock();
let phase = state.phase;
@@ -3656,12 +3853,23 @@ pub(super) fn cancel_session(inner: &Arc<Inner>) {
}
decision
}) else {
return;
return false;
};
stop_recorder_for_session(inner, decision.session_id);
cancel_asr_for_session(inner, decision.session_id);
restore_prepared_windows_ime_session(inner, decision.session_id);
// 顺序要紧:先把 UI 收干净,再去拆麦克风 / ASR。
//
// 反过来(原来的顺序)会让胶囊等在 `stop_recorder_for_session` 后面 ——
// `Recorder::stop()` 要 join 音频线程,而音频线程退出前要 join liveness watchdog,
// watchdog 又睡在自己的检查间隔里,实测撤销到胶囊消失能差 0.8~1 秒。用户按 Option+Q
// 或按 Esc 的观感就是「明明已经取消了,胶囊还赖着」。拆资源不需要 UI 等它,反正
// 这段时间录到的音频整条会话都要丢。
//
// 代价:胶囊消失后麦克风还会多开一小会儿(系统菜单栏的录音小圆点晚灭)。这段窗口
// 必须足够短 —— 否则紧接着那次真想说话的按下会在旧 recorder 还占着麦克风时
// build_input_stream,而 `Recorder` 没有 Drop 停采,recorder 槽被新会话覆盖后旧音频
// 线程会继续跑、抓着麦克风不放。所以 watchdog 的检查间隔必须是碎的(见
// recorder.rs 的 WATCHDOG_*),把这段窗口压到几十毫秒;两处改动是一对,不能只留一个。
//
// Processing 阶段保持 phase=Processing 让 end_session 自己走完检查 + 收尾;
// 其他阶段直接转 Idle。
if decision.phase != SessionPhase::Processing {
@@ -3672,6 +3880,8 @@ pub(super) fn cancel_session(inner: &Arc<Inner>) {
*inner.session_cooldown_until.lock() =
Some(now + std::time::Duration::from_millis(POST_SESSION_COOLDOWN_MS));
}
// emit_capsule 仍然排在 finish_cancel_session_state 之后:它要读 state.voice_agent /
// phase 拼 payload,提到前面会发出「还在进行中」的那一帧。
emit_capsule(inner, CapsuleState::Cancelled, 0.0, 0, None, None);
log::info!("[coord] session cancelled (was {:?})", decision.phase);
schedule_capsule_idle(inner, CAPSULE_CANCEL_HIDE_DELAY_MS);
@@ -3679,6 +3889,11 @@ pub(super) fn cancel_session(inner: &Arc<Inner>) {
if let Some(app) = inner.app.lock().clone() {
crate::hide_less_computer_glow(&app);
}
stop_recorder_for_session(inner, decision.session_id);
cancel_asr_for_session(inner, decision.session_id);
restore_prepared_windows_ime_session(inner, decision.session_id);
true
}
fn append_typed_prefix(target: &mut String, delta: &str, typed_chars: usize) -> usize {
@@ -3765,6 +3980,90 @@ mod tests {
);
}
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, 1).await);
assert!(started.elapsed() >= super::COMBO_ARBITRATION_GRACE);
}
#[tokio::test]
async fn arbitration_combo_does_not_consume_debounce_window() {
let coordinator = coordinator_with_dictation_hotkey(crate::types::ShortcutBinding {
primary: "LeftOption".into(),
modifiers: vec![],
});
coordinator
.inner
.hotkey_press_generation
.store(1, std::sync::atomic::Ordering::SeqCst);
coordinator
.inner
.hotkey_combo_pending_presses
.lock()
.push_back(1);
*coordinator.inner.last_hotkey_dispatch_at.lock() = Some(std::time::Instant::now());
super::begin_session_from_press(&coordinator.inner, 1).await;
assert!(coordinator.inner.last_hotkey_dispatch_at.lock().is_none());
assert_eq!(
coordinator.inner.state.lock().phase,
crate::coordinator_state::SessionPhase::Idle
);
}
// 自定义组合键(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, 1).await);
assert!(!super::combo_seen_for_press(&coordinator.inner, 0));
assert!(started.elapsed() < super::COMBO_ARBITRATION_GRACE);
}
#[test]
fn pending_combo_queue_preserves_multiple_press_ids() {
let coordinator = super::super::Coordinator::new();
coordinator
.inner
.hotkey_combo_pending_presses
.lock()
.extend([11, 12]);
assert!(super::combo_seen_for_press(&coordinator.inner, 11));
assert!(super::combo_seen_for_press(&coordinator.inner, 12));
assert!(!super::combo_seen_for_press(&coordinator.inner, 11));
}
#[test]
fn silent_retry_replaces_initial_asr_attribution() {
let mut label = Some(super::AsrCallLabel::new(
@@ -25,6 +25,21 @@ pub(super) fn esc_cancel_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<()>)
}
}
/// 组合键撤销专用消费线程。撤销事件携带触发键按下代次,避免独立通道的迟到事件
/// 误取消下一次按下开启的会话。
pub(super) fn combo_abort_bridge_loop(
inner: Arc<Inner>,
rx: mpsc::Receiver<u64>,
handler: fn(&Arc<Inner>, u64),
) {
while let Ok(press_id) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
continue;
}
handler(&inner, press_id);
}
}
pub(super) fn spawn_esc_cancel_bridge(inner: &Arc<Inner>) -> mpsc::Sender<()> {
let (cancel_tx, cancel_rx) = mpsc::channel::<()>();
let bridge_inner = Arc::clone(inner);
@@ -39,6 +54,19 @@ pub(super) fn spawn_esc_cancel_bridge(inner: &Arc<Inner>) -> mpsc::Sender<()> {
cancel_tx
}
pub(super) fn spawn_combo_abort_bridge(
inner: &Arc<Inner>,
handler: fn(&Arc<Inner>, u64),
) -> mpsc::Sender<u64> {
let (combo_tx, combo_rx) = mpsc::channel::<u64>();
let bridge_inner = Arc::clone(inner);
std::thread::Builder::new()
.name("openless-combo-abort-bridge".into())
.spawn(move || combo_abort_bridge_loop(bridge_inner, combo_rx, handler))
.ok();
combo_tx
}
pub(super) fn hotkey_supervisor_loop(inner: Arc<Inner>) {
let mut attempts: u32 = 0;
let capability = HotkeyMonitor::capability();
@@ -85,7 +113,10 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc<Inner>) {
#[cfg(target_os = "linux")]
let (fcitx_tx, fcitx_binding) = (tx.clone(), binding.clone());
let cancel_tx = spawn_esc_cancel_bridge(&inner);
match HotkeyMonitor::start(binding, tx, cancel_tx) {
let combo_tx = spawn_combo_abort_bridge(&inner, handle_trigger_combined);
#[cfg(target_os = "linux")]
let combo_tx_for_fcitx = combo_tx.clone();
match HotkeyMonitor::start(binding, tx, cancel_tx, combo_tx) {
Ok(monitor) => {
let adapter = monitor.kind();
*inner.hotkey.lock() = Some(monitor);
@@ -115,6 +146,7 @@ pub(super) fn hotkey_supervisor_loop(inner: Arc<Inner>) {
let custom_key = custom_dictation_key_string(&inner);
crate::linux_fcitx::start_dictation_signal_listener(
fcitx_tx,
combo_tx_for_fcitx,
fcitx_binding.clone(),
qa_trigger,
translation_trigger,
@@ -309,10 +341,11 @@ pub(super) fn update_coding_agent_hotkey_binding_now(inner: &Arc<Inner>) {
return;
}
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
// Less Computer 的独立 tap 也转发 Esc 取消(与主 monitor 双保险;
// cancel_session 幂等,重复触发无害)。
// Less Computer 的独立 tap 也转发 Esc 取消与组合键撤销(与主 monitor 双保险;
// cancel_session 幂等,重复触发无害)。组合键撤销撤销的是 voice_agent 会话。
let cancel_tx = spawn_esc_cancel_bridge(inner);
match HotkeyMonitor::start(modifier_binding, tx, cancel_tx) {
let combo_tx = spawn_combo_abort_bridge(inner, cancel_less_computer_press);
match HotkeyMonitor::start(modifier_binding, tx, cancel_tx, combo_tx) {
Ok(monitor) => {
*inner.coding_agent_modifier_hotkey.lock() = Some(monitor);
log::info!(
@@ -386,9 +419,9 @@ pub(super) fn less_computer_modifier_bridge_loop(inner: Arc<Inner>, rx: mpsc::Re
}
let inner_cloned = Arc::clone(&inner);
match evt {
HotkeyEvent::Pressed { .. } => {
HotkeyEvent::Pressed { press_id, .. } => {
async_runtime::block_on(async {
handle_less_computer_pressed(&inner_cloned).await
handle_less_computer_modifier_pressed(&inner_cloned, press_id).await
});
}
HotkeyEvent::Released { .. } => {
@@ -396,11 +429,70 @@ pub(super) fn less_computer_modifier_bridge_loop(inner: Arc<Inner>, rx: mpsc::Re
handle_less_computer_released(&inner_cloned).await
});
}
// Esc 取消与组合键撤销都不在此枚举里:分别走 esc_cancel_bridge_loop /
// combo_abort_bridge_loop(见各自函数注释)。
HotkeyEvent::TranslationModifierPressed | HotkeyEvent::QaShortcutPressed => {}
}
}
}
/// Less Computer 触发键被当修饰键用(Option+任意字母/数字键之类):撤销这次按下开出的语音会话。
/// handle_less_computer_pressed 只在 Idle 时开会话,所以此刻还在跑的 voice_agent
/// 会话必然就是这次按下开出来的;其他情况(按下被忽略)什么都不动。
async fn handle_less_computer_modifier_pressed(inner: &Arc<Inner>, press_id: u64) {
if press_id == 0 {
handle_less_computer_pressed(inner).await;
return;
}
inner
.less_computer_press_generation
.store(press_id, Ordering::SeqCst);
if inner
.less_computer_combo_pending_press
.compare_exchange(press_id, 0, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
return;
}
handle_less_computer_pressed(inner).await;
if inner
.less_computer_combo_pending_press
.compare_exchange(press_id, 0, Ordering::SeqCst, Ordering::SeqCst)
.is_ok()
{
cancel_less_computer_voice_session(inner);
}
}
fn cancel_less_computer_press(inner: &Arc<Inner>, press_id: u64) {
if press_id == 0 {
return;
}
inner
.less_computer_combo_pending_press
.store(press_id, Ordering::SeqCst);
if inner.less_computer_press_generation.load(Ordering::SeqCst) != press_id {
return;
}
cancel_less_computer_voice_session(inner);
}
fn cancel_less_computer_voice_session(inner: &Arc<Inner>) {
let (phase, voice_agent) = {
let state = inner.state.lock();
(state.phase, state.voice_agent)
};
if !voice_agent || !matches!(phase, SessionPhase::Starting | SessionPhase::Listening) {
return;
}
let _ = inner.less_computer_combo_pending_press.swap(0, Ordering::SeqCst);
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<Inner>, rx: mpsc::Receiver<ComboHotkeyEvent>) {
while let Ok(evt) = rx.recv() {
if inner.shortcut_recording_active.load(Ordering::SeqCst) {
@@ -636,7 +728,7 @@ pub(super) fn combo_hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<Com
// 否则 latch 竞态导致 combo 快捷键二次按键失效。
ComboHotkeyEvent::Pressed { at } => {
async_runtime::block_on(async {
handle_pressed_edge(&inner_cloned, at).await;
handle_pressed_edge(&inner_cloned, at, 0).await;
});
}
ComboHotkeyEvent::Released { at } => {
@@ -1059,9 +1151,9 @@ pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEve
// 里直到 begin_session 完成,但 SessionPhase::Starting 已经有
// request_stop_during_starting 兜底,begin_session 完成进 Listening 后
// bridge 立刻 recv Released → end_session,行为正确,仅有短暂 stop 延迟。
HotkeyEvent::Pressed { at } => {
HotkeyEvent::Pressed { at, press_id } => {
async_runtime::block_on(async {
handle_pressed_edge(&inner_cloned, at).await;
handle_pressed_edge(&inner_cloned, at, press_id).await;
});
}
HotkeyEvent::Released { at } => {
@@ -1069,8 +1161,9 @@ pub(super) fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEve
handle_released_edge(&inner_cloned, at).await;
});
}
// Esc 取消不在此枚举里:走独立的 esc_cancel_bridge_loop,避免被上面
// Released → end_session 的同步转写流程堵在队列里(见该函数注释)。
// Esc 取消与组合键撤销都不在此枚举里:分别走 esc_cancel_bridge_loop /
// combo_abort_bridge_loop,避免被上面 Released → end_session /
// Pressed → begin_session 的同步流程堵在队列里(见各自函数注释)。
HotkeyEvent::TranslationModifierPressed => {
let translation_hotkey = inner_cloned.prefs.get().translation_hotkey;
if is_builtin_translation_shift(&translation_hotkey)
@@ -1193,7 +1286,7 @@ pub(super) async fn handle_window_hotkey_event(
log::info!(
"[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}"
);
handle_pressed_edge(inner, std::time::Instant::now()).await;
handle_pressed_edge(inner, std::time::Instant::now(), 0).await;
}
"keyup" => {
log::info!("[window-hotkey] released trigger={trigger:?} code={code}");
@@ -1343,4 +1436,4 @@ mod tests {
drop(tx);
handle.join().unwrap();
}
}
}
+311 -21
View File
@@ -9,13 +9,15 @@
//!
//! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。
//!
//! Esc(取消)**不走** `HotkeyEvent` 通道,而是独立的 `Sender<()>`:Pressed/Released
//! 的 bridge 线程为了修 #468/#475 的 latch 竞态改成了串行 block_on,Released 会在
//! bridge 线程上同步跑完整个转写 + 润色流程 —— 若 Esc 与它们同队列,Processing 期间
//! 的取消事件只能排队等流程跑完,#798 的 select! 取消赛跑永远等不到 cancelled 旗标
//! (「转写中按 Esc 停不下来」)。独立通道 + 专用消费线程保证取消随到随处理。
//! Esc(取消)与组合键撤销(触发键按住期间叠加了普通键)**都不走** `HotkeyEvent`
//! 通道,而是独立的 `Sender<u64>`:Pressed/Released 的 bridge 线程为了修 #468/#475 的
//! latch 竞态改成了串行 block_on,Pressed / Released 会在 bridge 线程上同步跑完
//! `begin_session`(开麦 + ASR 握手)或整个转写 + 润色流程 —— 若取消 / 撤销与它们同
//! 队列,事件只能排队等流程跑完,观感就是「晚几百毫秒才生效」。独立通道 + 专用消费
//! 线程保证取消 / 撤销随到随处理(`cancel_session` / `handle_trigger_combined` 都是纯
//! 同步快路径:置旗标 + 清资源,不 await)。
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::mpsc::{self, Sender};
use std::sync::Arc;
use std::time::{Duration, Instant};
@@ -27,8 +29,10 @@ use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyIns
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HotkeyEvent {
Pressed { at: Instant },
Pressed { at: Instant, press_id: u64 },
Released { at: Instant },
// 组合键撤销不在此枚举里:走独立的 `combo_abort` 通道,避免被上面 Pressed →
// begin_session 的同步开麦流程堵在队列里(见模块注释)。
/// Shift(或未来配置项指定的修饰键)按下边沿。可在录音过程中任何时刻产生;
/// 上层据此切换到翻译输出管线。详见 issue #4。
TranslationModifierPressed,
@@ -44,6 +48,8 @@ mod tests {
Shared {
binding: RwLock::new(HotkeyBinding::default()),
trigger_held: AtomicBool::new(true),
trigger_press_id: AtomicU64::new(0),
trigger_companion_seen: AtomicU64::new(0),
qa_trigger: RwLock::new(None),
qa_trigger_held: AtomicBool::new(true),
translation_trigger: RwLock::new(None),
@@ -112,6 +118,12 @@ pub trait HotkeyAdapter: Send + Sync {
translation_trigger: Option<HotkeyTrigger>,
);
fn reset_held_state(&self);
/// 本次按住期间,监听器是否已经看到触发键被叠加了普通键。上层的「仲裁窗口」
/// 按下后先等一小会儿再读它,命中就整条按下作废(麦克风都不用开)。
/// 没有键盘监听器的平台(Linux/fcitx5)恒为 false。
fn trigger_combined_since_press(&self, _press_id: u64) -> bool {
false
}
fn shutdown(&self) {}
}
@@ -119,6 +131,11 @@ struct Shared {
binding: RwLock<HotkeyBinding>,
/// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。
trigger_held: AtomicBool,
/// 当前触发键按下的全局代次。代次由监听器生成,避免独立撤销通道的迟到事件
/// 误认成下一次按下。
trigger_press_id: AtomicU64,
/// 已经看到普通键的按下代次;0 表示本次按下尚未看到普通键。
trigger_companion_seen: AtomicU64,
qa_trigger: RwLock<Option<HotkeyTrigger>>,
qa_trigger_held: AtomicBool,
translation_trigger: RwLock<Option<HotkeyTrigger>>,
@@ -139,13 +156,17 @@ impl HotkeyMonitor {
///
/// `cancel_tx`:Esc 按下即发一个 `()`。独立于 `tx`,见模块注释——不能与
/// Pressed/Released 挤同一条串行 bridge,否则 Processing 期间取消排不上队。
/// `combo_tx`:触发键按住期间叠加了普通键就发一个 press id。独立于 `tx`,见模块
/// 注释——不能与 Pressed/Released 挤同一条串行 bridge,否则撤销要等
/// `begin_session` 开完麦才排得上队,胶囊晚几百毫秒才消失。
pub fn start(
binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
combo_tx: Sender<u64>,
) -> Result<Self, HotkeyInstallError> {
Ok(Self {
adapter: platform::start_adapter(binding, tx, cancel_tx)?,
adapter: platform::start_adapter(binding, tx, cancel_tx, combo_tx)?,
})
}
@@ -170,6 +191,10 @@ impl HotkeyMonitor {
self.adapter.reset_held_state();
}
pub fn trigger_combined_since_press(&self, press_id: u64) -> bool {
self.adapter.trigger_combined_since_press(press_id)
}
pub fn capability() -> HotkeyCapability {
HotkeyCapability::current()
}
@@ -200,6 +225,18 @@ fn send_cancel_or_log(tx: &Sender<()>) {
}
}
fn send_combo_abort_or_log(tx: &Sender<u64>, press_id: u64) {
if let Err(e) = tx.send(press_id) {
log::warn!("[hotkey] 组合键撤销事件发送失败: {e}");
}
}
static NEXT_PRESS_ID: AtomicU64 = AtomicU64::new(1);
pub(crate) fn next_press_id() -> u64 {
NEXT_PRESS_ID.fetch_add(1, std::sync::atomic::Ordering::Relaxed)
}
/// 会话激活期间(胶囊显示录音/转写/润色中)Esc 由 OpenLess 独占:tap/hook 吞掉
/// keydown 不透传宿主应用。否则一次 Esc 双重生效——既取消 OpenLess 会话,又触发
/// 宿主应用自己的 Esc 语义(如取消 Claude 正在生成的回复)。对照输入法的行为:
@@ -215,7 +252,6 @@ pub fn set_esc_exclusive(active: bool) {
fn esc_exclusive() -> bool {
ESC_EXCLUSIVE.load(std::sync::atomic::Ordering::SeqCst)
}
type StartupTx<T> = mpsc::Sender<Result<T, HotkeyInstallError>>;
struct ListenerThread<T> {
@@ -227,17 +263,26 @@ fn start_listener_thread<T, F>(
binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
combo_tx: Sender<u64>,
thread_name: &str,
startup_timeout_message: &'static str,
run_listen_loop: F,
) -> Result<ListenerThread<T>, HotkeyInstallError>
where
T: Send + 'static,
F: FnOnce(Arc<Shared>, Sender<HotkeyEvent>, Sender<()>, StartupTx<T>) + Send + 'static,
F: FnOnce(
Arc<Shared>,
Sender<HotkeyEvent>,
Sender<()>,
Sender<u64>,
StartupTx<T>,
) + Send + 'static,
{
let shared = Arc::new(Shared {
binding: RwLock::new(binding),
trigger_held: AtomicBool::new(false),
trigger_press_id: AtomicU64::new(0),
trigger_companion_seen: AtomicU64::new(0),
qa_trigger: RwLock::new(None),
qa_trigger_held: AtomicBool::new(false),
translation_trigger: RwLock::new(None),
@@ -249,7 +294,7 @@ where
let (status_tx, status_rx) = mpsc::channel::<Result<T, HotkeyInstallError>>();
std::thread::Builder::new()
.name(thread_name.into())
.spawn(move || run_listen_loop(thread_shared, tx, cancel_tx, status_tx))
.spawn(move || run_listen_loop(thread_shared, tx, cancel_tx, combo_tx, status_tx))
.map_err(|e| install_error("spawn_failed", format!("hotkey 线程启动失败: {e}")))?;
match status_rx.recv_timeout(Duration::from_secs(3)) {
@@ -295,6 +340,9 @@ fn reset_shared_held_state(shared: &Shared) {
shared
.trigger_held
.store(false, std::sync::atomic::Ordering::SeqCst);
shared
.trigger_companion_seen
.store(0, std::sync::atomic::Ordering::SeqCst);
shared
.qa_trigger_held
.store(false, std::sync::atomic::Ordering::SeqCst);
@@ -316,7 +364,8 @@ mod platform {
use std::sync::Arc;
use super::{
esc_exclusive, install_error, reset_shared_held_state, send_cancel_or_log, send_or_log,
esc_exclusive, install_error, reset_shared_held_state, send_cancel_or_log,
send_combo_abort_or_log, send_or_log,
start_listener_thread, update_shared_binding, update_shared_modifier_shortcuts,
HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
};
@@ -326,11 +375,13 @@ mod platform {
binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
combo_tx: Sender<u64>,
) -> Result<Box<dyn HotkeyAdapter>, HotkeyInstallError> {
let listener = start_listener_thread(
binding,
tx,
cancel_tx,
combo_tx,
"openless-hotkey-mac-event-tap",
"hotkey hook 启动超时",
run_listen_loop,
@@ -384,6 +435,13 @@ mod platform {
reset_shared_held_state(&self.shared);
}
fn trigger_combined_since_press(&self, press_id: u64) -> bool {
self.shared
.trigger_companion_seen
.load(Ordering::SeqCst)
== press_id
}
fn shutdown(&self) {
// 顺序:先 disable tap 让 OS 不再向我们派发事件,然后 stop runloop
// 让 listener 线程从 CFRunLoopRun() 返回退出。take() 保证幂等。
@@ -488,6 +546,8 @@ mod platform {
tx: Sender<HotkeyEvent>,
/// Esc 专用通道,见模块注释——不与 tx 挤同一条串行 bridge。
cancel_tx: Sender<()>,
/// 组合键撤销专用通道,见模块注释——不与 tx 挤同一条串行 bridge。
combo_tx: Sender<u64>,
/// 与 MacHotkeyAdapter 共享的 (tap, runloop) refs。tap re-enable on
/// TAP_DISABLED_BY_TIMEOUT 走 handles.tap;adapter shutdown 也走这两个 lock。
handles: Arc<MacShutdownHandles>,
@@ -500,6 +560,7 @@ mod platform {
shared: Arc<Shared>,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
combo_tx: Sender<u64>,
status_tx: StartupTx<Arc<MacShutdownHandles>>,
) {
let mask: CgEventMask = (1u64 << FLAGS_CHANGED)
@@ -513,6 +574,7 @@ mod platform {
shared,
tx,
cancel_tx,
combo_tx,
handles: Arc::clone(&handles),
}));
@@ -641,7 +703,20 @@ mod platform {
if is_active && !was_held {
ctx.shared.trigger_held.store(true, Ordering::SeqCst);
send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: std::time::Instant::now() });
let press_id = super::next_press_id();
ctx.shared
.trigger_press_id
.store(press_id, Ordering::SeqCst);
ctx.shared
.trigger_companion_seen
.store(0, Ordering::SeqCst);
send_or_log(
&ctx.tx,
HotkeyEvent::Pressed {
at: std::time::Instant::now(),
press_id,
},
);
} else if !is_active && was_held {
ctx.shared.trigger_held.store(false, Ordering::SeqCst);
send_or_log(&ctx.tx, HotkeyEvent::Released { at: std::time::Instant::now() });
@@ -675,8 +750,38 @@ mod platform {
fn handle_key_down(ctx: &CallbackContext, event: CgEventRef) {
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
if keycode == ESC_KEYCODE {
note_companion_key_down(ctx);
send_cancel_or_log(&ctx.cancel_tx);
return;
}
note_companion_key_down(ctx);
}
/// 触发键按住期间按下任意普通键 = 用户在打组合键(Option+任意字母/数字键、Option+Tab…),
/// 不是想说话 —— 往组合键撤销通道发一次让上层撤销这次按下。
///
/// 走 `combo_tx` 而不是 `tx`:撤销必须在按下 Q 的那一帧就生效(胶囊立刻消失),
/// 而 `tx` 那头的 bridge 此刻多半正卡在这次按下自己的 `begin_session` 里。见模块注释。
///
/// 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;
}
let press_id = ctx.shared.trigger_press_id.load(Ordering::SeqCst);
if press_id == 0
|| ctx
.shared
.trigger_companion_seen
.compare_exchange(0, press_id, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
log::info!("[hotkey] 触发键与其他键组合按下 —— 撤销本次触发");
send_combo_abort_or_log(&ctx.combo_tx, press_id);
}
fn trigger_to_keycode(trigger: HotkeyTrigger) -> i64 {
@@ -713,7 +818,7 @@ mod platform {
mod tests {
use super::*;
use parking_lot::RwLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::mpsc;
fn shared(trigger: HotkeyTrigger) -> Arc<Shared> {
@@ -724,6 +829,8 @@ mod platform {
keys: None,
}),
trigger_held: AtomicBool::new(false),
trigger_press_id: AtomicU64::new(0),
trigger_companion_seen: AtomicU64::new(0),
qa_trigger: RwLock::new(None),
qa_trigger_held: AtomicBool::new(false),
translation_trigger: RwLock::new(None),
@@ -732,23 +839,42 @@ mod platform {
})
}
fn callback_context(shared: Arc<Shared>) -> (CallbackContext, mpsc::Receiver<HotkeyEvent>) {
fn callback_context_with_combo(
shared: Arc<Shared>,
) -> (
CallbackContext,
mpsc::Receiver<HotkeyEvent>,
mpsc::Receiver<u64>,
) {
let (tx, rx) = mpsc::channel();
let (cancel_tx, _cancel_rx) = mpsc::channel();
let (combo_tx, combo_rx) = mpsc::channel();
(
CallbackContext {
shared,
tx,
cancel_tx,
combo_tx,
handles: Arc::new(MacShutdownHandles {
tap: std::sync::Mutex::new(None),
runloop: std::sync::Mutex::new(None),
}),
},
rx,
combo_rx,
)
}
/// 不关心组合键撤销的用例:撤销通道的接收端就地丢弃(这些用例不会往它发东西)。
fn callback_context(shared: Arc<Shared>) -> (CallbackContext, mpsc::Receiver<HotkeyEvent>) {
let (ctx, rx, _combo_rx) = callback_context_with_combo(shared);
(ctx, rx)
}
fn drain_combo(rx: &mpsc::Receiver<u64>) -> usize {
rx.try_iter().count()
}
fn drain(rx: &mpsc::Receiver<HotkeyEvent>) -> Vec<HotkeyEvent> {
rx.try_iter().collect()
}
@@ -807,6 +933,38 @@ mod platform {
]
);
}
// Option+任意字母/数字键这类组合键:按住期间的普通键按下只撤销一次,
// 且必须真的按住了触发键。
#[test]
fn mac_companion_key_down_aborts_trigger_once_per_hold() {
let shared = shared(HotkeyTrigger::LeftOption);
let (ctx, rx, combo_rx) = callback_context_with_combo(Arc::clone(&shared));
// 没按住触发键时的普通打字:与听写无关,不发事件。
note_companion_key_down(&ctx);
assert_eq!(drain_combo(&combo_rx), 0);
shared
.trigger_press_id
.store(1, Ordering::SeqCst);
shared.trigger_held.store(true, Ordering::SeqCst);
// OS 自动重复 / 按住触发键连按多个键,都只撤销一次。
note_companion_key_down(&ctx);
note_companion_key_down(&ctx);
assert_eq!(drain_combo(&combo_rx), 1);
// 下一次 Pressed 边沿会重置 latch(handle_flags_changed 里做),下一轮组合键
// 才能再次撤销 —— 否则第二次组合键会被当成正常听写。
shared
.trigger_companion_seen
.store(0, Ordering::SeqCst);
note_companion_key_down(&ctx);
assert_eq!(drain_combo(&combo_rx), 1);
// 撤销全程不碰 Pressed/Released 那条串行通道 —— 它此刻正卡在 begin_session 里。
assert!(drain(&rx).is_empty());
}
}
}
@@ -828,7 +986,8 @@ mod platform {
};
use super::{
esc_exclusive, install_error, reset_shared_held_state, send_cancel_or_log, send_or_log,
esc_exclusive, install_error, reset_shared_held_state, send_cancel_or_log,
send_combo_abort_or_log, send_or_log,
start_listener_thread, update_shared_binding, update_shared_modifier_shortcuts,
HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
};
@@ -841,6 +1000,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;
@@ -859,11 +1021,13 @@ mod platform {
binding: HotkeyBinding,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
combo_tx: Sender<u64>,
) -> Result<Box<dyn HotkeyAdapter>, HotkeyInstallError> {
let listener = start_listener_thread(
binding,
tx,
cancel_tx,
combo_tx,
"openless-hotkey-win-ll-hook",
"Windows hotkey hook 启动超时",
run_listen_loop,
@@ -900,6 +1064,13 @@ mod platform {
reset_shared_held_state(&self.shared);
}
fn trigger_combined_since_press(&self, press_id: u64) -> bool {
self.shared
.trigger_companion_seen
.load(Ordering::SeqCst)
== press_id
}
fn shutdown(&self) {
unsafe {
if let Err(err) = PostThreadMessageW(self.thread_id, WM_QUIT, WPARAM(0), LPARAM(0))
@@ -915,6 +1086,8 @@ mod platform {
tx: Sender<HotkeyEvent>,
/// Esc 专用通道,见模块注释——不与 tx 挤同一条串行 bridge。
cancel_tx: Sender<()>,
/// 组合键撤销专用通道,见模块注释——不与 tx 挤同一条串行 bridge。
combo_tx: Sender<u64>,
hook: std::sync::Mutex<Option<HHOOK>>,
}
@@ -925,6 +1098,7 @@ mod platform {
shared: Arc<Shared>,
tx: Sender<HotkeyEvent>,
cancel_tx: Sender<()>,
combo_tx: Sender<u64>,
status_tx: StartupTx<u32>,
) {
let thread_id = unsafe { GetCurrentThreadId() };
@@ -932,6 +1106,7 @@ mod platform {
shared,
tx,
cancel_tx,
combo_tx,
hook: std::sync::Mutex::new(None),
}));
HOOK_CONTEXT.store(context, AtomicOrdering::SeqCst);
@@ -1007,16 +1182,21 @@ mod platform {
}
fn dispatch_keyboard_event(ctx: &CallbackContext, vk_code: u32, message: usize) -> bool {
let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN);
if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) {
note_companion_key_down(ctx);
send_cancel_or_log(&ctx.cancel_tx);
// 会话激活期间独占消费 Esc(返回 true → LRESULT(1) 吞掉),宿主应用收不到,
// 避免「取消会话」与宿主 Esc 语义双重生效。见 esc_exclusive 注释。
return esc_exclusive();
}
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 {
@@ -1068,8 +1248,21 @@ mod platform {
WM_KEYDOWN | WM_SYSKEYDOWN => {
let was_held = ctx.shared.trigger_held.swap(true, Ordering::SeqCst);
if !was_held {
let press_id = super::next_press_id();
ctx.shared
.trigger_press_id
.store(press_id, Ordering::SeqCst);
ctx.shared
.trigger_companion_seen
.store(0, Ordering::SeqCst);
log::info!("[hotkey] Windows trigger pressed vk={vk_code}");
send_or_log(&ctx.tx, HotkeyEvent::Pressed { at: std::time::Instant::now() });
send_or_log(
&ctx.tx,
HotkeyEvent::Pressed {
at: std::time::Instant::now(),
press_id,
},
);
}
}
WM_KEYUP | WM_SYSKEYUP => {
@@ -1084,6 +1277,47 @@ mod platform {
true
}
/// 触发键按住期间按下任意非修饰键 = 用户在打组合键(Alt+Tab / Alt+F4…),不是想
/// 说话 —— 往组合键撤销通道发一次让上层撤销这次按下。走 `combo_tx` 而不是 `tx` 的
/// 理由见模块注释(`tx` 那头此刻多半正卡在这次按下自己的 begin_session 里)。
/// 修饰键本身不算「其他键」,与 macOS 侧(修饰键走 FLAGS_CHANGED、不进 KEY_DOWN)
/// 保持同一语义:叠加 Shift(翻译修饰键)或 Ctrl 不会撤销听写。
fn note_companion_key_down(ctx: &CallbackContext) {
if !ctx.shared.trigger_held.load(Ordering::SeqCst) {
return;
}
let press_id = ctx.shared.trigger_press_id.load(Ordering::SeqCst);
if press_id == 0
|| ctx
.shared
.trigger_companion_seen
.compare_exchange(0, press_id, Ordering::SeqCst, Ordering::SeqCst)
.is_err()
{
return;
}
log::info!("[hotkey] Windows 触发键与其他键组合按下 —— 撤销本次触发");
send_combo_abort_or_log(&ctx.combo_tx, press_id);
}
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,
@@ -1139,7 +1373,7 @@ mod platform {
mod tests {
use super::*;
use parking_lot::RwLock;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicBool, AtomicU64};
use std::sync::mpsc;
fn shared(trigger: HotkeyTrigger) -> Arc<Shared> {
@@ -1150,6 +1384,8 @@ mod platform {
keys: None,
}),
trigger_held: AtomicBool::new(false),
trigger_press_id: AtomicU64::new(0),
trigger_companion_seen: AtomicU64::new(0),
qa_trigger: RwLock::new(None),
qa_trigger_held: AtomicBool::new(false),
translation_trigger: RwLock::new(None),
@@ -1158,20 +1394,39 @@ mod platform {
})
}
fn callback_context(shared: Arc<Shared>) -> (CallbackContext, mpsc::Receiver<HotkeyEvent>) {
fn callback_context_with_combo(
shared: Arc<Shared>,
) -> (
CallbackContext,
mpsc::Receiver<HotkeyEvent>,
mpsc::Receiver<u64>,
) {
let (tx, rx) = mpsc::channel();
let (cancel_tx, _cancel_rx) = mpsc::channel();
let (combo_abort_tx, combo_abort_rx) = mpsc::channel();
(
CallbackContext {
shared,
tx,
cancel_tx,
combo_tx: combo_abort_tx,
hook: std::sync::Mutex::new(None),
},
rx,
combo_abort_rx,
)
}
/// 不关心组合键撤销的用例:撤销通道的接收端就地丢弃(这些用例不会往它发东西)。
fn callback_context(shared: Arc<Shared>) -> (CallbackContext, mpsc::Receiver<HotkeyEvent>) {
let (ctx, rx, _combo_abort_rx) = callback_context_with_combo(shared);
(ctx, rx)
}
fn drain_combo(rx: &mpsc::Receiver<u64>) -> usize {
rx.try_iter().count()
}
fn drain(rx: &mpsc::Receiver<HotkeyEvent>) -> Vec<HotkeyEvent> {
rx.try_iter().collect()
}
@@ -1288,6 +1543,33 @@ 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, combo_abort_rx) = callback_context_with_combo(shared);
dispatch_keyboard_event(&ctx, VK_LMENU, WM_KEYDOWN);
dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN);
assert_eq!(drain_combo(&combo_abort_rx), 0);
dispatch_keyboard_event(&ctx, 0x41, WM_KEYDOWN); // A
dispatch_keyboard_event(&ctx, 0x41, WM_KEYDOWN); // OS 自动重复
assert_eq!(drain_combo(&combo_abort_rx), 1);
}
#[test]
fn windows_escape_while_trigger_held_is_also_a_companion_key() {
let shared = shared(HotkeyTrigger::LeftOption);
let (ctx, _rx, combo_abort_rx) = callback_context_with_combo(shared);
dispatch_keyboard_event(&ctx, VK_LMENU, WM_KEYDOWN);
dispatch_keyboard_event(&ctx, VK_ESCAPE, WM_KEYDOWN);
assert_eq!(drain_combo(&combo_abort_rx), 1);
}
#[test]
fn windows_shift_side_combo_receives_pressed_via_dispatch_keyboard_event() {
use crate::combo_hotkey::ComboHotkeyEvent;
@@ -1336,15 +1618,23 @@ mod platform {
_binding: HotkeyBinding,
_tx: Sender<HotkeyEvent>,
_cancel_tx: Sender<()>,
_combo_tx: Sender<u64>,
) -> Result<Box<dyn HotkeyAdapter>, HotkeyInstallError> {
log::info!("[hotkey] Linux — fcitx5 plugin handles hotkeys");
Ok(Box::new(PlaceholderAdapter { _tx }))
Ok(Box::new(PlaceholderAdapter {
_tx,
_cancel_tx,
_combo_tx,
}))
}
/// Linux 占位 adapter:实现接口但不监听键盘。
/// 热键事件由 fcitx5 插件的 `DictationKeyEvent` DBus 信号提供。
/// 组合键撤销由 fcitx5 插件通过 `DictationKeyCombined` 信号上报。
struct PlaceholderAdapter {
_tx: Sender<HotkeyEvent>,
_cancel_tx: Sender<()>,
_combo_tx: Sender<u64>,
}
impl HotkeyAdapter for PlaceholderAdapter {
+19 -1
View File
@@ -276,11 +276,14 @@ pub fn available() -> bool {
#[cfg(target_os = "linux")]
pub fn start_dictation_signal_listener(
tx: std::sync::mpsc::Sender<crate::hotkey::HotkeyEvent>,
combo_tx: std::sync::mpsc::Sender<u64>,
binding: crate::types::HotkeyBinding,
qa_trigger: Option<crate::types::HotkeyTrigger>,
translation_trigger: Option<crate::types::HotkeyTrigger>,
custom_trigger_key: Option<String>,
) {
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
std::thread::Builder::new()
@@ -307,6 +310,9 @@ pub fn start_dictation_signal_listener(
};
let tx2 = tx.clone();
let combo_tx = combo_tx.clone();
let current_press_id = Arc::new(AtomicU64::new(0));
let current_press_id_for_match = Arc::clone(&current_press_id);
let _match = match conn.add_match(rule, move |args: (u32, u32, bool), _conn, msg| {
let (sym, states, is_press) = args;
let member = msg.member();
@@ -318,8 +324,20 @@ pub fn start_dictation_signal_listener(
if let Some(member) = member {
if member == "DictationKeyEvent" {
let at = std::time::Instant::now();
let event = if is_press { crate::hotkey::HotkeyEvent::Pressed { at } } else { crate::hotkey::HotkeyEvent::Released { at } };
let event = if is_press {
let press_id = crate::hotkey::next_press_id();
current_press_id_for_match.store(press_id, Ordering::SeqCst);
crate::hotkey::HotkeyEvent::Pressed { at, press_id }
} else {
current_press_id_for_match.store(0, Ordering::SeqCst);
crate::hotkey::HotkeyEvent::Released { at }
};
let _ = tx.send(event);
} else if member == "DictationKeyCombined" && is_press {
let press_id = current_press_id_for_match.load(Ordering::SeqCst);
if press_id != 0 {
let _ = combo_tx.send(press_id);
}
} else if member == "QaShortcutEvent" {
if is_press {
let _ = tx2.send(crate::hotkey::HotkeyEvent::QaShortcutPressed);
@@ -9,8 +9,10 @@ use crate::types::{
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum HotkeyEvent {
Pressed { at: Instant },
Pressed { at: Instant, press_id: u64 },
Released { at: Instant },
// 组合键撤销与 Esc 取消在移动端无全局键盘监听,不在此枚举里(见 hotkey.rs 模块注释)。
TranslationModifierPressed,
QaShortcutPressed,
}
@@ -25,6 +27,7 @@ impl HotkeyMonitor {
_binding: HotkeyBinding,
_tx: Sender<HotkeyEvent>,
_cancel_tx: Sender<()>,
_combo_tx: Sender<u64>,
) -> Result<Self, HotkeyInstallError> {
Err(HotkeyInstallError {
code: "unavailable".into(),
@@ -47,6 +50,12 @@ impl HotkeyMonitor {
pub fn reset_held_state(&self) {}
/// 移动端没有键盘监听器,永远「没看到叠加的普通键」——组合键仲裁窗口在这里
/// 恒等于放行(`press_resolves_to_combo` 也拿不到 monitor,双保险)。
pub fn trigger_combined_since_press(&self, _press_id: u64) -> bool {
false
}
pub fn capability() -> HotkeyCapability {
HotkeyCapability::current()
}
+22 -1
View File
@@ -201,6 +201,17 @@ fn run_audio_thread(
// 启动 liveness watchdog 线程:检测录音回调是否静默停止
const WATCHDOG_CHECK_INTERVAL_MS: u64 = 1000; // 每秒检查一次
/// 一次「检查间隔」被切成这么碎的若干觉来睡,每觉醒来都重看 stop_flag。
///
/// 为什么不直接 sleep 满 1000ms:`Recorder::stop()` 要 join 音频线程,音频线程退出前
/// 要 join 本 watchdog —— watchdog 睡多沉,停采就要等多久。实测停一次录音因此要
/// 0.8~1 秒,而 `cancel_session` 又在拆完 recorder 才收胶囊,用户按 Option+Q / Esc
/// 看到的就是「取消了但胶囊还赖着一秒」。切碎后 stop 的等待从最坏 1000ms 降到 50ms。
///
/// 检查的判据(下面两个 SECS)用的是真实时间差,不是「醒了几次」,所以睡眠粒度变化
/// 不改变 watchdog 的灵敏度:仍然是回调静默超过 CALLBACK_TIMEOUT_SECS 才报错。
/// 代价只是录音期间线程多醒几次(醒来只读一个时间戳)。
const WATCHDOG_SLEEP_SLICE_MS: u64 = 50;
const CALLBACK_TIMEOUT_SECS: u64 = 3; // 3 秒没有回调视为异常
const FIRST_CALLBACK_DEADLINE_SECS: u64 = 5; // 5 秒内必须收到首次回调
@@ -215,7 +226,17 @@ fn run_audio_thread(
let watchdog_start_time = std::time::Instant::now();
while !stop_flag_for_watchdog.load(Ordering::SeqCst) {
thread::sleep(std::time::Duration::from_millis(WATCHDOG_CHECK_INTERVAL_MS));
// 一个检查间隔切成 WATCHDOG_SLEEP_SLICE_MS 的碎觉睡完,中途 stop 就立刻退出
// (见 WATCHDOG_SLEEP_SLICE_MS:停采要 join 本线程,睡沉了停采就慢)。
let mut slept_ms = 0;
while slept_ms < WATCHDOG_CHECK_INTERVAL_MS {
if stop_flag_for_watchdog.load(Ordering::SeqCst) {
return;
}
let slice = WATCHDOG_SLEEP_SLICE_MS.min(WATCHDOG_CHECK_INTERVAL_MS - slept_ms);
thread::sleep(std::time::Duration::from_millis(slice));
slept_ms += slice;
}
// 关键:sleep 醒来后必须重新检查 stop_flag,再去看 elapsed。
//
@@ -71,6 +71,8 @@ public:
translationRawSym_(0),
translationRawStates_(0),
hasCustomDictationKey_(false),
dictationTriggerHeld_(false),
dictationTriggerCombined_(false),
savedIc_(nullptr) {
// 1. 读取配置
@@ -141,12 +143,25 @@ public:
: static_cast<uint32_t>(triggerKeyList_[0].sym());
auto dstates = triggerRawStates_ != 0 ? triggerRawStates_
: static_cast<uint32_t>(triggerKeyList_[0].states());
if (!hasCustomDictationKey_ && isModifierKeySym(dsym)) {
dictationTriggerHeld_ = isPress;
if (isPress) {
dictationTriggerCombined_ = false;
}
}
FCITX_LOGC(openless, Debug)
<< "Dictation hotkey sym=" << dsym;
dictationKeyEvent(dsym, dstates, isPress);
keyEvent.filterAndAccept();
return;
}
if (isPress && dictationTriggerHeld_ && !isModifierKeySym(sym) &&
!dictationTriggerCombined_) {
FCITX_LOGC(openless, Debug)
<< "Dictation hotkey combined with sym=" << sym;
dictationTriggerCombined_ = true;
dictationKeyCombined(sym, states, true);
}
if (qaRawSym_ != 0 && sym == qaRawSym_ &&
states == qaRawStates_) {
FCITX_LOGC(openless, Debug)
@@ -296,6 +311,7 @@ public:
void setHotkey(const std::vector<std::string> &keys) {
// 切换预设修饰键时清空自定义组合键,避免双发
hasCustomDictationKey_ = false;
resetDictationTriggerState();
KeyList keyList;
for (const auto &s : keys) {
Key key(s);
@@ -326,6 +342,7 @@ public:
void setHotkeyRaw(uint32_t sym, uint32_t states) {
// 切换预设修饰键时清空自定义组合键,避免双发
hasCustomDictationKey_ = false;
resetDictationTriggerState();
triggerRawSym_ = sym;
triggerRawStates_ = states;
// 同时尝试维护 KeyList(如果 sym 可转为有效 key)
@@ -353,10 +370,12 @@ public:
FCITX_LOGC(openless, Warn)
<< "SetCustomDictationTrigger: invalid key '" << keyString << "'";
hasCustomDictationKey_ = false;
resetDictationTriggerState();
return;
}
customDictationKey_ = key;
hasCustomDictationKey_ = true;
resetDictationTriggerState();
// 有自定义键时清空已有 raw+keylist 路径,避免双发
triggerRawSym_ = 0;
triggerRawStates_ = 0;
@@ -409,12 +428,14 @@ public:
FCITX_OBJECT_VTABLE_METHOD(setQaHotkeyRaw, "SetQaHotkeyRaw", "uu", "");
FCITX_OBJECT_VTABLE_METHOD(setTranslationHotkeyRaw, "SetTranslationHotkeyRaw", "uu", "");
FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyEvent, "DictationKeyEvent", "uub");
FCITX_OBJECT_VTABLE_SIGNAL(dictationKeyCombined, "DictationKeyCombined", "uub");
FCITX_OBJECT_VTABLE_SIGNAL(qaShortcutEvent, "QaShortcutEvent", "uub");
FCITX_OBJECT_VTABLE_SIGNAL(translationModifierEvent, "TranslationModifierEvent", "uub");
Instance *instance() { return instance_; }
void reloadConfig() override {
resetDictationTriggerState();
readAsIni(config_, configFile());
// 加载原始 sym/states(由 SetHotkeyRaw / SetQaHotkeyRaw / SetTranslationHotkeyRaw 写入的持久化键值)
RawConfig raw;
@@ -461,6 +482,18 @@ private:
return "conf/openless.conf";
}
static bool isModifierKeySym(uint32_t sym) {
// X11 modifier keysyms. CapsLock is included to match the desktop hook's
// treatment of lock keys: pressing it alongside a trigger must not abort
// dictation as if it were a printable companion key.
return sym >= 0xffe1 && sym <= 0xffee;
}
void resetDictationTriggerState() {
dictationTriggerHeld_ = false;
dictationTriggerCombined_ = false;
}
void rebuildTriggerKeys() {
triggerKeyList_ = config_.triggerKey.value();
}
@@ -476,6 +509,8 @@ private:
uint32_t translationRawStates_;
Key customDictationKey_;
bool hasCustomDictationKey_;
bool dictationTriggerHeld_;
bool dictationTriggerCombined_;
/// 快捷键按下时保存的输入上下文指针,用于 commitText 在失焦后仍能提交文字。
/// 事件处理线程和 DBus 处理线程都是 fcitx5 主事件循环,无竞态。
/// 通过 InputContextDestroyed 事件监听 IC 销毁时自动清空指针。