fix: address hotkey review feedback

This commit is contained in:
millionart committed 2026-05-04 19:03:23 +08:00
1 parent 2d155912b1
commit e7625c5eb5
2 files changed
+289 -77

No files matched your search

+158 -15
View File
@@ -5,6 +5,7 @@
//! insertion, persists history, emits `capsule:state` events to the capsule
//! window.
use std::collections::BTreeSet;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
@@ -19,7 +20,7 @@ use crate::asr::{
DictionaryHotword, RawTranscript, VolcengineCredentials, VolcengineStreamingASR,
WhisperBatchASR,
};
use crate::hotkey::{HotkeyEvent, HotkeyMonitor};
use crate::hotkey::{binding_matches_pressed_codes, HotkeyEvent, HotkeyMonitor};
use crate::insertion::TextInserter;
use crate::persistence::{
CredentialAccount, CredentialsVault, DictionaryStore, HistoryStore, PreferencesStore,
@@ -130,6 +131,7 @@ struct Inner {
hotkey_status: Mutex<HotkeyStatus>,
hotkey_trigger_held: AtomicBool,
hotkey_last_click_at: Mutex<Option<Instant>>,
window_hotkey_pressed_codes: Mutex<BTreeSet<String>>,
/// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在
/// Listening / Starting 阶段看到 Shift down 边沿时 set true。
/// end_session 在调 polish/translate 前读这个 flag + translation_target_language
@@ -226,6 +228,7 @@ impl Coordinator {
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
hotkey_last_click_at: Mutex::new(None),
window_hotkey_pressed_codes: Mutex::new(BTreeSet::new()),
translation_modifier_seen: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
qa_state: Mutex::new(QaSessionState::default()),
@@ -374,6 +377,7 @@ impl Coordinator {
}
pub fn update_hotkey_binding(&self) {
self.inner.window_hotkey_pressed_codes.lock().clear();
if let Some(monitor) = self.inner.hotkey.lock().as_ref() {
monitor.update_binding(self.inner.prefs.get().hotkey);
}
@@ -700,11 +704,14 @@ fn hotkey_bridge_loop(inner: Arc<Inner>, rx: mpsc::Receiver<HotkeyEvent>) {
}
async fn handle_pressed_edge(inner: &Arc<Inner>) {
let was_held = inner.hotkey_trigger_held.swap(true, Ordering::SeqCst);
let was_held = inner.hotkey_trigger_held.load(Ordering::SeqCst);
if !was_held {
if !should_accept_pressed_edge(inner) {
return;
}
if inner.hotkey_trigger_held.swap(true, Ordering::SeqCst) {
return;
}
// 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。
let panel_visible = inner.qa_state.lock().panel_visible;
if panel_visible {
@@ -908,22 +915,41 @@ async fn handle_window_hotkey_event(
return Ok(());
}
let binding = inner.prefs.get().hotkey;
if !window_key_matches_binding(&binding, &key, &code) {
return Ok(());
}
match event_type.as_str() {
"keydown" => {
if repeat {
return Ok(());
}
log::info!("[window-hotkey] pressed code={code} repeat={repeat}");
handle_pressed_edge(inner).await;
let binding = inner.prefs.get().hotkey;
let Some((was_active, is_active)) = update_window_hotkey_pressed_codes(
&mut inner.window_hotkey_pressed_codes.lock(),
&binding,
&key,
&code,
true,
) else {
return Ok(());
};
if is_active && !was_active {
log::info!("[window-hotkey] pressed code={code} repeat={repeat}");
handle_pressed_edge(inner).await;
}
}
"keyup" => {
log::info!("[window-hotkey] released code={code}");
handle_released_edge(inner).await;
let binding = inner.prefs.get().hotkey;
let Some((was_active, is_active)) = update_window_hotkey_pressed_codes(
&mut inner.window_hotkey_pressed_codes.lock(),
&binding,
&key,
&code,
false,
) else {
return Ok(());
};
if was_active && !is_active {
log::info!("[window-hotkey] released code={code}");
handle_released_edge(inner).await;
}
}
_ => {}
}
@@ -935,6 +961,31 @@ fn window_hotkey_fallback_enabled() -> bool {
crate::types::HotkeyCapability::current().explicit_fallback_available
}
#[cfg(any(target_os = "windows", test))]
fn update_window_hotkey_pressed_codes(
pressed_codes: &mut BTreeSet<String>,
binding: &HotkeyBinding,
key: &str,
code: &str,
pressed: bool,
) -> Option<(bool, bool)> {
if !window_key_matches_binding(binding, key, code) {
return None;
}
let normalized = normalize_window_hotkey_code(key, code);
let was_active = binding_matches_pressed_codes(binding, pressed_codes);
if pressed {
pressed_codes.insert(normalized);
} else {
pressed_codes.remove(&normalized);
}
Some((
was_active,
binding_matches_pressed_codes(binding, pressed_codes),
))
}
#[cfg(any(target_os = "windows", test))]
fn window_key_matches_binding(binding: &HotkeyBinding, key: &str, code: &str) -> bool {
let normalized = normalize_window_hotkey_code(key, code);
@@ -2607,16 +2658,85 @@ mod tests {
trigger: HotkeyTrigger::RightControl,
..Default::default()
};
assert!(window_key_matches_binding(&legacy, "Control", "ControlRight"));
assert!(!window_key_matches_binding(&legacy, "Control", "ControlLeft"));
assert!(window_key_matches_binding(
&legacy,
"Control",
"ControlRight"
));
assert!(!window_key_matches_binding(
&legacy,
"Control",
"ControlLeft"
));
let caps_lock = HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
keys: Some(vec![HotkeyKey::new("CapsLock")]),
..Default::default()
};
assert!(window_key_matches_binding(&caps_lock, "CapsLock", "CapsLock"));
assert!(!window_key_matches_binding(&caps_lock, "Control", "ControlRight"));
assert!(window_key_matches_binding(
&caps_lock, "CapsLock", "CapsLock"
));
assert!(!window_key_matches_binding(
&caps_lock,
"Control",
"ControlRight"
));
}
#[test]
fn window_hotkey_fallback_requires_full_combo_before_activating() {
let binding = HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
keys: Some(vec![
HotkeyKey::new("ControlLeft"),
HotkeyKey::new("AltLeft"),
HotkeyKey::new("Mouse4"),
]),
..Default::default()
};
let mut pressed_codes = std::collections::BTreeSet::new();
assert_eq!(
update_window_hotkey_pressed_codes(
&mut pressed_codes,
&binding,
"Control",
"ControlLeft",
true,
),
Some((false, false))
);
assert_eq!(
update_window_hotkey_pressed_codes(
&mut pressed_codes,
&binding,
"Alt",
"AltLeft",
true
),
Some((false, false))
);
assert_eq!(
update_window_hotkey_pressed_codes(
&mut pressed_codes,
&binding,
"Mouse4",
"Mouse4",
true,
),
Some((false, true))
);
assert_eq!(
update_window_hotkey_pressed_codes(
&mut pressed_codes,
&binding,
"Alt",
"AltLeft",
false
),
Some((true, false))
);
}
#[test]
@@ -2732,6 +2852,29 @@ mod tests {
assert_eq!(state.session_id, 41);
}
#[tokio::test]
async fn rejected_double_click_press_does_not_mark_trigger_held() {
let coordinator = Coordinator::new();
coordinator
.inner
.prefs
.set(crate::types::UserPreferences {
hotkey: crate::types::HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::DoubleClick,
..Default::default()
},
..Default::default()
})
.unwrap();
handle_pressed_edge(&coordinator.inner).await;
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
handle_released_edge(&coordinator.inner).await;
assert!(!coordinator.inner.hotkey_trigger_held.load(Ordering::SeqCst));
}
#[test]
fn double_click_mode_requires_second_press_within_window() {
let coordinator = Coordinator::new();
+131 -62
View File
@@ -11,7 +11,7 @@
//! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。
use std::collections::BTreeSet;
use std::sync::atomic::AtomicBool;
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc::{self, Sender};
use std::sync::Arc;
use std::time::Duration;
@@ -166,7 +166,48 @@ fn dispatch_hotkey_code(shared: &Shared, tx: &Sender<HotkeyEvent>, code: &str, p
}
}
fn binding_matches_pressed_codes(binding: &HotkeyBinding, pressed_codes: &BTreeSet<String>) -> bool {
fn dispatch_translation_modifier_code(
shared: &Shared,
tx: &Sender<HotkeyEvent>,
code: &str,
pressed: bool,
) {
if !is_shift_hotkey_code(code) {
return;
}
let shift_is_hotkey = shared
.binding
.read()
.effective_codes()
.iter()
.any(|candidate| candidate == code);
if shift_is_hotkey {
return;
}
if pressed {
let was_held = shared
.translation_modifier_held
.swap(true, Ordering::SeqCst);
if !was_held {
send_or_log(tx, HotkeyEvent::TranslationModifierPressed);
}
} else {
shared
.translation_modifier_held
.store(false, Ordering::SeqCst);
}
}
fn is_shift_hotkey_code(code: &str) -> bool {
matches!(code, "ShiftLeft" | "ShiftRight")
}
pub(crate) fn binding_matches_pressed_codes(
binding: &HotkeyBinding,
pressed_codes: &BTreeSet<String>,
) -> bool {
let codes = binding.effective_codes();
!codes.is_empty()
&& codes
@@ -179,13 +220,13 @@ fn binding_matches_pressed_codes(binding: &HotkeyBinding, pressed_codes: &BTreeS
#[cfg(target_os = "macos")]
mod platform {
use std::ffi::c_void;
use std::sync::atomic::Ordering;
use std::sync::mpsc::Sender;
use std::sync::Arc;
use super::{
dispatch_hotkey_code, install_error, send_or_log, start_listener_thread,
update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
dispatch_hotkey_code, dispatch_translation_modifier_code, install_error,
is_shift_hotkey_code, send_or_log, start_listener_thread, update_shared_binding,
HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError};
@@ -392,23 +433,17 @@ mod platform {
fn handle_flags_changed(ctx: &CallbackContext, event: CgEventRef) {
let flags = unsafe { CGEventGetFlags(event) };
// Shift 是翻译模式修饰键 — 与触发键的 keycode 检查独立,任何时刻按 Shift 都生效。
let shift_active = (flags & FLAG_MASK_SHIFT) != 0;
let shift_was_held = ctx.shared.translation_modifier_held.load(Ordering::SeqCst);
if shift_active && !shift_was_held {
ctx.shared
.translation_modifier_held
.store(true, Ordering::SeqCst);
send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed);
} else if !shift_active && shift_was_held {
ctx.shared
.translation_modifier_held
.store(false, Ordering::SeqCst);
}
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
if let Some(code) = mac_keycode_to_hotkey_code(keycode) {
if is_shift_hotkey_code(code) {
// Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。
dispatch_translation_modifier_code(
&ctx.shared,
&ctx.tx,
code,
(flags & FLAG_MASK_SHIFT) != 0,
);
}
if let Some(mask) = mac_keycode_flag_mask(keycode) {
let family_active = (flags & mask) != 0;
let code_was_pressed = ctx.shared.pressed_codes.read().contains(code);
@@ -578,11 +613,67 @@ mod platform {
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::types::{HotkeyKey, HotkeyMode, HotkeyTrigger};
fn shared_with_binding(binding: HotkeyBinding) -> Shared {
Shared {
binding: RwLock::new(binding),
pressed_codes: RwLock::new(BTreeSet::new()),
trigger_held: AtomicBool::new(false),
translation_modifier_held: AtomicBool::new(false),
}
}
#[test]
fn shift_hotkey_press_does_not_emit_translation_modifier() {
let shared = shared_with_binding(HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Toggle,
keys: Some(vec![HotkeyKey::new("ShiftLeft")]),
});
let (tx, rx) = mpsc::channel();
dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true);
assert!(rx.try_recv().is_err());
dispatch_hotkey_code(&shared, &tx, "ShiftLeft", true);
assert!(matches!(rx.try_recv(), Ok(HotkeyEvent::Pressed)));
}
#[test]
fn unbound_shift_press_still_emits_translation_modifier_once_per_hold() {
let shared = shared_with_binding(HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Toggle,
keys: Some(vec![HotkeyKey::new("ControlRight")]),
});
let (tx, rx) = mpsc::channel();
dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true);
assert!(matches!(
rx.try_recv(),
Ok(HotkeyEvent::TranslationModifierPressed)
));
dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true);
assert!(rx.try_recv().is_err());
dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", false);
dispatch_translation_modifier_code(&shared, &tx, "ShiftLeft", true);
assert!(matches!(
rx.try_recv(),
Ok(HotkeyEvent::TranslationModifierPressed)
));
}
}
// ─────────────────────────── Windows implementation ───────────────────────────
#[cfg(target_os = "windows")]
mod platform {
use std::sync::atomic::Ordering;
use std::sync::atomic::{AtomicPtr, Ordering as AtomicOrdering};
use std::sync::mpsc::Sender;
use std::sync::Arc;
@@ -591,13 +682,14 @@ mod platform {
use windows::Win32::System::Threading::GetCurrentThreadId;
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, DispatchMessageW, GetMessageW, PostThreadMessageW, SetWindowsHookExW,
TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
MSLLHOOKSTRUCT, MSG, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_QUIT,
TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG,
MSLLHOOKSTRUCT, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_QUIT,
};
use super::{
dispatch_hotkey_code, install_error, send_or_log, start_listener_thread,
update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, send_or_log,
start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared,
StartupTx,
};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError};
@@ -830,28 +922,11 @@ mod platform {
return;
}
// Shift(任一侧)= 翻译模式修饰键。在录音过程中任意时刻按下都生效。详见 issue #4。
if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) {
match message {
WM_KEYDOWN | WM_SYSKEYDOWN => {
let was_held = ctx
.shared
.translation_modifier_held
.swap(true, Ordering::SeqCst);
if !was_held {
send_or_log(&ctx.tx, HotkeyEvent::TranslationModifierPressed);
}
}
WM_KEYUP | WM_SYSKEYUP => {
ctx.shared
.translation_modifier_held
.store(false, Ordering::SeqCst);
}
_ => {}
}
}
if let Some(code) = vk_to_hotkey_code(vk_code) {
if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) {
// Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。
dispatch_translation_modifier_code(&ctx.shared, &ctx.tx, code, is_down);
}
if is_down {
dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, true);
} else if is_up {
@@ -1008,8 +1083,9 @@ mod platform {
use rdev::{listen, Button, Event, EventType, Key};
use super::{
dispatch_hotkey_code, install_error, send_or_log, start_listener_thread,
update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
dispatch_hotkey_code, dispatch_translation_modifier_code, install_error, send_or_log,
start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared,
StartupTx,
};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError};
@@ -1082,26 +1158,19 @@ mod platform {
send_or_log(tx, HotkeyEvent::Cancelled);
return;
}
// Shift(任一侧)= 翻译模式修饰键。详见 issue #4。
if matches!(key, Key::ShiftLeft | Key::ShiftRight) {
let was_held = shared
.translation_modifier_held
.swap(true, Ordering::SeqCst);
if !was_held {
send_or_log(tx, HotkeyEvent::TranslationModifierPressed);
}
}
if let Some(code) = rdev_key_to_hotkey_code(key) {
if matches!(key, Key::ShiftLeft | Key::ShiftRight) {
// Shift 作为录音热键成员时只参与热键匹配,不再额外切到翻译模式。
dispatch_translation_modifier_code(shared, tx, code, true);
}
dispatch_hotkey_code(shared, tx, code, true);
}
}
EventType::KeyRelease(key) => {
if matches!(key, Key::ShiftLeft | Key::ShiftRight) {
shared
.translation_modifier_held
.store(false, Ordering::SeqCst);
}
if let Some(code) = rdev_key_to_hotkey_code(key) {
if matches!(key, Key::ShiftLeft | Key::ShiftRight) {
dispatch_translation_modifier_code(shared, tx, code, false);
}
dispatch_hotkey_code(shared, tx, code, false);
}
}