Merge branch 'codex-recording-hotkey-settings'

This commit is contained in:
millionart committed 2026-05-04 18:22:52 +08:00
commit 2d155912b1
15 files changed
+1325 -243

No files matched your search

@@ -642,6 +642,7 @@ mod tests {
hotkey: HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Toggle,
..Default::default()
},
qa_hotkey: Some(QaHotkeyBinding {
primary: ";".to_string(),
+99 -51
View File
@@ -8,7 +8,7 @@
use std::sync::atomic::{AtomicBool, Ordering};
use std::sync::mpsc;
use std::sync::Arc;
use std::time::Instant;
use std::time::{Duration, Instant};
use chrono::Utc;
use parking_lot::Mutex;
@@ -30,8 +30,8 @@ use crate::qa_hotkey::{QaHotkeyError, QaHotkeyEvent, QaHotkeyMonitor};
use crate::recorder::{Recorder, RecorderError};
use crate::selection::{capture_selection, SelectionContext};
use crate::types::{
CapsulePayload, CapsuleState, DictationSession, HotkeyCapability, HotkeyMode, HotkeyStatus,
HotkeyStatusState, InsertStatus, PolishMode,
CapsulePayload, CapsuleState, DictationSession, HotkeyBinding, HotkeyCapability, HotkeyMode,
HotkeyStatus, HotkeyStatusState, InsertStatus, PolishMode,
};
#[cfg(target_os = "windows")]
use crate::windows_ime_ipc::ImeSubmitTarget;
@@ -51,6 +51,8 @@ enum SessionPhase {
Inserting,
}
const HOTKEY_DOUBLE_CLICK_INTERVAL: Duration = Duration::from_millis(450);
enum ActiveAsr {
Volcengine(Arc<VolcengineStreamingASR>),
Whisper(Arc<WhisperBatchASR>),
@@ -127,6 +129,7 @@ struct Inner {
hotkey: Mutex<Option<HotkeyMonitor>>,
hotkey_status: Mutex<HotkeyStatus>,
hotkey_trigger_held: AtomicBool,
hotkey_last_click_at: Mutex<Option<Instant>>,
/// 翻译模式触发标志。每次 begin_session 重置为 false;hotkey 监听器在
/// Listening / Starting 阶段看到 Shift down 边沿时 set true。
/// end_session 在调 polish/translate 前读这个 flag + translation_target_language
@@ -222,6 +225,7 @@ impl Coordinator {
hotkey: Mutex::new(None),
hotkey_status: Mutex::new(HotkeyStatus::default()),
hotkey_trigger_held: AtomicBool::new(false),
hotkey_last_click_at: Mutex::new(None),
translation_modifier_seen: AtomicBool::new(false),
qa_hotkey: Mutex::new(None),
qa_state: Mutex::new(QaSessionState::default()),
@@ -698,6 +702,9 @@ 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);
if !was_held {
if !should_accept_pressed_edge(inner) {
return;
}
// 路由:QA 浮窗可见时,rightOption 边沿走 QA;否则走主听写。详见 issue #118 v2。
let panel_visible = inner.qa_state.lock().panel_visible;
if panel_visible {
@@ -708,15 +715,30 @@ async fn handle_pressed_edge(inner: &Arc<Inner>) {
}
}
fn should_accept_pressed_edge(inner: &Arc<Inner>) -> bool {
if inner.prefs.get().hotkey.mode != HotkeyMode::DoubleClick {
*inner.hotkey_last_click_at.lock() = None;
return true;
}
let now = Instant::now();
let mut last_click_at = inner.hotkey_last_click_at.lock();
let accepted = last_click_at
.map(|previous| now.duration_since(previous) <= HOTKEY_DOUBLE_CLICK_INTERVAL)
.unwrap_or(false);
*last_click_at = if accepted { None } else { Some(now) };
accepted
}
async fn handle_pressed(inner: &Arc<Inner>) {
let mode = inner.prefs.get().hotkey.mode;
let phase = inner.state.lock().phase;
log::info!("[coord] hotkey pressed (mode={mode:?}, phase={phase:?})");
match (mode, phase) {
(HotkeyMode::Toggle, SessionPhase::Idle) => {
(HotkeyMode::Toggle | HotkeyMode::DoubleClick, SessionPhase::Idle) => {
let _ = begin_session(inner).await;
}
(HotkeyMode::Toggle, SessionPhase::Listening) => {
(HotkeyMode::Toggle | HotkeyMode::DoubleClick, SessionPhase::Listening) => {
let _ = end_session(inner).await;
}
(HotkeyMode::Hold, SessionPhase::Idle) => {
@@ -724,7 +746,7 @@ async fn handle_pressed(inner: &Arc<Inner>) {
}
// Toggle 模式 Starting 阶段第二次按 → 用户想停。
// 不能直接 end_session(ASR session 还没建好),存边沿,握手完成后立即触发。
(HotkeyMode::Toggle, SessionPhase::Starting) => {
(HotkeyMode::Toggle | HotkeyMode::DoubleClick, SessionPhase::Starting) => {
request_stop_during_starting(inner, "toggle stop edge");
}
_ => {}
@@ -886,8 +908,8 @@ async fn handle_window_hotkey_event(
return Ok(());
}
let trigger = inner.prefs.get().hotkey.trigger;
if !window_key_matches_trigger(trigger, &key, &code) {
let binding = inner.prefs.get().hotkey;
if !window_key_matches_binding(&binding, &key, &code) {
return Ok(());
}
@@ -896,13 +918,11 @@ async fn handle_window_hotkey_event(
if repeat {
return Ok(());
}
log::info!(
"[window-hotkey] pressed trigger={trigger:?} code={code} repeat={repeat}"
);
log::info!("[window-hotkey] pressed code={code} repeat={repeat}");
handle_pressed_edge(inner).await;
}
"keyup" => {
log::info!("[window-hotkey] released trigger={trigger:?} code={code}");
log::info!("[window-hotkey] released code={code}");
handle_released_edge(inner).await;
}
_ => {}
@@ -916,18 +936,34 @@ fn window_hotkey_fallback_enabled() -> bool {
}
#[cfg(any(target_os = "windows", test))]
fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, key: &str, code: &str) -> bool {
use crate::types::HotkeyTrigger;
fn window_key_matches_binding(binding: &HotkeyBinding, key: &str, code: &str) -> bool {
let normalized = normalize_window_hotkey_code(key, code);
!normalized.is_empty()
&& binding
.effective_codes()
.iter()
.any(|candidate| candidate == &normalized)
}
match trigger {
HotkeyTrigger::RightControl => key == "Control" && code == "ControlRight",
HotkeyTrigger::LeftControl => key == "Control" && code == "ControlLeft",
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => {
(key == "Alt" || key == "AltGraph") && code == "AltRight"
#[cfg(any(target_os = "windows", test))]
fn normalize_window_hotkey_code(key: &str, code: &str) -> String {
if !code.is_empty() {
return code.to_string();
}
match key {
"Control" => "ControlLeft".into(),
"Alt" | "AltGraph" => "AltLeft".into(),
"Shift" => "ShiftLeft".into(),
"Meta" => "MetaLeft".into(),
" " => "Space".into(),
"ArrowUp" | "ArrowDown" | "ArrowLeft" | "ArrowRight" => key.into(),
other if other.len() == 1 && other.as_bytes()[0].is_ascii_alphabetic() => {
format!("Key{}", other.to_ascii_uppercase())
}
HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltRight",
HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight",
HotkeyTrigger::Fn => key == "Control" && code == "ControlRight",
other if other.len() == 1 && other.as_bytes()[0].is_ascii_digit() => {
format!("Digit{other}")
}
other => other.into(),
}
}
@@ -2548,7 +2584,7 @@ fn resolve_ark_endpoint_with_policy(
#[cfg(test)]
mod tests {
use super::*;
use crate::types::HotkeyTrigger;
use crate::types::{HotkeyKey, HotkeyTrigger};
#[tokio::test]
async fn hotkey_injection_gate_logs_pressed_and_cancels() {
@@ -2566,35 +2602,21 @@ mod tests {
}
#[test]
fn window_key_matcher_mirrors_windows_trigger_aliases() {
let cases = [
(HotkeyTrigger::RightControl, "Control", "ControlRight"),
(HotkeyTrigger::LeftControl, "Control", "ControlLeft"),
(HotkeyTrigger::RightOption, "Alt", "AltRight"),
(HotkeyTrigger::RightAlt, "AltGraph", "AltRight"),
(HotkeyTrigger::RightCommand, "Meta", "MetaRight"),
// Mirrors Windows trigger_to_vk_code aliases.
(HotkeyTrigger::LeftOption, "Alt", "AltRight"),
(HotkeyTrigger::Fn, "Control", "ControlRight"),
];
for (trigger, key, code) in cases {
assert!(
window_key_matches_trigger(trigger, key, code),
"{trigger:?} should match {key}/{code}"
);
}
fn window_key_matcher_accepts_legacy_and_configured_codes() {
let legacy = HotkeyBinding {
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_trigger(
HotkeyTrigger::RightControl,
"Control",
"ControlLeft"
));
assert!(!window_key_matches_trigger(
HotkeyTrigger::LeftOption,
"Alt",
"AltLeft"
));
assert!(!window_key_matches_trigger(HotkeyTrigger::Fn, "Fn", "Fn"));
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"));
}
#[test]
@@ -2710,6 +2732,31 @@ mod tests {
assert_eq!(state.session_id, 41);
}
#[test]
fn double_click_mode_requires_second_press_within_window() {
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();
assert!(!should_accept_pressed_edge(&coordinator.inner));
assert!(should_accept_pressed_edge(&coordinator.inner));
assert!(!should_accept_pressed_edge(&coordinator.inner));
*coordinator.inner.hotkey_last_click_at.lock() =
Some(Instant::now() - HOTKEY_DOUBLE_CLICK_INTERVAL - Duration::from_millis(1));
assert!(!should_accept_pressed_edge(&coordinator.inner));
}
#[tokio::test]
async fn repeated_pressed_edge_during_hold_session_does_not_restart() {
let coordinator = Coordinator::new();
@@ -2720,6 +2767,7 @@ mod tests {
hotkey: crate::types::HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Hold,
..Default::default()
},
..Default::default()
})
+565 -113
View File
@@ -10,6 +10,7 @@
//!
//! 仅产出"边沿"事件,toggle vs hold 由 Coordinator 解释。
use std::collections::BTreeSet;
use std::sync::atomic::AtomicBool;
use std::sync::mpsc::{self, Sender};
use std::sync::Arc;
@@ -37,6 +38,7 @@ pub trait HotkeyAdapter: Send + Sync {
struct Shared {
binding: RwLock<HotkeyBinding>,
pressed_codes: RwLock<BTreeSet<String>>,
/// 触发键当前是否处于"按住"状态。OS 自动重复事件用此去重。
trigger_held: AtomicBool,
/// Shift(翻译修饰键)当前是否按住。用于在 FLAGS_CHANGED 上识别 down 边沿
@@ -113,6 +115,7 @@ where
{
let shared = Arc::new(Shared {
binding: RwLock::new(binding),
pressed_codes: RwLock::new(BTreeSet::new()),
trigger_held: AtomicBool::new(false),
translation_modifier_held: AtomicBool::new(false),
});
@@ -133,11 +136,44 @@ where
fn update_shared_binding(shared: &Shared, binding: HotkeyBinding) {
*shared.binding.write() = binding;
shared.pressed_codes.write().clear();
shared
.trigger_held
.store(false, std::sync::atomic::Ordering::SeqCst);
}
fn dispatch_hotkey_code(shared: &Shared, tx: &Sender<HotkeyEvent>, code: &str, pressed: bool) {
if code.is_empty() {
return;
}
let binding = shared.binding.read().clone();
let active_after = {
let mut pressed_codes = shared.pressed_codes.write();
if pressed {
pressed_codes.insert(code.to_string());
} else {
pressed_codes.remove(code);
}
binding_matches_pressed_codes(&binding, &pressed_codes)
};
let was_active = shared
.trigger_held
.swap(active_after, std::sync::atomic::Ordering::SeqCst);
if active_after && !was_active {
send_or_log(tx, HotkeyEvent::Pressed);
} else if !active_after && was_active {
send_or_log(tx, HotkeyEvent::Released);
}
}
fn binding_matches_pressed_codes(binding: &HotkeyBinding, pressed_codes: &BTreeSet<String>) -> bool {
let codes = binding.effective_codes();
!codes.is_empty()
&& codes
.iter()
.all(|code| pressed_codes.contains(code.as_str()))
}
// ─────────────────────────── macOS implementation ───────────────────────────
#[cfg(target_os = "macos")]
@@ -148,10 +184,10 @@ mod platform {
use std::sync::Arc;
use super::{
install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter,
HotkeyEvent, Shared, StartupTx,
dispatch_hotkey_code, install_error, send_or_log, start_listener_thread,
update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError};
pub fn start_adapter(
binding: HotkeyBinding,
@@ -218,12 +254,17 @@ mod platform {
const TAP_OPTION_DEFAULT: CgEventTapOptions = 0;
const KEY_DOWN: CgEventType = 10;
const KEY_UP: CgEventType = 11;
const FLAGS_CHANGED: CgEventType = 12;
const OTHER_MOUSE_DOWN: CgEventType = 25;
const OTHER_MOUSE_UP: CgEventType = 26;
const TAP_DISABLED_BY_TIMEOUT: CgEventType = 0xFFFF_FFFE;
const TAP_DISABLED_BY_USER_INPUT: CgEventType = 0xFFFF_FFFF;
const MOUSE_EVENT_BUTTON_NUMBER: CgEventField = 3;
const KEYBOARD_EVENT_KEYCODE: CgEventField = 9;
const FLAG_MASK_ALPHA_SHIFT: CgEventFlags = 0x0001_0000;
const FLAG_MASK_SHIFT: CgEventFlags = 0x0002_0000;
const FLAG_MASK_CONTROL: CgEventFlags = 0x0004_0000;
const FLAG_MASK_ALTERNATE: CgEventFlags = 0x0008_0000;
@@ -277,7 +318,11 @@ mod platform {
unsafe impl Sync for CallbackContext {}
fn run_listen_loop(shared: Arc<Shared>, tx: Sender<HotkeyEvent>, status_tx: StartupTx<()>) {
let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN);
let mask: CgEventMask = (1u64 << FLAGS_CHANGED)
| (1u64 << KEY_DOWN)
| (1u64 << KEY_UP)
| (1u64 << OTHER_MOUSE_DOWN)
| (1u64 << OTHER_MOUSE_UP);
let context = Box::into_raw(Box::new(CallbackContext {
shared,
tx,
@@ -337,6 +382,9 @@ mod platform {
}
FLAGS_CHANGED => handle_flags_changed(ctx, event),
KEY_DOWN => handle_key_down(ctx, event),
KEY_UP => handle_key_up(ctx, event),
OTHER_MOUSE_DOWN => handle_mouse_button(ctx, event, true),
OTHER_MOUSE_UP => handle_mouse_button(ctx, event, false),
_ => {}
}
event
@@ -360,21 +408,17 @@ mod platform {
}
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
let trigger = ctx.shared.binding.read().trigger;
let expected_keycode = trigger_to_keycode(trigger);
if keycode != expected_keycode {
return;
}
let mask = trigger_to_flag_mask(trigger);
let is_active = (flags & mask) != 0;
let was_held = ctx.shared.trigger_held.load(Ordering::SeqCst);
if is_active && !was_held {
ctx.shared.trigger_held.store(true, Ordering::SeqCst);
send_or_log(&ctx.tx, HotkeyEvent::Pressed);
} else if !is_active && was_held {
ctx.shared.trigger_held.store(false, Ordering::SeqCst);
send_or_log(&ctx.tx, HotkeyEvent::Released);
if let Some(code) = mac_keycode_to_hotkey_code(keycode) {
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);
dispatch_hotkey_code(
&ctx.shared,
&ctx.tx,
code,
family_active && !code_was_pressed,
);
}
}
}
@@ -382,28 +426,154 @@ mod platform {
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
if keycode == ESC_KEYCODE {
send_or_log(&ctx.tx, HotkeyEvent::Cancelled);
return;
}
}
fn trigger_to_keycode(trigger: HotkeyTrigger) -> i64 {
match trigger {
HotkeyTrigger::LeftControl => 59,
HotkeyTrigger::RightControl => 62,
HotkeyTrigger::LeftOption => 58,
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => 61,
HotkeyTrigger::RightCommand => 54,
HotkeyTrigger::Fn => 63,
}
}
fn trigger_to_flag_mask(trigger: HotkeyTrigger) -> CgEventFlags {
match trigger {
HotkeyTrigger::LeftControl | HotkeyTrigger::RightControl => FLAG_MASK_CONTROL,
HotkeyTrigger::RightCommand => FLAG_MASK_COMMAND,
HotkeyTrigger::LeftOption | HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => {
FLAG_MASK_ALTERNATE
if let Some(code) = mac_keycode_to_hotkey_code(keycode) {
if mac_keycode_flag_mask(keycode).is_none() {
dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, true);
}
HotkeyTrigger::Fn => FLAG_MASK_SECONDARY_FN,
}
}
fn handle_key_up(ctx: &CallbackContext, event: CgEventRef) {
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
if let Some(code) = mac_keycode_to_hotkey_code(keycode) {
if mac_keycode_flag_mask(keycode).is_none() {
dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, false);
}
}
}
fn handle_mouse_button(ctx: &CallbackContext, event: CgEventRef, pressed: bool) {
let button = unsafe { CGEventGetIntegerValueField(event, MOUSE_EVENT_BUTTON_NUMBER) };
let code = match button {
3 => "Mouse4",
4 => "Mouse5",
_ => return,
};
dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, pressed);
}
fn mac_keycode_flag_mask(keycode: i64) -> Option<CgEventFlags> {
match keycode {
54 | 55 => Some(FLAG_MASK_COMMAND),
56 | 60 => Some(FLAG_MASK_SHIFT),
57 => Some(FLAG_MASK_ALPHA_SHIFT),
58 | 61 => Some(FLAG_MASK_ALTERNATE),
59 | 62 => Some(FLAG_MASK_CONTROL),
63 => Some(FLAG_MASK_SECONDARY_FN),
_ => None,
}
}
fn mac_keycode_to_hotkey_code(keycode: i64) -> Option<&'static str> {
match keycode {
0 => Some("KeyA"),
1 => Some("KeyS"),
2 => Some("KeyD"),
3 => Some("KeyF"),
4 => Some("KeyH"),
5 => Some("KeyG"),
6 => Some("KeyZ"),
7 => Some("KeyX"),
8 => Some("KeyC"),
9 => Some("KeyV"),
11 => Some("KeyB"),
12 => Some("KeyQ"),
13 => Some("KeyW"),
14 => Some("KeyE"),
15 => Some("KeyR"),
16 => Some("KeyY"),
17 => Some("KeyT"),
18 => Some("Digit1"),
19 => Some("Digit2"),
20 => Some("Digit3"),
21 => Some("Digit4"),
22 => Some("Digit6"),
23 => Some("Digit5"),
24 => Some("Equal"),
25 => Some("Digit9"),
26 => Some("Digit7"),
27 => Some("Minus"),
28 => Some("Digit8"),
29 => Some("Digit0"),
30 => Some("BracketRight"),
31 => Some("KeyO"),
32 => Some("KeyU"),
33 => Some("BracketLeft"),
34 => Some("KeyI"),
35 => Some("KeyP"),
36 => Some("Enter"),
37 => Some("KeyL"),
38 => Some("KeyJ"),
39 => Some("Quote"),
40 => Some("KeyK"),
41 => Some("Semicolon"),
42 => Some("Backslash"),
43 => Some("Comma"),
44 => Some("Slash"),
45 => Some("KeyN"),
46 => Some("KeyM"),
47 => Some("Period"),
48 => Some("Tab"),
49 => Some("Space"),
50 => Some("Backquote"),
51 => Some("Backspace"),
54 => Some("MetaRight"),
55 => Some("MetaLeft"),
56 => Some("ShiftLeft"),
57 => Some("CapsLock"),
58 => Some("AltLeft"),
59 => Some("ControlLeft"),
60 => Some("ShiftRight"),
61 => Some("AltRight"),
62 => Some("ControlRight"),
63 => Some("Fn"),
64 => Some("F17"),
65 => Some("NumpadDecimal"),
67 => Some("NumpadMultiply"),
69 => Some("NumpadAdd"),
75 => Some("NumpadDivide"),
76 => Some("NumpadEnter"),
78 => Some("NumpadSubtract"),
79 => Some("F18"),
80 => Some("F19"),
82 => Some("Numpad0"),
83 => Some("Numpad1"),
84 => Some("Numpad2"),
85 => Some("Numpad3"),
86 => Some("Numpad4"),
87 => Some("Numpad5"),
88 => Some("Numpad6"),
89 => Some("Numpad7"),
91 => Some("Numpad8"),
92 => Some("Numpad9"),
96 => Some("F5"),
97 => Some("F6"),
98 => Some("F7"),
99 => Some("F3"),
100 => Some("F8"),
101 => Some("F9"),
103 => Some("F11"),
105 => Some("F13"),
106 => Some("F16"),
107 => Some("F14"),
109 => Some("F10"),
111 => Some("F12"),
113 => Some("F15"),
115 => Some("Home"),
116 => Some("PageUp"),
117 => Some("Delete"),
118 => Some("F4"),
119 => Some("End"),
120 => Some("F2"),
121 => Some("PageDown"),
122 => Some("F1"),
123 => Some("ArrowLeft"),
124 => Some("ArrowRight"),
125 => Some("ArrowDown"),
126 => Some("ArrowUp"),
_ => None,
}
}
}
@@ -421,29 +591,72 @@ mod platform {
use windows::Win32::System::Threading::GetCurrentThreadId;
use windows::Win32::UI::WindowsAndMessaging::{
CallNextHookEx, DispatchMessageW, GetMessageW, PostThreadMessageW, SetWindowsHookExW,
TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT, MSG,
WH_KEYBOARD_LL, WM_QUIT,
TranslateMessage, UnhookWindowsHookEx, HC_ACTION, HHOOK, KBDLLHOOKSTRUCT,
MSLLHOOKSTRUCT, MSG, WH_KEYBOARD_LL, WH_MOUSE_LL, WM_QUIT,
};
use super::{
install_error, send_or_log, start_listener_thread, update_shared_binding, HotkeyAdapter,
HotkeyEvent, Shared, StartupTx,
dispatch_hotkey_code, install_error, send_or_log, start_listener_thread,
update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError};
const WM_KEYDOWN: usize = 0x0100;
const WM_KEYUP: usize = 0x0101;
const WM_SYSKEYDOWN: usize = 0x0104;
const WM_SYSKEYUP: usize = 0x0105;
const WM_XBUTTONDOWN: usize = 0x020B;
const WM_XBUTTONUP: usize = 0x020C;
const VK_BACK: u32 = 0x08;
const VK_TAB: u32 = 0x09;
const VK_RETURN: u32 = 0x0D;
const VK_ESCAPE: u32 = 0x1B;
const VK_SHIFT: u32 = 0x10;
const VK_CONTROL: u32 = 0x11;
const VK_MENU: u32 = 0x12;
const VK_PAUSE: u32 = 0x13;
const VK_CAPITAL: u32 = 0x14;
const VK_SPACE: u32 = 0x20;
const VK_PRIOR: u32 = 0x21;
const VK_NEXT: u32 = 0x22;
const VK_END: u32 = 0x23;
const VK_HOME: u32 = 0x24;
const VK_LEFT: u32 = 0x25;
const VK_UP: u32 = 0x26;
const VK_RIGHT: u32 = 0x27;
const VK_DOWN: u32 = 0x28;
const VK_SNAPSHOT: u32 = 0x2C;
const VK_INSERT: u32 = 0x2D;
const VK_DELETE: u32 = 0x2E;
const VK_APPS: u32 = 0x5D;
const VK_LWIN: u32 = 0x5B;
const VK_RWIN: u32 = 0x5C;
const VK_MULTIPLY: u32 = 0x6A;
const VK_ADD: u32 = 0x6B;
const VK_SUBTRACT: u32 = 0x6D;
const VK_DECIMAL: u32 = 0x6E;
const VK_DIVIDE: u32 = 0x6F;
const VK_SCROLL: u32 = 0x91;
const VK_LSHIFT: u32 = 0xA0;
const VK_RSHIFT: u32 = 0xA1;
const VK_LCONTROL: u32 = 0xA2;
const VK_RCONTROL: u32 = 0xA3;
const VK_LMENU: u32 = 0xA4;
const VK_RMENU: u32 = 0xA5;
const VK_RWIN: u32 = 0x5C;
const VK_OEM_1: u32 = 0xBA;
const VK_OEM_PLUS: u32 = 0xBB;
const VK_OEM_COMMA: u32 = 0xBC;
const VK_OEM_MINUS: u32 = 0xBD;
const VK_OEM_PERIOD: u32 = 0xBE;
const VK_OEM_2: u32 = 0xBF;
const VK_OEM_3: u32 = 0xC0;
const VK_OEM_4: u32 = 0xDB;
const VK_OEM_5: u32 = 0xDC;
const VK_OEM_6: u32 = 0xDD;
const VK_OEM_7: u32 = 0xDE;
const XBUTTON1: u32 = 0x0001;
const XBUTTON2: u32 = 0x0002;
const LLKHF_INJECTED: u32 = 0x0000_0010;
const ACCEPT_INJECTED_ENV: &str = "OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS";
@@ -493,7 +706,8 @@ mod platform {
struct CallbackContext {
shared: Arc<Shared>,
tx: Sender<HotkeyEvent>,
hook: std::sync::Mutex<Option<HHOOK>>,
keyboard_hook: std::sync::Mutex<Option<HHOOK>>,
mouse_hook: std::sync::Mutex<Option<HHOOK>>,
}
unsafe impl Send for CallbackContext {}
@@ -504,7 +718,8 @@ mod platform {
let context = Box::into_raw(Box::new(CallbackContext {
shared,
tx,
hook: std::sync::Mutex::new(None),
keyboard_hook: std::sync::Mutex::new(None),
mouse_hook: std::sync::Mutex::new(None),
}));
HOOK_CONTEXT.store(context, AtomicOrdering::SeqCst);
@@ -512,9 +727,8 @@ mod platform {
let hook = SetWindowsHookExW(WH_KEYBOARD_LL, Some(low_level_keyboard_proc), None, 0);
match hook {
Ok(hook) => {
*(*context).hook.lock().unwrap() = Some(hook);
*(*context).keyboard_hook.lock().unwrap() = Some(hook);
log::info!("[hotkey] Windows low-level keyboard hook 已启动");
let _ = status_tx.send(Ok(thread_id));
}
Err(err) => {
HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst);
@@ -527,6 +741,19 @@ mod platform {
}
}
match SetWindowsHookExW(WH_MOUSE_LL, Some(low_level_mouse_proc), None, 0) {
Ok(hook) => {
*(*context).mouse_hook.lock().unwrap() = Some(hook);
log::info!("[hotkey] Windows low-level mouse hook installed");
}
Err(err) => {
log::warn!(
"[hotkey] Windows low-level mouse hook install failed; Mouse4/Mouse5 hotkeys will be unavailable: {err}"
);
}
}
let _ = status_tx.send(Ok(thread_id));
let mut message = MSG::default();
loop {
let result = GetMessageW(&mut message, None, 0, 0).0;
@@ -542,7 +769,10 @@ mod platform {
let _ = DispatchMessageW(&message);
}
if let Some(hook) = (*context).hook.lock().unwrap().take() {
if let Some(hook) = (*context).keyboard_hook.lock().unwrap().take() {
let _ = UnhookWindowsHookEx(hook);
}
if let Some(hook) = (*context).mouse_hook.lock().unwrap().take() {
let _ = UnhookWindowsHookEx(hook);
}
HOOK_CONTEXT.store(std::ptr::null_mut(), AtomicOrdering::SeqCst);
@@ -567,6 +797,21 @@ mod platform {
CallNextHookEx(None, code, wparam, lparam)
}
unsafe extern "system" fn low_level_mouse_proc(
code: i32,
wparam: WPARAM,
lparam: LPARAM,
) -> LRESULT {
if code == HC_ACTION as i32 && lparam.0 != 0 {
if let Some(ctx) = callback_context() {
let mouse = *(lparam.0 as *const MSLLHOOKSTRUCT);
dispatch_mouse_event(ctx, mouse.mouseData, wparam.0);
}
}
CallNextHookEx(None, code, wparam, lparam)
}
unsafe fn callback_context<'a>() -> Option<&'a CallbackContext> {
let ptr = HOOK_CONTEXT.load(AtomicOrdering::SeqCst);
if ptr.is_null() {
@@ -577,7 +822,10 @@ mod platform {
}
fn dispatch_keyboard_event(ctx: &CallbackContext, vk_code: u32, message: usize) {
if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) {
let is_down = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN);
let is_up = matches!(message, WM_KEYUP | WM_SYSKEYUP);
if vk_code == VK_ESCAPE && is_down {
send_or_log(&ctx.tx, HotkeyEvent::Cancelled);
return;
}
@@ -601,47 +849,145 @@ mod platform {
}
_ => {}
}
return;
}
let trigger = ctx.shared.binding.read().trigger;
if vk_code != trigger_to_vk_code(trigger) {
return;
}
match message {
WM_KEYDOWN | WM_SYSKEYDOWN => {
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);
}
if let Some(code) = vk_to_hotkey_code(vk_code) {
if is_down {
dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, true);
} else if is_up {
dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, false);
}
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);
}
}
_ => {}
}
}
fn trigger_to_vk_code(trigger: HotkeyTrigger) -> u32 {
// Windows only gives us a small set of modifier virtual keys that can be
// used as reliable modifier-only global triggers, so the cross-platform
// trigger list intentionally collapses a few aliases onto the same
// physical Windows key:
// - LeftOption reuses RightAlt / VK_RMENU
// - Fn reuses RightControl / VK_RCONTROL
match trigger {
HotkeyTrigger::RightControl => VK_RCONTROL,
HotkeyTrigger::LeftControl => VK_LCONTROL,
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => VK_RMENU,
HotkeyTrigger::RightCommand => VK_RWIN,
HotkeyTrigger::LeftOption => VK_RMENU,
HotkeyTrigger::Fn => VK_RCONTROL,
fn dispatch_mouse_event(ctx: &CallbackContext, mouse_data: u32, message: usize) {
let code = match ((mouse_data >> 16) & 0xffff, message) {
(XBUTTON1, WM_XBUTTONDOWN | WM_XBUTTONUP) => "Mouse4",
(XBUTTON2, WM_XBUTTONDOWN | WM_XBUTTONUP) => "Mouse5",
_ => return,
};
dispatch_hotkey_code(&ctx.shared, &ctx.tx, code, message == WM_XBUTTONDOWN);
}
fn vk_to_hotkey_code(vk_code: u32) -> Option<&'static str> {
match vk_code {
VK_SHIFT => Some("ShiftLeft"),
VK_LSHIFT => Some("ShiftLeft"),
VK_RSHIFT => Some("ShiftRight"),
VK_CONTROL => Some("ControlLeft"),
VK_LCONTROL => Some("ControlLeft"),
VK_RCONTROL => Some("ControlRight"),
VK_MENU => Some("AltLeft"),
VK_LMENU => Some("AltLeft"),
VK_RMENU => Some("AltRight"),
VK_LWIN => Some("MetaLeft"),
VK_RWIN => Some("MetaRight"),
VK_BACK => Some("Backspace"),
VK_TAB => Some("Tab"),
VK_RETURN => Some("Enter"),
VK_CAPITAL => Some("CapsLock"),
VK_PAUSE => Some("Pause"),
VK_SPACE => Some("Space"),
VK_PRIOR => Some("PageUp"),
VK_NEXT => Some("PageDown"),
VK_END => Some("End"),
VK_HOME => Some("Home"),
VK_LEFT => Some("ArrowLeft"),
VK_UP => Some("ArrowUp"),
VK_RIGHT => Some("ArrowRight"),
VK_DOWN => Some("ArrowDown"),
VK_SNAPSHOT => Some("PrintScreen"),
VK_INSERT => Some("Insert"),
VK_DELETE => Some("Delete"),
VK_APPS => Some("ContextMenu"),
VK_MULTIPLY => Some("NumpadMultiply"),
VK_ADD => Some("NumpadAdd"),
VK_SUBTRACT => Some("NumpadSubtract"),
VK_DECIMAL => Some("NumpadDecimal"),
VK_DIVIDE => Some("NumpadDivide"),
VK_SCROLL => Some("ScrollLock"),
VK_OEM_1 => Some("Semicolon"),
VK_OEM_PLUS => Some("Equal"),
VK_OEM_COMMA => Some("Comma"),
VK_OEM_MINUS => Some("Minus"),
VK_OEM_PERIOD => Some("Period"),
VK_OEM_2 => Some("Slash"),
VK_OEM_3 => Some("Backquote"),
VK_OEM_4 => Some("BracketLeft"),
VK_OEM_5 => Some("Backslash"),
VK_OEM_6 => Some("BracketRight"),
VK_OEM_7 => Some("Quote"),
0x30 => Some("Digit0"),
0x31 => Some("Digit1"),
0x32 => Some("Digit2"),
0x33 => Some("Digit3"),
0x34 => Some("Digit4"),
0x35 => Some("Digit5"),
0x36 => Some("Digit6"),
0x37 => Some("Digit7"),
0x38 => Some("Digit8"),
0x39 => Some("Digit9"),
0x41 => Some("KeyA"),
0x42 => Some("KeyB"),
0x43 => Some("KeyC"),
0x44 => Some("KeyD"),
0x45 => Some("KeyE"),
0x46 => Some("KeyF"),
0x47 => Some("KeyG"),
0x48 => Some("KeyH"),
0x49 => Some("KeyI"),
0x4A => Some("KeyJ"),
0x4B => Some("KeyK"),
0x4C => Some("KeyL"),
0x4D => Some("KeyM"),
0x4E => Some("KeyN"),
0x4F => Some("KeyO"),
0x50 => Some("KeyP"),
0x51 => Some("KeyQ"),
0x52 => Some("KeyR"),
0x53 => Some("KeyS"),
0x54 => Some("KeyT"),
0x55 => Some("KeyU"),
0x56 => Some("KeyV"),
0x57 => Some("KeyW"),
0x58 => Some("KeyX"),
0x59 => Some("KeyY"),
0x5A => Some("KeyZ"),
0x60 => Some("Numpad0"),
0x61 => Some("Numpad1"),
0x62 => Some("Numpad2"),
0x63 => Some("Numpad3"),
0x64 => Some("Numpad4"),
0x65 => Some("Numpad5"),
0x66 => Some("Numpad6"),
0x67 => Some("Numpad7"),
0x68 => Some("Numpad8"),
0x69 => Some("Numpad9"),
0x70 => Some("F1"),
0x71 => Some("F2"),
0x72 => Some("F3"),
0x73 => Some("F4"),
0x74 => Some("F5"),
0x75 => Some("F6"),
0x76 => Some("F7"),
0x77 => Some("F8"),
0x78 => Some("F9"),
0x79 => Some("F10"),
0x7A => Some("F11"),
0x7B => Some("F12"),
0x7C => Some("F13"),
0x7D => Some("F14"),
0x7E => Some("F15"),
0x7F => Some("F16"),
0x80 => Some("F17"),
0x81 => Some("F18"),
0x82 => Some("F19"),
0x83 => Some("F20"),
0x84 => Some("F21"),
0x85 => Some("F22"),
0x86 => Some("F23"),
0x87 => Some("F24"),
_ => None,
}
}
@@ -659,13 +1005,13 @@ mod platform {
use std::sync::Arc;
use std::time::Duration;
use rdev::{listen, Event, EventType, Key};
use rdev::{listen, Button, Event, EventType, Key};
use super::{
install_error, start_listener_thread, update_shared_binding, HotkeyAdapter, HotkeyEvent,
Shared, StartupTx,
dispatch_hotkey_code, install_error, send_or_log, start_listener_thread,
update_shared_binding, HotkeyAdapter, HotkeyEvent, Shared, StartupTx,
};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError, HotkeyTrigger};
use crate::types::{HotkeyAdapterKind, HotkeyBinding, HotkeyInstallError};
pub fn start_adapter(
binding: HotkeyBinding,
@@ -730,11 +1076,10 @@ mod platform {
}
fn dispatch_event(shared: &Shared, tx: &Sender<HotkeyEvent>, event: Event) {
let trigger = shared.binding.read().trigger;
match event.event_type {
EventType::KeyPress(key) => {
if key == Key::Escape {
let _ = tx.send(HotkeyEvent::Cancelled);
send_or_log(tx, HotkeyEvent::Cancelled);
return;
}
// Shift(任一侧)= 翻译模式修饰键。详见 issue #4。
@@ -743,15 +1088,11 @@ mod platform {
.translation_modifier_held
.swap(true, Ordering::SeqCst);
if !was_held {
let _ = tx.send(HotkeyEvent::TranslationModifierPressed);
send_or_log(tx, HotkeyEvent::TranslationModifierPressed);
}
return;
}
if key == trigger_to_rdev_key(trigger) {
let was_held = shared.trigger_held.swap(true, Ordering::SeqCst);
if !was_held {
let _ = tx.send(HotkeyEvent::Pressed);
}
if let Some(code) = rdev_key_to_hotkey_code(key) {
dispatch_hotkey_code(shared, tx, code, true);
}
}
EventType::KeyRelease(key) => {
@@ -759,27 +1100,138 @@ mod platform {
shared
.translation_modifier_held
.store(false, Ordering::SeqCst);
return;
}
if key == trigger_to_rdev_key(trigger) {
let was_held = shared.trigger_held.swap(false, Ordering::SeqCst);
if was_held {
let _ = tx.send(HotkeyEvent::Released);
}
if let Some(code) = rdev_key_to_hotkey_code(key) {
dispatch_hotkey_code(shared, tx, code, false);
}
}
EventType::ButtonPress(button) => {
if let Some(code) = rdev_button_to_hotkey_code(button) {
dispatch_hotkey_code(shared, tx, code, true);
}
}
EventType::ButtonRelease(button) => {
if let Some(code) = rdev_button_to_hotkey_code(button) {
dispatch_hotkey_code(shared, tx, code, false);
}
}
_ => {}
}
}
fn trigger_to_rdev_key(trigger: HotkeyTrigger) -> Key {
match trigger {
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => Key::AltGr,
HotkeyTrigger::LeftOption => Key::Alt,
HotkeyTrigger::RightControl => Key::ControlRight,
HotkeyTrigger::LeftControl => Key::ControlLeft,
HotkeyTrigger::RightCommand => Key::MetaRight,
HotkeyTrigger::Fn => Key::Function,
fn rdev_button_to_hotkey_code(button: Button) -> Option<&'static str> {
match button {
Button::Unknown(4) | Button::Unknown(8) => Some("Mouse4"),
Button::Unknown(5) | Button::Unknown(9) => Some("Mouse5"),
_ => None,
}
}
fn rdev_key_to_hotkey_code(key: Key) -> Option<&'static str> {
match key {
Key::Alt => Some("AltLeft"),
Key::AltGr => Some("AltRight"),
Key::Backspace => Some("Backspace"),
Key::CapsLock => Some("CapsLock"),
Key::ControlLeft => Some("ControlLeft"),
Key::ControlRight => Some("ControlRight"),
Key::Delete => Some("Delete"),
Key::DownArrow => Some("ArrowDown"),
Key::End => Some("End"),
Key::F1 => Some("F1"),
Key::F2 => Some("F2"),
Key::F3 => Some("F3"),
Key::F4 => Some("F4"),
Key::F5 => Some("F5"),
Key::F6 => Some("F6"),
Key::F7 => Some("F7"),
Key::F8 => Some("F8"),
Key::F9 => Some("F9"),
Key::F10 => Some("F10"),
Key::F11 => Some("F11"),
Key::F12 => Some("F12"),
Key::Home => Some("Home"),
Key::LeftArrow => Some("ArrowLeft"),
Key::MetaLeft => Some("MetaLeft"),
Key::MetaRight => Some("MetaRight"),
Key::PageDown => Some("PageDown"),
Key::PageUp => Some("PageUp"),
Key::Return => Some("Enter"),
Key::RightArrow => Some("ArrowRight"),
Key::ShiftLeft => Some("ShiftLeft"),
Key::ShiftRight => Some("ShiftRight"),
Key::Space => Some("Space"),
Key::Tab => Some("Tab"),
Key::UpArrow => Some("ArrowUp"),
Key::PrintScreen => Some("PrintScreen"),
Key::ScrollLock => Some("ScrollLock"),
Key::Pause => Some("Pause"),
Key::BackQuote => Some("Backquote"),
Key::Num0 => Some("Digit0"),
Key::Num1 => Some("Digit1"),
Key::Num2 => Some("Digit2"),
Key::Num3 => Some("Digit3"),
Key::Num4 => Some("Digit4"),
Key::Num5 => Some("Digit5"),
Key::Num6 => Some("Digit6"),
Key::Num7 => Some("Digit7"),
Key::Num8 => Some("Digit8"),
Key::Num9 => Some("Digit9"),
Key::Minus => Some("Minus"),
Key::Equal => Some("Equal"),
Key::KeyA => Some("KeyA"),
Key::KeyB => Some("KeyB"),
Key::KeyC => Some("KeyC"),
Key::KeyD => Some("KeyD"),
Key::KeyE => Some("KeyE"),
Key::KeyF => Some("KeyF"),
Key::KeyG => Some("KeyG"),
Key::KeyH => Some("KeyH"),
Key::KeyI => Some("KeyI"),
Key::KeyJ => Some("KeyJ"),
Key::KeyK => Some("KeyK"),
Key::KeyL => Some("KeyL"),
Key::KeyM => Some("KeyM"),
Key::KeyN => Some("KeyN"),
Key::KeyO => Some("KeyO"),
Key::KeyP => Some("KeyP"),
Key::KeyQ => Some("KeyQ"),
Key::KeyR => Some("KeyR"),
Key::KeyS => Some("KeyS"),
Key::KeyT => Some("KeyT"),
Key::KeyU => Some("KeyU"),
Key::KeyV => Some("KeyV"),
Key::KeyW => Some("KeyW"),
Key::KeyX => Some("KeyX"),
Key::KeyY => Some("KeyY"),
Key::KeyZ => Some("KeyZ"),
Key::LeftBracket => Some("BracketLeft"),
Key::RightBracket => Some("BracketRight"),
Key::SemiColon => Some("Semicolon"),
Key::Quote => Some("Quote"),
Key::BackSlash | Key::IntlBackslash => Some("Backslash"),
Key::Comma => Some("Comma"),
Key::Dot => Some("Period"),
Key::Slash => Some("Slash"),
Key::Insert => Some("Insert"),
Key::KpReturn => Some("NumpadEnter"),
Key::KpMinus => Some("NumpadSubtract"),
Key::KpPlus => Some("NumpadAdd"),
Key::KpMultiply => Some("NumpadMultiply"),
Key::KpDivide => Some("NumpadDivide"),
Key::Kp0 => Some("Numpad0"),
Key::Kp1 => Some("Numpad1"),
Key::Kp2 => Some("Numpad2"),
Key::Kp3 => Some("Numpad3"),
Key::Kp4 => Some("Numpad4"),
Key::Kp5 => Some("Numpad5"),
Key::Kp6 => Some("Numpad6"),
Key::Kp7 => Some("Numpad7"),
Key::Kp8 => Some("Numpad8"),
Key::Kp9 => Some("Numpad9"),
Key::KpDelete => Some("NumpadDecimal"),
Key::Function => Some("Fn"),
_ => None,
}
}
}
+169 -2
View File
@@ -274,6 +274,7 @@ impl HotkeyTrigger {
pub enum HotkeyMode {
Toggle,
Hold,
DoubleClick,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
@@ -294,11 +295,131 @@ impl HotkeyAdapterKind {
}
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub struct HotkeyKey {
pub code: String,
}
impl HotkeyKey {
pub fn new(code: impl Into<String>) -> Self {
Self { code: code.into() }
}
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
#[serde(default, rename_all = "camelCase")]
pub struct HotkeyBinding {
pub trigger: HotkeyTrigger,
pub mode: HotkeyMode,
pub keys: Option<Vec<HotkeyKey>>,
}
impl HotkeyBinding {
pub fn effective_codes(&self) -> Vec<String> {
let Some(keys) = &self.keys else {
return vec![legacy_trigger_code(self.trigger).to_string()];
};
keys.iter()
.map(|key| key.code.trim().to_string())
.filter(|code| !code.is_empty())
.collect()
}
pub fn display_label(&self) -> String {
let codes = self.effective_codes();
if codes.is_empty() {
return "未设置".to_string();
}
codes
.iter()
.map(|code| display_hotkey_code(code))
.collect::<Vec<_>>()
.join("+")
}
}
fn legacy_trigger_code(trigger: HotkeyTrigger) -> &'static str {
match trigger {
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => "AltRight",
HotkeyTrigger::LeftOption => "AltLeft",
HotkeyTrigger::RightControl => "ControlRight",
HotkeyTrigger::LeftControl => "ControlLeft",
HotkeyTrigger::RightCommand => "MetaRight",
HotkeyTrigger::Fn => "Fn",
}
}
fn display_hotkey_code(code: &str) -> String {
let label = match code {
"ControlLeft" => "左Ctrl",
"ControlRight" => "右 Control",
"AltLeft" => "左Alt",
"AltRight" => "右Alt",
"ShiftLeft" => "左Shift",
"ShiftRight" => "右Shift",
"MetaLeft" | "OSLeft" => "左Win",
"MetaRight" | "OSRight" => "右Win",
"Fn" => "Fn",
"FnLock" => "FnLock",
"CapsLock" => "CapsLock",
"ScrollLock" => "ScrLock",
"Pause" => "Pause",
"PrintScreen" => "PrtSc",
"Backspace" => "Backspace",
"Tab" => "Tab",
"Enter" => "Enter",
"Space" => "Space",
"Insert" => "Insert",
"Delete" => "Delete",
"Home" => "Home",
"End" => "End",
"PageUp" => "PageUp",
"PageDown" => "PageDown",
"ArrowUp" => "Up",
"ArrowDown" => "Down",
"ArrowLeft" => "Left",
"ArrowRight" => "Right",
"NumpadAdd" => "Num+",
"NumpadSubtract" => "Num-",
"NumpadMultiply" => "Num*",
"NumpadDivide" => "Num/",
"NumpadDecimal" => "Num.",
"NumpadEnter" => "NumEnter",
"Mouse4" => "Mouse4",
"Mouse5" => "Mouse5",
"Backquote" => "`",
"Minus" => "-",
"Equal" => "=",
"BracketLeft" => "[",
"BracketRight" => "]",
"Backslash" => "\\",
"Semicolon" => ";",
"Quote" => "'",
"Comma" => ",",
"Period" => ".",
"Slash" => "/",
_ => "",
};
if !label.is_empty() {
return label.to_string();
}
if let Some(letter) = code.strip_prefix("Key") {
if letter.len() == 1 {
return letter.to_string();
}
}
if let Some(digit) = code.strip_prefix("Digit") {
if digit.len() == 1 {
return digit.to_string();
}
}
if let Some(num) = code.strip_prefix("Numpad") {
if num.len() == 1 && num.as_bytes()[0].is_ascii_digit() {
return format!("Num{num}");
}
}
code.to_string()
}
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
@@ -350,7 +471,7 @@ impl HotkeyCapability {
supports_side_specific_modifiers: true,
explicit_fallback_available: false,
status_hint: Some(
"默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。"
"默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。"
.into(),
),
};
@@ -443,6 +564,7 @@ impl Default for HotkeyBinding {
Self {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::Toggle,
keys: Some(vec![HotkeyKey::new("ControlRight")]),
}
}
@@ -451,6 +573,7 @@ impl Default for HotkeyBinding {
Self {
trigger: HotkeyTrigger::RightOption,
mode: HotkeyMode::Toggle,
keys: Some(vec![HotkeyKey::new("AltRight")]),
}
}
}
@@ -527,4 +650,48 @@ mod tests {
assert!(prefs.allow_non_tsf_insertion_fallback);
}
#[test]
fn legacy_hotkey_trigger_still_produces_effective_key_codes() {
let binding: HotkeyBinding =
serde_json::from_str(r#"{"trigger":"rightControl","mode":"toggle"}"#).unwrap();
assert_eq!(binding.effective_codes(), vec!["ControlRight".to_string()]);
assert_eq!(binding.display_label(), "右 Control");
}
#[test]
fn hotkey_binding_supports_combo_side_keys_mouse_and_double_click_mode() {
let binding = HotkeyBinding {
trigger: HotkeyTrigger::RightControl,
mode: HotkeyMode::DoubleClick,
keys: Some(vec![
HotkeyKey::new("ControlLeft"),
HotkeyKey::new("AltLeft"),
HotkeyKey::new("Mouse4"),
]),
};
assert_eq!(
binding.effective_codes(),
vec![
"ControlLeft".to_string(),
"AltLeft".to_string(),
"Mouse4".to_string()
]
);
assert_eq!(binding.display_label(), "左Ctrl+左Alt+Mouse4");
let json = serde_json::to_value(&binding).unwrap();
assert_eq!(json["mode"], "doubleClick");
}
#[test]
fn explicit_empty_hotkey_keys_clear_the_binding() {
let binding: HotkeyBinding =
serde_json::from_str(r#"{"trigger":"rightControl","mode":"toggle","keys":[]}"#)
.unwrap();
assert!(binding.effective_codes().is_empty());
}
}
+19 -10
View File
@@ -84,7 +84,7 @@ export const en: typeof zhCN = {
},
hotkeyModePrompt: {
title: 'Review your recording mode',
body: 'This version now defaults to Toggle. If you changed the hotkey trigger mode before, please open Recording settings and confirm it once. This update also adjusts how the hotkey mode preference is read; if you prefer push-to-talk, switch it back manually.',
body: 'This version now defaults to Single. If you changed the hotkey trigger mode before, please open Recording settings and confirm it once. This update also adjusts how the hotkey mode preference is read; if you prefer hold-to-talk, switch it back to Hold.',
later: 'Remind me later',
openSettings: 'Open Recording',
},
@@ -265,14 +265,20 @@ export const en: typeof zhCN = {
title: 'Recording',
desc: 'Define the global recording hotkey and how it triggers.',
hotkeyLabel: 'Recording hotkey',
hotkeyDescAcc: 'Pressing it captures voice globally. Requires Accessibility permission.',
hotkeyDescNoAcc: 'Pressing it captures voice globally. No Accessibility permission required.',
hotkeyDescAcc: 'Choose single, hold, or double trigger; the hotkey can be left empty.',
hotkeyDescNoAcc: 'Choose single, hold, or double trigger; the hotkey can be left empty.',
hotkeyRecording: 'Press keys...',
hotkeyClear: 'Clear hotkey',
hotkeySetStatus: 'Set: {{hotkey}}',
hotkeyUnsetStatus: 'Not set',
modeLabel: 'Trigger mode',
modeDesc: 'Toggle = press once to start, again to stop. Push-to-talk = hold to record, release to stop.',
modeToggle: 'Toggle',
modeHold: 'Push-to-talk',
migrationNoticeTitle: 'Default recording mode is now Toggle',
migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer push-to-talk, switch it back manually.',
keyLabel: 'Hotkey',
modeDesc: 'Single starts/stops recording. Hold starts on press and stops on release. Double requires two presses.',
modeToggle: 'Single',
modeHold: 'Hold',
modeDoubleClick: 'Double',
migrationNoticeTitle: 'Default recording mode is now Single',
migrationNoticeDesc: 'If you changed the hotkey trigger mode before, please confirm it here once. This update changes both the default value and the preference-reading path; if you prefer hold-to-talk, switch it back to Hold.',
capsuleLabel: 'Recording capsule',
capsuleDesc: 'Show a translucent capsule at the bottom of the screen while recording / transcribing.',
restoreClipboardLabel: 'Restore clipboard after insert',
@@ -496,10 +502,13 @@ export const en: typeof zhCN = {
rightAlt: 'Right Alt',
},
fallback: 'Global hotkey',
modeHoldSuffix: ' (push-to-talk)',
modeToggleSuffix: ' (start / stop)',
unset: 'Not set',
modeHoldSuffix: ' (hold)',
modeToggleSuffix: ' (single)',
modeDoubleClickSuffix: ' (double)',
usageHold: 'Hold {{trigger}} to talk, release to stop.',
usageToggle: 'Press {{trigger}} to start, press again to stop.',
usageDoubleClick: 'Double-click {{trigger}} to start or stop recording.',
adapter: {
macEventTap: 'macOS Event Tap',
windowsLowLevel: 'Windows low-level keyboard hook',
+20 -11
View File
@@ -82,7 +82,7 @@ export const zhCN = {
},
hotkeyModePrompt: {
title: '检查录音方式',
body: '本版本默认改为“切换式说话”。如果你之前改过快捷键触发方式,请到“录音”里手动确认一次。本次更新同时调整了快捷键方式的读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。',
body: '本版本默认改为“单击”触发。如果你之前改过快捷键触发方式,请到“录音”里手动确认一次。本次更新同时调整了快捷键方式的读取逻辑;如果你更习惯按住说话,可以切回“按住”。',
later: '稍后提醒',
openSettings: '去录音设置',
},
@@ -263,14 +263,20 @@ export const zhCN = {
title: '录音',
desc: '定义全局录音的快捷键与触发方式。',
hotkeyLabel: '录音快捷键',
hotkeyDescAcc: '按下即开始捕获语音,全局生效。需要授予辅助功能权限。',
hotkeyDescNoAcc: '按下即开始捕获语音,全局生效。无需额外辅助功能授权。',
modeLabel: '录音方式',
modeDesc: '切换式 = 按一次开始、再按一次结束;按住说话 = 按住开始、松开结束。',
modeToggle: '切换式',
modeHold: '按住说话',
migrationNoticeTitle: '默认已改为切换式说话',
migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以重新切回“按住说话”。',
hotkeyDescAcc: '选择单击、按住或双击触发;快捷键可留空。',
hotkeyDescNoAcc: '选择单击、按住或双击触发;快捷键可留空。',
hotkeyRecording: '请按键...',
hotkeyClear: '清除快捷键',
hotkeySetStatus: '已设置:{{hotkey}}',
hotkeyUnsetStatus: '未设置',
modeLabel: '触发方式',
keyLabel: '快捷键',
modeDesc: '单击开始/停止录音;按住为按下开始、松开结束;双击需要连续按两次。',
modeToggle: '单击',
modeHold: '按住',
modeDoubleClick: '双击',
migrationNoticeTitle: '默认已改为单击触发',
migrationNoticeDesc: '如果你之前改过快捷键触发方式,请在这里手动确认一次。本次更新调整了快捷键方式的默认值与读取逻辑;如果你更习惯按住说话,可以切回“按住”。',
capsuleLabel: '录音胶囊',
capsuleDesc: '录音 / 转写时在屏幕底部显示半透明胶囊。',
restoreClipboardLabel: '插入后恢复剪贴板',
@@ -494,10 +500,13 @@ export const zhCN = {
rightAlt: '右 Alt',
},
fallback: '全局快捷键',
modeHoldSuffix: '(按住说话)',
modeToggleSuffix: '(开始 / 停止)',
unset: '未设置',
modeHoldSuffix: '(按住)',
modeToggleSuffix: '(单击)',
modeDoubleClickSuffix: '(双击)',
usageHold: '按住 {{trigger}} 说话,松开结束。',
usageToggle: '按 {{trigger}} 开始录音,再按一次结束。',
usageDoubleClick: '双击 {{trigger}} 开始或结束录音。',
adapter: {
macEventTap: 'macOS Event Tap',
windowsLowLevel: 'Windows 低层键盘 hook',
+107 -6
View File
@@ -7,16 +7,117 @@ export function getHotkeyTriggerLabel(trigger: HotkeyTrigger | null | undefined)
}
export function getHotkeyStartStopLabel(binding: HotkeyBinding | null | undefined): string {
const trigger = getHotkeyTriggerLabel(binding?.trigger);
const trigger = getHotkeyBindingLabel(binding);
const suffix = binding?.mode === 'hold'
? i18n.t('hotkey.modeHoldSuffix')
: i18n.t('hotkey.modeToggleSuffix');
: binding?.mode === 'doubleClick'
? i18n.t('hotkey.modeDoubleClickSuffix')
: i18n.t('hotkey.modeToggleSuffix');
return `${trigger}${suffix}`;
}
export function getHotkeyUsageHint(binding: HotkeyBinding | null | undefined): string {
const trigger = getHotkeyTriggerLabel(binding?.trigger);
return binding?.mode === 'hold'
? i18n.t('hotkey.usageHold', { trigger })
: i18n.t('hotkey.usageToggle', { trigger });
const trigger = getHotkeyBindingLabel(binding);
if (binding?.mode === 'hold') return i18n.t('hotkey.usageHold', { trigger });
if (binding?.mode === 'doubleClick') return i18n.t('hotkey.usageDoubleClick', { trigger });
return i18n.t('hotkey.usageToggle', { trigger });
}
export function getHotkeyBindingCodes(binding: HotkeyBinding | null | undefined): string[] {
if (!binding) return [];
if (Array.isArray(binding.keys)) {
return binding.keys.map(key => key.code.trim()).filter(Boolean);
}
const legacy = legacyTriggerCode(binding.trigger);
return legacy ? [legacy] : [];
}
export function getHotkeyBindingLabel(binding: HotkeyBinding | null | undefined): string {
const codes = getHotkeyBindingCodes(binding);
if (codes.length === 0) return i18n.t('hotkey.unset');
return codes.map(getHotkeyCodeLabel).join('+');
}
export function getHotkeyCodeLabel(code: string): string {
const zh = i18n.language.toLowerCase().startsWith('zh');
const labels: Record<string, string> = {
ControlLeft: zh ? '左Ctrl' : 'Left Ctrl',
ControlRight: zh ? '右Ctrl' : 'Right Ctrl',
AltLeft: zh ? '左Alt' : 'Left Alt',
AltRight: zh ? '右Alt' : 'Right Alt',
ShiftLeft: zh ? '左Shift' : 'Left Shift',
ShiftRight: zh ? '右Shift' : 'Right Shift',
MetaLeft: zh ? '左Win' : 'Left Win',
MetaRight: zh ? '右Win' : 'Right Win',
OSLeft: zh ? '左Win' : 'Left Win',
OSRight: zh ? '右Win' : 'Right Win',
Fn: 'Fn',
FnLock: 'FnLock',
CapsLock: 'CapsLock',
ScrollLock: 'ScrLock',
Pause: 'Pause',
PrintScreen: 'PrtSc',
Backspace: 'Backspace',
Tab: 'Tab',
Enter: 'Enter',
Space: 'Space',
Insert: 'Insert',
Delete: 'Delete',
Home: 'Home',
End: 'End',
PageUp: 'PageUp',
PageDown: 'PageDown',
ArrowUp: 'Up',
ArrowDown: 'Down',
ArrowLeft: 'Left',
ArrowRight: 'Right',
ContextMenu: 'Menu',
NumpadAdd: 'Num+',
NumpadSubtract: 'Num-',
NumpadMultiply: 'Num*',
NumpadDivide: 'Num/',
NumpadDecimal: 'Num.',
NumpadEnter: 'NumEnter',
Mouse4: 'Mouse4',
Mouse5: 'Mouse5',
Backquote: '`',
Minus: '-',
Equal: '=',
BracketLeft: '[',
BracketRight: ']',
Backslash: '\\',
Semicolon: ';',
Quote: "'",
Comma: ',',
Period: '.',
Slash: '/',
};
if (labels[code]) return labels[code];
const letter = code.match(/^Key([A-Z])$/);
if (letter) return letter[1];
const digit = code.match(/^Digit([0-9])$/);
if (digit) return digit[1];
const numpad = code.match(/^Numpad([0-9])$/);
if (numpad) return `Num${numpad[1]}`;
return code;
}
function legacyTriggerCode(trigger: HotkeyTrigger | null | undefined): string | null {
switch (trigger) {
case 'rightOption':
case 'rightAlt':
return 'AltRight';
case 'leftOption':
return 'AltLeft';
case 'rightControl':
return 'ControlRight';
case 'leftControl':
return 'ControlLeft';
case 'rightCommand':
return 'MetaRight';
case 'fn':
return 'Fn';
default:
return null;
}
}
+2 -2
View File
@@ -40,7 +40,7 @@ export async function invokeOrMock<T>(
// ── Mock fixtures ──────────────────────────────────────────────────────
const mockSettings: UserPreferences = {
hotkey: { trigger: 'rightControl', mode: 'toggle' },
hotkey: { trigger: 'rightControl', mode: 'toggle', keys: [{ code: 'ControlRight' }] },
defaultMode: 'structured',
enabledModes: ['raw', 'light', 'structured', 'formal'],
launchAtLogin: false,
@@ -62,7 +62,7 @@ const mockHotkeyCapability: HotkeyCapability = {
supportsModifierOnlyTrigger: true,
supportsSideSpecificModifiers: true,
explicitFallbackAvailable: false,
statusHint: '默认建议使用“右 Control + 切换式说话”;若更习惯按住说话,可在录音设置里切回。若无响应,可在权限页查看 hook 安装状态。',
statusHint: '默认建议使用“右Ctrl + 单击”;若更习惯按住说话,可在录音设置里切回“按住”。若无响应,可在权限页查看 hook 安装状态。',
};
const mockCredentialsStatus: CredentialsStatus = {
+6 -1
View File
@@ -50,11 +50,16 @@ export type HotkeyTrigger =
| 'fn'
| 'rightAlt';
export type HotkeyMode = 'toggle' | 'hold';
export type HotkeyMode = 'toggle' | 'hold' | 'doubleClick';
export interface HotkeyKey {
code: string;
}
export interface HotkeyBinding {
trigger: HotkeyTrigger;
mode: HotkeyMode;
keys?: HotkeyKey[] | null;
}
export type HotkeyAdapterKind = 'macEventTap' | 'windowsLowLevel' | 'rdev';
+2 -2
View File
@@ -5,7 +5,7 @@ import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { detectOS } from '../components/WindowChrome';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { getHotkeyBindingLabel } from '../lib/hotkey';
import { clearHistory, deleteHistoryEntry, listHistory } from '../lib/ipc';
import type { DictationSession, PolishMode } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
@@ -130,7 +130,7 @@ export function History() {
{loading && <div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>{t('common.loading')}</div>}
{!loading && filtered.length === 0 && (
<div style={{ padding: 16, fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('history.empty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })}
{t('history.empty', { trigger: getHotkeyBindingLabel(hotkey) })}
</div>
)}
{filtered.map(s => (
+2 -2
View File
@@ -3,7 +3,7 @@
import { useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { Icon } from '../components/Icon';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { getHotkeyBindingLabel } from '../lib/hotkey';
import { getCredentials, listHistory } from '../lib/ipc';
import type { CredentialsStatus, DictationSession, PolishMode } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
@@ -114,7 +114,7 @@ export function Overview({ onOpenHistory }: OverviewProps) {
<div>
{history.length === 0 && (
<div style={{ padding: 24, textAlign: 'center', fontSize: 12, color: 'var(--ol-ink-4)' }}>
{t('overview.recentEmpty', { trigger: getHotkeyTriggerLabel(hotkey?.trigger) })}
{t('overview.recentEmpty', { trigger: getHotkeyBindingLabel(hotkey) })}
</div>
)}
{history.slice(0, 5).map(s => (
+3 -3
View File
@@ -15,7 +15,7 @@ import { useTranslation } from 'react-i18next';
import { marked } from 'marked';
import { getSettings, isTauri, qaWindowDismiss, qaWindowPin } from '../lib/ipc';
import type { QaChatMessage, QaStatePayload, UserPreferences } from '../lib/types';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { getHotkeyBindingLabel } from '../lib/hotkey';
const SELECTION_PREVIEW_MAX = 60;
@@ -123,7 +123,7 @@ export function QaPanel() {
// webview,没有 HotkeySettingsContext;如果用户在主窗口改了录音键,
// 浮窗里的 "{recordHotkey}" 文案必须立刻跟上,否则会一直停在旧值。
const prefsHandle = await listen<UserPreferences>('prefs:changed', event => {
setRecordHotkeyLabel(getHotkeyTriggerLabel(event.payload?.hotkey?.trigger));
setRecordHotkeyLabel(getHotkeyBindingLabel(event.payload?.hotkey));
});
if (cancelled) {
stateHandle();
@@ -174,7 +174,7 @@ export function QaPanel() {
void getSettings()
.then(prefs => {
if (cancelled) return;
setRecordHotkeyLabel(getHotkeyTriggerLabel(prefs.hotkey?.trigger));
setRecordHotkeyLabel(getHotkeyBindingLabel(prefs.hotkey));
})
.catch(err => {
console.warn('[QaPanel] load hotkey label failed', err);
+2 -2
View File
@@ -11,7 +11,7 @@ import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { setQaHotkey } from '../lib/ipc';
import type { QaHotkeyBinding } from '../lib/types';
import { detectOS, type OS } from '../components/WindowChrome';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { getHotkeyBindingLabel } from '../lib/hotkey';
const QA_HOTKEY_DISABLED_ID = 'disabled' as const;
@@ -83,7 +83,7 @@ export function SelectionAsk() {
const os = detectOS();
const qaHotkeyPresets = getQaHotkeyPresets(os);
const defaultHotkeyLabel = qaHotkeyPresets[0]?.label ?? '快捷键';
const recordHotkeyLabel = getHotkeyTriggerLabel(hotkey?.trigger);
const recordHotkeyLabel = getHotkeyBindingLabel(hotkey);
if (!prefs) {
return (
+326 -36
View File
@@ -8,7 +8,7 @@ import { Icon } from '../components/Icon';
import { isDialogStatus, UpdateDialog, useAutoUpdate } from '../components/AutoUpdate';
import { APP_VERSION_LABEL } from '../lib/appVersion';
import { isHotkeyModeMigrationNoticeActive } from '../lib/hotkeyMigration';
import { getHotkeyStartStopLabel, getHotkeyTriggerLabel } from '../lib/hotkey';
import { getHotkeyBindingCodes, getHotkeyBindingLabel, getHotkeyCodeLabel, getHotkeyStartStopLabel } from '../lib/hotkey';
import {
checkAccessibilityPermission,
checkMicrophonePermission,
@@ -28,6 +28,7 @@ import {
} from '../lib/ipc';
import type {
HotkeyCapability,
HotkeyBinding,
HotkeyMode,
HotkeyStatus,
HotkeyTrigger,
@@ -164,10 +165,17 @@ function RecordingSection() {
);
}
const onTriggerChange = (trigger: HotkeyTrigger) =>
savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, trigger } });
const onModeChange = (mode: HotkeyMode) =>
savePrefs({ ...prefs, hotkey: { ...prefs.hotkey, mode } });
const onHotkeyKeysChange = (codes: string[]) =>
savePrefs({
...prefs,
hotkey: {
...prefs.hotkey,
trigger: inferLegacyTrigger(codes, prefs.hotkey.trigger),
keys: codes.map(code => ({ code })),
},
});
const onShowCapsuleChange = (showCapsule: boolean) =>
savePrefs({ ...prefs, showCapsule });
const onRestoreClipboardChange = (restoreClipboardAfterPaste: boolean) =>
@@ -178,6 +186,7 @@ function RecordingSection() {
const choices: Array<[HotkeyMode, string]> = [
['toggle', t('settings.recording.modeToggle')],
['hold', t('settings.recording.modeHold')],
['doubleClick', t('settings.recording.modeDoubleClick')],
];
const hotkeyDesc = capability.requiresAccessibilityPermission
? t('settings.recording.hotkeyDescAcc')
@@ -207,39 +216,49 @@ function RecordingSection() {
</div>
)}
<SettingRow label={t('settings.recording.hotkeyLabel')} desc={hotkeyDesc}>
<select
value={prefs.hotkey.trigger}
onChange={e => onTriggerChange(e.target.value as HotkeyTrigger)}
style={{
...inputStyle,
maxWidth: 200,
fontFamily: 'var(--ol-font-mono)',
}}
>
{capability.availableTriggers.map(tr => (
<option key={tr} value={tr}>{getHotkeyTriggerLabel(tr)}</option>
))}
</select>
</SettingRow>
<SettingRow label={t('settings.recording.modeLabel')} desc={t('settings.recording.modeDesc')}>
<div style={{ display: 'inline-flex', padding: 2, borderRadius: 8, background: 'rgba(0,0,0,0.05)' }}>
{choices.map(([v, l]) => (
<button
key={v}
onClick={() => onModeChange(v)}
style={{
padding: '5px 14px', fontSize: 12, fontWeight: 500,
border: 0, borderRadius: 6, fontFamily: 'inherit',
background: prefs.hotkey.mode === v ? '#fff' : 'transparent',
color: prefs.hotkey.mode === v ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
boxShadow: prefs.hotkey.mode === v ? '0 1px 2px rgba(0,0,0,.08)' : 'none',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
}}
>
{l}
</button>
))}
<div style={recordingHotkeyGroupStyle}>
<div style={recordingHotkeyLineStyle}>
<span style={recordingHotkeyFieldLabelStyle}>{t('settings.recording.modeLabel')}</span>
<div style={recordingHotkeySegmentedStyle}>
{choices.map(([v, l]) => (
<button
key={v}
onClick={() => onModeChange(v)}
style={{
flex: 1,
minWidth: 0,
height: 28,
padding: '0 8px', fontSize: 12, fontWeight: 500,
border: 0, borderRadius: 6, fontFamily: 'inherit',
display: 'inline-flex', alignItems: 'center', justifyContent: 'center',
lineHeight: 1,
whiteSpace: 'nowrap',
background: prefs.hotkey.mode === v ? '#fff' : 'transparent',
color: prefs.hotkey.mode === v ? 'var(--ol-ink)' : 'var(--ol-ink-3)',
boxShadow: prefs.hotkey.mode === v ? '0 1px 2px rgba(0,0,0,.08)' : 'none',
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick), box-shadow 0.18s var(--ol-motion-soft)',
}}
>
{l}
</button>
))}
</div>
</div>
<div style={recordingHotkeyLineStyle}>
<span style={recordingHotkeyFieldLabelStyle}>{t('settings.recording.keyLabel')}</span>
<HotkeyRecorder binding={prefs.hotkey} onCommit={onHotkeyKeysChange} />
</div>
<div
style={{
...recordingHotkeyStatusStyle,
color: getHotkeyBindingCodes(prefs.hotkey).length > 0 ? '#16813d' : 'var(--ol-ink-4)',
}}
>
{getHotkeyBindingCodes(prefs.hotkey).length > 0
? t('settings.recording.hotkeySetStatus', { hotkey: getHotkeyBindingLabel(prefs.hotkey) })
: t('settings.recording.hotkeyUnsetStatus')}
</div>
</div>
</SettingRow>
<SettingRow label={t('settings.recording.capsuleLabel')} desc={t('settings.recording.capsuleDesc')}>
@@ -274,6 +293,204 @@ function RecordingSection() {
// 不存进 prefs:autostart 状态由 OS 持有(mac LaunchAgent plist / linux .desktop /
// windows HKCU\Run),prefs 缓存反而会与 OS 真相不一致。issue #194。
function HotkeyRecorder({
binding,
onCommit,
}: {
binding: HotkeyBinding;
onCommit: (codes: string[]) => void;
}) {
const { t } = useTranslation();
const [recording, setRecording] = useState(false);
const [draftCodes, setDraftCodes] = useState<string[]>([]);
const pressedRef = useRef<Set<string>>(new Set());
const recordingRef = useRef(false);
const resetRecording = () => {
recordingRef.current = false;
pressedRef.current.clear();
setDraftCodes([]);
setRecording(false);
};
const commitCodes = (codes: string[]) => {
const ordered = orderHotkeyCodes(codes);
resetRecording();
onCommit(ordered);
};
const startRecording = () => {
recordingRef.current = true;
pressedRef.current.clear();
setDraftCodes([]);
setRecording(true);
};
useEffect(() => {
if (!recording) return undefined;
const stopEvent = (event: Event) => {
event.preventDefault();
event.stopPropagation();
};
const onKeyDown = (event: KeyboardEvent) => {
stopEvent(event);
if (event.key === 'Escape' || event.code === 'Escape') {
resetRecording();
return;
}
const code = normalizeKeyboardHotkeyCode(event);
if (!code) return;
pressedRef.current.add(code);
setDraftCodes(orderHotkeyCodes([...pressedRef.current]));
};
const onKeyUp = (event: KeyboardEvent) => {
stopEvent(event);
if (!recordingRef.current) return;
if (event.key === 'Escape' || event.code === 'Escape') {
resetRecording();
return;
}
const codes = orderHotkeyCodes([...pressedRef.current]);
if (codes.length > 0) commitCodes(codes);
};
const onMouseDown = (event: MouseEvent) => {
const code = mouseButtonToHotkeyCode(event.button);
if (!code) return;
stopEvent(event);
pressedRef.current.add(code);
commitCodes([...pressedRef.current]);
};
window.addEventListener('keydown', onKeyDown, true);
window.addEventListener('keyup', onKeyUp, true);
window.addEventListener('mousedown', onMouseDown, true);
return () => {
window.removeEventListener('keydown', onKeyDown, true);
window.removeEventListener('keyup', onKeyUp, true);
window.removeEventListener('mousedown', onMouseDown, true);
};
}, [recording]);
const label = recording
? draftCodes.length > 0
? draftCodes.map(getHotkeyCodeLabel).join('+')
: t('settings.recording.hotkeyRecording')
: getHotkeyBindingLabel(binding);
const hasKeys = getHotkeyBindingCodes(binding).length > 0;
return (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
<button
type="button"
onClick={startRecording}
style={{
...hotkeyRecorderButtonStyle,
borderColor: recording ? 'var(--ol-blue)' : 'var(--ol-line-strong)',
color: recording ? 'var(--ol-blue)' : 'var(--ol-ink)',
}}
>
<span style={hotkeyRecorderLabelStyle}>{label}</span>
{!recording && hasKeys && (
<span
role="button"
tabIndex={0}
aria-label={t('settings.recording.hotkeyClear')}
onClick={event => {
event.stopPropagation();
onCommit([]);
}}
onKeyDown={event => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault();
event.stopPropagation();
onCommit([]);
}
}}
style={hotkeyClearButtonStyle}
>
<Icon name="x" size={11} strokeWidth={2} />
</span>
)}
</button>
</div>
);
}
function inferLegacyTrigger(codes: string[], fallback: HotkeyTrigger): HotkeyTrigger {
if (codes.includes('ControlRight')) return 'rightControl';
if (codes.includes('ControlLeft')) return 'leftControl';
if (codes.includes('AltRight')) return 'rightAlt';
if (codes.includes('AltLeft')) return 'leftOption';
if (codes.includes('MetaRight')) return 'rightCommand';
if (codes.includes('Fn')) return 'fn';
return fallback;
}
function normalizeKeyboardHotkeyCode(event: KeyboardEvent): string | null {
if (event.key === 'Fn' || event.code === 'Fn') return 'Fn';
if (event.key === 'FnLock' || event.code === 'FnLock') return 'FnLock';
const code = event.code === 'OSLeft' ? 'MetaLeft' : event.code === 'OSRight' ? 'MetaRight' : event.code;
if (SUPPORTED_HOTKEY_CODES.has(code)) return code;
if (/^Key[A-Z]$/.test(code)) return code;
if (/^Digit[0-9]$/.test(code)) return code;
if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return code;
if (/^Numpad[0-9]$/.test(code)) return code;
return null;
}
function mouseButtonToHotkeyCode(button: number): string | null {
if (button === 3) return 'Mouse4';
if (button === 4) return 'Mouse5';
return null;
}
function orderHotkeyCodes(codes: string[]): string[] {
const seen = new Set<string>();
return codes
.filter(code => {
if (!code || seen.has(code)) return false;
seen.add(code);
return true;
})
.sort((a, b) => hotkeyCodeRank(a) - hotkeyCodeRank(b));
}
function hotkeyCodeRank(code: string): number {
const index = HOTKEY_CODE_ORDER.indexOf(code);
if (index >= 0) return index;
if (/^Key[A-Z]$/.test(code)) return 100 + code.charCodeAt(3);
if (/^Digit[0-9]$/.test(code)) return 200 + Number(code.slice(5));
if (/^F([1-9]|1[0-9]|2[0-4])$/.test(code)) return 300 + Number(code.slice(1));
if (/^Numpad[0-9]$/.test(code)) return 400 + Number(code.slice(6));
return 1000;
}
const SUPPORTED_HOTKEY_CODES = new Set([
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight',
'MetaLeft', 'MetaRight', 'CapsLock', 'ScrollLock', 'Pause', 'PrintScreen',
'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home', 'End',
'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'ContextMenu', 'NumpadAdd', 'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide',
'NumpadDecimal', 'NumpadEnter', 'Backquote', 'Minus', 'Equal', 'BracketLeft',
'BracketRight', 'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash',
'Fn', 'FnLock',
]);
const HOTKEY_CODE_ORDER = [
'ControlLeft', 'ControlRight', 'AltLeft', 'AltRight', 'ShiftLeft', 'ShiftRight',
'MetaLeft', 'MetaRight', 'Fn', 'FnLock', 'CapsLock', 'ScrollLock', 'Pause',
'PrintScreen', 'Backspace', 'Tab', 'Enter', 'Space', 'Insert', 'Delete', 'Home',
'End', 'PageUp', 'PageDown', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight',
'ContextMenu', 'Backquote', 'Minus', 'Equal', 'BracketLeft', 'BracketRight',
'Backslash', 'Semicolon', 'Quote', 'Comma', 'Period', 'Slash', 'NumpadAdd',
'NumpadSubtract', 'NumpadMultiply', 'NumpadDivide', 'NumpadDecimal', 'NumpadEnter',
'Mouse4', 'Mouse5',
];
function AutostartRow() {
const { t } = useTranslation();
const [enabled, setEnabled] = useState(false);
@@ -862,6 +1079,79 @@ const miniBtnStyle: CSSProperties = {
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
};
const recordingHotkeyControlWidth = 178;
const hotkeyRecorderButtonStyle: CSSProperties = {
width: recordingHotkeyControlWidth,
height: 32,
padding: '0 8px 0 11px',
border: '0.5px solid var(--ol-line-strong)',
borderRadius: 8,
background: 'var(--ol-surface-2)',
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'space-between',
gap: 8,
fontFamily: 'var(--ol-font-mono)',
fontSize: 12.5,
cursor: 'default',
transition: 'background 0.16s var(--ol-motion-quick), border-color 0.16s var(--ol-motion-quick), color 0.16s var(--ol-motion-quick)',
};
const recordingHotkeySegmentedStyle: CSSProperties = {
width: recordingHotkeyControlWidth,
display: 'inline-flex',
padding: 2,
borderRadius: 8,
background: 'rgba(0,0,0,0.05)',
};
const recordingHotkeyGroupStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: 'auto',
rowGap: 10,
justifyItems: 'start',
};
const recordingHotkeyLineStyle: CSSProperties = {
display: 'grid',
gridTemplateColumns: '64px auto',
alignItems: 'center',
columnGap: 10,
};
const recordingHotkeyFieldLabelStyle: CSSProperties = {
fontSize: 12,
color: 'var(--ol-ink-4)',
textAlign: 'right',
whiteSpace: 'nowrap',
};
const recordingHotkeyStatusStyle: CSSProperties = {
marginLeft: 74,
fontSize: 12,
lineHeight: 1.3,
};
const hotkeyRecorderLabelStyle: CSSProperties = {
minWidth: 0,
overflow: 'hidden',
textOverflow: 'ellipsis',
whiteSpace: 'nowrap',
};
const hotkeyClearButtonStyle: CSSProperties = {
width: 18,
height: 18,
borderRadius: 999,
display: 'inline-flex',
alignItems: 'center',
justifyContent: 'center',
flexShrink: 0,
background: 'rgba(0,0,0,0.2)',
color: '#fff',
};
const iconBtnStyle: CSSProperties = {
width: 32, height: 32,
border: '0.5px solid var(--ol-line-strong)',
+2 -2
View File
@@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next';
import { Card, PageHeader } from './_atoms';
import { SUPPORTED_LANGUAGES } from '../lib/types';
import { useHotkeySettings } from '../state/HotkeySettingsContext';
import { getHotkeyTriggerLabel } from '../lib/hotkey';
import { getHotkeyBindingLabel } from '../lib/hotkey';
export function Translation() {
const { t } = useTranslation();
@@ -40,7 +40,7 @@ export function Translation() {
const onTargetChange = (translationTargetLanguage: string) =>
savePrefs({ ...prefs, translationTargetLanguage });
const triggerLabel = getHotkeyTriggerLabel(hotkey?.trigger);
const triggerLabel = getHotkeyBindingLabel(hotkey);
const enabled = prefs.translationTargetLanguage.trim() !== '';
return (