feat(hotkey): support side-specific modifier combos

Add side-aware combo detection for dictation on macOS/Windows, shortcut recording support for side-specific modifiers, overlap validation, and tests.
This commit is contained in:
浩彬(^-^) authored and GitHub committed 2026-06-25 13:41:28 +08:00
1 parent 346942e1bb
commit fa09eaf605
21 files changed
+1366 -58

No files matched your search

+1
View File
@@ -18,6 +18,7 @@
"copy:android-scaffolding": "node scripts/copy-android-scaffolding.mjs",
"check:macos-capsule-spaces": "node scripts/macos-capsule-spaces-contract.test.mjs",
"check:hotkey-injection": "node scripts/check-hotkey-injection.mjs",
"check:hotkey-side-modifiers": "npx tsx src/lib/hotkeySideModifiers.test.ts",
"check:windows-startup-lifecycle": "node scripts/windows-startup-lifecycle-contract.test.mjs",
"check:android-updater-pubkey": "node scripts/check-android-updater-pubkey.mjs"
},
@@ -36,6 +36,12 @@ mod asr {
#[path = "../../src/coordinator_state.rs"]
mod coordinator_state;
#[path = "../../src/global_hotkey_runtime.rs"]
mod global_hotkey_runtime;
#[path = "../../src/combo_hotkey.rs"]
mod combo_hotkey;
#[path = "../../src/side_aware_combo.rs"]
mod side_aware_combo;
#[path = "../../src/hotkey.rs"]
mod hotkey;
#[cfg(not(target_os = "macos"))]
@@ -19,7 +19,7 @@ use crate::global_hotkey_runtime::{GlobalHotkeyRuntime, RegisteredHotkey};
use crate::shortcut_binding::{parse_global_hotkey, ShortcutBindingError};
use crate::types::ShortcutBinding;
#[derive(Debug, Clone, Copy)]
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ComboHotkeyEvent {
/// 用户按下了配置的组合键。
Pressed,
@@ -40,6 +40,7 @@ pub fn set_translation_hotkey(
binding: ShortcutBinding,
) -> Result<(), String> {
crate::shortcut_binding::validate_binding(&binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(&binding)?;
let previous = coord.prefs().get();
reject_dictation_translation_hotkey_overlap(&previous.dictation_hotkey, &binding)?;
if let Some(qa_hotkey) = previous.qa_hotkey.as_ref() {
@@ -76,6 +77,7 @@ pub fn set_switch_style_hotkey(
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
reject_modifier_only_action_shortcut(binding)?;
}
let mut prefs = coord.prefs().get();
@@ -106,6 +108,7 @@ pub fn set_open_app_hotkey(
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
reject_modifier_only_action_shortcut(binding)?;
}
let mut prefs = coord.prefs().get();
@@ -229,6 +232,7 @@ fn reject_hotkey_overlap(
}
pub(crate) fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), String> {
reject_non_dictation_side_specific_shortcuts(prefs)?;
// 停用(None)的 action 快捷键不参与任何冲突检测。
let switch_style = prefs.switch_style_hotkey.as_ref();
let open_app = prefs.open_app_hotkey.as_ref();
@@ -274,6 +278,25 @@ pub(crate) fn reject_hotkey_collisions(prefs: &UserPreferences) -> Result<(), St
Ok(())
}
pub(crate) fn reject_non_dictation_side_specific_shortcuts(
prefs: &UserPreferences,
) -> Result<(), String> {
crate::shortcut_binding::reject_side_specific_non_dictation(&prefs.translation_hotkey)?;
if let Some(binding) = prefs.qa_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
if let Some(binding) = prefs.switch_style_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
if let Some(binding) = prefs.open_app_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
if let Some(binding) = prefs.coding_agent_voice_hotkey.as_ref() {
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
}
Ok(())
}
pub(crate) fn reject_dictation_translation_hotkey_overlap(
dictation: &ShortcutBinding,
translation: &ShortcutBinding,
@@ -405,21 +428,7 @@ fn reject_less_computer_open_app_hotkey_overlap(
}
fn shortcut_bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool {
let left_legacy = crate::shortcut_binding::legacy_modifier_trigger(left);
let right_legacy = crate::shortcut_binding::legacy_modifier_trigger(right);
match (left_legacy, right_legacy) {
(Some(left), Some(right)) => left == right,
(Some(_), None) | (None, Some(_)) => false,
(None, None) => {
let Ok(left) = crate::shortcut_binding::parse_global_hotkey(left) else {
return false;
};
let Ok(right) = crate::shortcut_binding::parse_global_hotkey(right) else {
return false;
};
left == right
}
}
crate::shortcut_binding::bindings_overlap(left, right)
}
#[cfg(test)]
@@ -433,9 +442,6 @@ mod tests {
}
}
/// 锁定碰撞矩阵:每个动作键与 Less Computer 键相同都必须被 reject_hotkey_collisions
/// 拒绝。5 个快捷 setter(set_dictation/translation/switch_style/open_app/qa_hotkey)
/// 此前漏校验 coding_agent_voice_hotkey,已接入对应的 less_computer 校验。
#[test]
fn each_action_hotkey_collides_with_less_computer() {
let lc = key("LeftControl");
@@ -474,4 +480,61 @@ mod tests {
// 复位后再次全不同 → 通过。
assert!(reject_hotkey_collisions(&prefs).is_ok());
}
#[test]
fn side_specific_dictation_overlaps_generic_qa_hotkey() {
let mut prefs = UserPreferences {
dictation_hotkey: ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
},
qa_hotkey: Some(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd".into()],
}),
..Default::default()
};
#[cfg(target_os = "windows")]
{
assert!(reject_hotkey_collisions(&prefs).is_ok());
prefs.qa_hotkey = Some(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["super".into()],
});
assert!(reject_hotkey_collisions(&prefs).is_err());
}
#[cfg(not(target_os = "windows"))]
{
assert!(reject_hotkey_collisions(&prefs).is_err());
}
prefs.qa_hotkey = Some(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd".into(), "shift".into()],
});
assert!(reject_hotkey_collisions(&prefs).is_ok());
}
#[test]
fn rejects_side_specific_qa_hotkey_on_save() {
let prefs = UserPreferences {
qa_hotkey: Some(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
}),
..Default::default()
};
assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_err());
}
#[test]
fn accepts_side_specific_dictation_hotkey_on_save() {
let prefs = UserPreferences {
dictation_hotkey: ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
},
..Default::default()
};
assert!(reject_non_dictation_side_specific_shortcuts(&prefs).is_ok());
}
}
@@ -15,6 +15,7 @@ pub fn set_qa_hotkey(
) -> Result<(), String> {
if let Some(binding) = binding.as_ref() {
crate::shortcut_binding::validate_binding(binding).map_err(|e| e.to_string())?;
crate::shortcut_binding::reject_side_specific_non_dictation(binding)?;
if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") {
return Err("Shift 单键目前只能用于翻译快捷键".into());
}
+30 -3
View File
@@ -282,6 +282,7 @@ struct Inner {
/// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时
/// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。
combo_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
side_aware_combo: Mutex<Option<crate::side_aware_combo::SideAwareComboMonitor>>,
translation_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
switch_style_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
open_app_hotkey: Mutex<Option<ComboHotkeyMonitor>>,
@@ -403,6 +404,7 @@ impl Coordinator {
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
@@ -495,6 +497,7 @@ impl Coordinator {
session_cooldown_until: Mutex::new(None),
shortcut_recording_active: AtomicBool::new(false),
combo_hotkey: Mutex::new(None),
side_aware_combo: Mutex::new(None),
translation_hotkey: Mutex::new(None),
switch_style_hotkey: Mutex::new(None),
open_app_hotkey: Mutex::new(None),
@@ -810,18 +813,41 @@ impl Coordinator {
pub fn update_combo_hotkey_binding(&self) {
let prefs = self.inner.prefs.get();
if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() {
// 修饰键单键由 HotkeyMonitor 处理,组合键 monitor 要释放。
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
log::info!("[coord] combo hotkey 已关闭(modifier-only)");
return;
}
let binding = prefs.dictation_hotkey.clone();
if is_unconfigured_shortcut(&binding) {
// Custom 但没录到有效主键:清掉旧 monitor,避免旧快捷键继续生效。
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
log::info!("[coord] combo hotkey 已关闭(无绑定)");
return;
};
}
if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) {
take_combo_hotkey_on_main_thread(&self.inner);
self.inner.side_aware_combo.lock().take();
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) {
Ok(monitor) => {
*self.inner.side_aware_combo.lock() = Some(monitor);
let bridge_inner = Arc::clone(&self.inner);
std::thread::Builder::new()
.name("openless-side-combo-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx))
.ok();
log::info!("[coord] side-aware combo hotkey listener installed (via update)");
}
Err(e) => {
log::warn!("[coord] update side-aware combo binding 失败: {e}");
}
}
return;
}
self.inner.side_aware_combo.lock().take();
let app = self.inner.app.lock().clone();
let Some(app) = app else {
log::warn!("[coord] update combo hotkey binding: AppHandle 未 bind,跳过");
@@ -2028,6 +2054,7 @@ fn resolve_ark_endpoint_with_policy(
#[cfg(test)]
mod tests {
use super::dictation::abort_recording_with_error;
use super::dictation::{handle_pressed_edge, handle_released_edge};
use super::*;
use crate::types::{HotkeyMode, HotkeyTrigger};
use once_cell::sync::Lazy;
@@ -488,21 +488,48 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
// 读当前 prefs
let prefs = inner.prefs.get();
if crate::shortcut_binding::legacy_modifier_trigger(&prefs.dictation_hotkey).is_some() {
// 不是 Custom → 卸载后退出守护
take_combo_hotkey_on_main_thread(&inner);
// 对齐主 supervisor 的 exit-on-success:装/卸交给 update_combo_hotkey_binding 主动路径,issue #470
inner.side_aware_combo.lock().take();
return;
}
let binding = prefs.dictation_hotkey.clone();
if is_unconfigured_shortcut(&binding) {
take_combo_hotkey_on_main_thread(&inner);
// 对齐主 supervisor 的 exit-on-success:装/卸交给 update_combo_hotkey_binding 主动路径,issue #470
inner.side_aware_combo.lock().take();
return;
}
if crate::shortcut_binding::binding_requires_side_aware_hook(&binding) {
take_combo_hotkey_on_main_thread(&inner);
if inner.side_aware_combo.lock().is_some() {
return;
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) {
Ok(monitor) => {
*inner.side_aware_combo.lock() = Some(monitor);
let inner_clone = Arc::clone(&inner);
std::thread::Builder::new()
.name("openless-side-combo-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(inner_clone, rx))
.ok();
return;
}
Err(e) => {
attempts += 1;
if attempts <= 3 || attempts % 10 == 0 {
log::warn!("[coord] side-aware combo 第 {attempts} 次注册失败: {e}; 3s 后重试");
}
std::thread::sleep(std::time::Duration::from_secs(3));
continue;
}
}
}
inner.side_aware_combo.lock().take();
if inner.combo_hotkey.lock().is_some() {
// 对齐主 supervisor 的 exit-on-success:装/卸交给 update_combo_hotkey_binding 主动路径,issue #470
return;
}
@@ -550,7 +577,10 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
.name("openless-combo-hotkey-bridge".into())
.spawn(move || combo_hotkey_bridge_loop(inner_clone, rx))
.ok();
#[cfg(target_os = "linux")]
sync_custom_dictation_to_plugin(&inner);
attempts = 0;
return;
}
Err(e) => {
attempts += 1;
@@ -1160,6 +1190,9 @@ pub(super) fn window_key_matches_trigger(trigger: crate::types::HotkeyTrigger, k
}
HotkeyTrigger::LeftOption => (key == "Alt" || key == "AltGraph") && code == "AltLeft",
HotkeyTrigger::RightCommand => key == "Meta" && code == "MetaRight",
HotkeyTrigger::LeftCommand => key == "Meta" && code == "MetaLeft",
HotkeyTrigger::LeftShift => key == "Shift" && code == "ShiftLeft",
HotkeyTrigger::RightShift => key == "Shift" && code == "ShiftRight",
HotkeyTrigger::Fn => key == "Control" && code == "ControlRight",
// MediaPlayPause 走 WH_KEYBOARD_LL,不走 window hotkey fallback
HotkeyTrigger::MediaPlayPause => false,
+55 -3
View File
@@ -398,6 +398,7 @@ mod platform {
const TAP_OPTION_DEFAULT: CgEventTapOptions = 0;
const KEY_DOWN: CgEventType = 10;
const KEY_UP: CgEventType = 11;
const FLAGS_CHANGED: CgEventType = 12;
const TAP_DISABLED_BY_TIMEOUT: CgEventType = 0xFFFF_FFFE;
const TAP_DISABLED_BY_USER_INPUT: CgEventType = 0xFFFF_FFFF;
@@ -464,7 +465,9 @@ mod platform {
tx: Sender<HotkeyEvent>,
status_tx: StartupTx<Arc<MacShutdownHandles>>,
) {
let mask: CgEventMask = (1u64 << FLAGS_CHANGED) | (1u64 << KEY_DOWN);
let mask: CgEventMask = (1u64 << FLAGS_CHANGED)
| (1u64 << KEY_DOWN)
| (1u64 << KEY_UP);
let handles = Arc::new(MacShutdownHandles {
tap: std::sync::Mutex::new(None),
runloop: std::sync::Mutex::new(None),
@@ -531,7 +534,15 @@ mod platform {
return event;
}
FLAGS_CHANGED => handle_flags_changed(ctx, event),
KEY_DOWN => handle_key_down(ctx, event),
KEY_DOWN => {
handle_key_down(ctx, event);
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, true);
}
KEY_UP => {
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
crate::side_aware_combo::platform::dispatch_keycode(keycode, false, 0, false);
}
_ => {}
}
event
@@ -555,6 +566,7 @@ mod platform {
}
let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) };
crate::side_aware_combo::platform::dispatch_keycode(keycode, true, flags, false);
handle_optional_modifier_trigger(
ctx,
keycode,
@@ -631,6 +643,9 @@ mod platform {
HotkeyTrigger::LeftOption => 58,
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => 61,
HotkeyTrigger::RightCommand => 54,
HotkeyTrigger::LeftCommand => 55,
HotkeyTrigger::LeftShift => 56,
HotkeyTrigger::RightShift => 60,
HotkeyTrigger::Fn => 63,
HotkeyTrigger::MediaPlayPause => 0,
HotkeyTrigger::Custom => unreachable!("custom combo hotkeys use ComboHotkeyMonitor"),
@@ -640,7 +655,8 @@ mod platform {
fn trigger_to_flag_mask(trigger: HotkeyTrigger) -> CgEventFlags {
match trigger {
HotkeyTrigger::LeftControl | HotkeyTrigger::RightControl => FLAG_MASK_CONTROL,
HotkeyTrigger::RightCommand => FLAG_MASK_COMMAND,
HotkeyTrigger::LeftCommand | HotkeyTrigger::RightCommand => FLAG_MASK_COMMAND,
HotkeyTrigger::LeftShift | HotkeyTrigger::RightShift => FLAG_MASK_SHIFT,
HotkeyTrigger::LeftOption | HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => {
FLAG_MASK_ALTERNATE
}
@@ -779,6 +795,7 @@ mod platform {
const VK_LMENU: u32 = 0xA4;
const VK_RMENU: u32 = 0xA5;
const VK_RWIN: u32 = 0x5C;
const VK_LWIN: u32 = 0x5B;
const VK_MEDIA_PLAY_PAUSE: u32 = 0xB3;
const LLKHF_INJECTED: u32 = 0x0000_0010;
const ACCEPT_INJECTED_ENV: &str = "OPENLESS_ACCEPT_SYNTHETIC_HOTKEY_EVENTS";
@@ -932,6 +949,9 @@ mod platform {
return false;
}
let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN);
crate::side_aware_combo::platform::dispatch_vk(vk_code, pressed);
// Shift(任一侧)= 翻译模式修饰键。在录音过程中任意时刻按下都生效。详见 issue #4。
if matches!(vk_code, VK_SHIFT | VK_LSHIFT | VK_RSHIFT) {
match message {
@@ -1036,6 +1056,9 @@ mod platform {
HotkeyTrigger::LeftControl => VK_LCONTROL,
HotkeyTrigger::RightOption | HotkeyTrigger::RightAlt => VK_RMENU,
HotkeyTrigger::RightCommand => VK_RWIN,
HotkeyTrigger::LeftCommand => VK_LWIN,
HotkeyTrigger::LeftShift => VK_LSHIFT,
HotkeyTrigger::RightShift => VK_RSHIFT,
HotkeyTrigger::LeftOption => VK_LMENU,
HotkeyTrigger::Fn => VK_RCONTROL,
HotkeyTrigger::MediaPlayPause => VK_MEDIA_PLAY_PAUSE,
@@ -1190,6 +1213,35 @@ mod platform {
));
assert_eq!(drain(&right_alt_rx), vec![HotkeyEvent::Pressed]);
}
#[test]
fn windows_shift_side_combo_receives_pressed_via_dispatch_keyboard_event() {
use crate::combo_hotkey::ComboHotkeyEvent;
use crate::side_aware_combo::SideAwareComboMonitor;
use crate::types::ShortcutBinding;
let (combo_tx, combo_rx) = mpsc::channel();
let binding = ShortcutBinding {
primary: "D".into(),
modifiers: vec!["shift-left".into()],
};
let monitor = SideAwareComboMonitor::start(binding, combo_tx).expect("start monitor");
let shared = shared(HotkeyTrigger::Custom);
let (ctx, hotkey_rx) = callback_context(shared);
dispatch_keyboard_event(&ctx, VK_LSHIFT, WM_KEYDOWN);
dispatch_keyboard_event(&ctx, 0x44, WM_KEYDOWN);
assert_eq!(combo_rx.recv().unwrap(), ComboHotkeyEvent::Pressed);
assert!(
hotkey_rx
.try_iter()
.any(|evt| evt == HotkeyEvent::TranslationModifierPressed)
);
drop(monitor);
}
}
}
+5
View File
@@ -67,6 +67,11 @@ mod selection;
mod selection;
#[cfg(not(mobile))]
mod shortcut_binding;
#[cfg(not(mobile))]
mod side_aware_combo;
#[cfg(mobile)]
#[path = "mobile_stubs/side_aware_combo.rs"]
mod side_aware_combo;
#[cfg(mobile)]
#[path = "mobile_stubs/shortcut_binding.rs"]
mod shortcut_binding;
@@ -102,6 +102,9 @@ fn trigger_to_keysym(trigger: crate::types::HotkeyTrigger) -> u32 {
}
crate::types::HotkeyTrigger::LeftOption => KEYSYM_ALT_L,
crate::types::HotkeyTrigger::RightCommand => KEYSYM_SUPER_R,
crate::types::HotkeyTrigger::LeftCommand => KEYSYM_SUPER_L,
crate::types::HotkeyTrigger::LeftShift => KEYSYM_SHIFT_L,
crate::types::HotkeyTrigger::RightShift => KEYSYM_SHIFT_R,
crate::types::HotkeyTrigger::Fn => KEYSYM_CONTROL_R,
crate::types::HotkeyTrigger::MediaPlayPause => unreachable!("Windows-only"),
crate::types::HotkeyTrigger::Custom => unreachable!(),
@@ -115,6 +118,9 @@ fn trigger_name(trigger: crate::types::HotkeyTrigger) -> &'static str {
crate::types::HotkeyTrigger::RightOption | crate::types::HotkeyTrigger::RightAlt => "Alt_R",
crate::types::HotkeyTrigger::LeftOption => "Alt_L",
crate::types::HotkeyTrigger::RightCommand => "Super_R",
crate::types::HotkeyTrigger::LeftCommand => "Super_L",
crate::types::HotkeyTrigger::LeftShift => "Shift_L",
crate::types::HotkeyTrigger::RightShift => "Shift_R",
crate::types::HotkeyTrigger::Fn => "Control_R",
crate::types::HotkeyTrigger::MediaPlayPause => unreachable!("Windows-only"),
crate::types::HotkeyTrigger::Custom => unreachable!(),
@@ -16,6 +16,29 @@ pub fn parse_global_hotkey(_binding: &ShortcutBinding) -> Result<(), ShortcutBin
Err(ShortcutBindingError::Unavailable)
}
pub fn is_side_specific_modifier_tag(_raw: &str) -> bool {
false
}
pub fn binding_requires_side_aware_hook(_binding: &ShortcutBinding) -> bool {
false
}
pub const SIDE_SPECIFIC_NON_DICTATION_MSG: &str =
"Side-specific modifier shortcuts are only supported for dictation start/stop.";
pub fn reject_side_specific_non_dictation(_binding: &ShortcutBinding) -> Result<(), String> {
Ok(())
}
pub fn bindings_overlap(_left: &ShortcutBinding, _right: &ShortcutBinding) -> bool {
false
}
pub fn normalize_side_modifier_tag(raw: &str) -> String {
raw.trim().to_ascii_lowercase()
}
pub fn legacy_modifier_trigger(_binding: &ShortcutBinding) -> Option<HotkeyTrigger> {
None
}
@@ -27,6 +50,9 @@ pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding {
HotkeyTrigger::RightControl => "RightControl",
HotkeyTrigger::LeftControl => "LeftControl",
HotkeyTrigger::RightCommand => "RightCommand",
HotkeyTrigger::LeftCommand => "LeftCommand",
HotkeyTrigger::LeftShift => "LeftShift",
HotkeyTrigger::RightShift => "RightShift",
HotkeyTrigger::Fn => "Fn",
HotkeyTrigger::MediaPlayPause => "MediaPlayPause",
HotkeyTrigger::Custom => "RightOption",
@@ -0,0 +1,45 @@
//! Mobile stub — side-specific combo hotkeys are unavailable on Android/iOS.
use std::sync::mpsc::Sender;
use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent};
use crate::types::ShortcutBinding;
#[derive(Debug, Clone, Copy)]
pub enum SideModifier {
CmdLeft,
CmdRight,
CtrlLeft,
CtrlRight,
AltLeft,
AltRight,
ShiftLeft,
ShiftRight,
}
pub struct SideAwareComboMonitor;
impl SideAwareComboMonitor {
pub fn start(
_binding: ShortcutBinding,
_tx: Sender<ComboHotkeyEvent>,
) -> Result<Self, ComboHotkeyError> {
Err(ComboHotkeyError::RegisterFailed(
"Side-specific combo hotkeys are not available on mobile".into(),
))
}
}
pub fn handle_side_modifier(_side: SideModifier, _pressed: bool) {}
pub fn handle_primary_key(_primary: &str, _pressed: bool) {}
#[cfg(target_os = "macos")]
pub mod platform {
pub fn dispatch_keycode(_keycode: i64, _flags_changed: bool, _flags: u64, _pressed: bool) {}
}
#[cfg(target_os = "windows")]
pub mod platform {
pub fn dispatch_vk(_vk_code: u32, _pressed: bool) {}
}
@@ -12,6 +12,114 @@ pub enum ShortcutBindingError {
UnsupportedKey(String),
}
const SIDE_MODIFIER_TAGS: &[&str] = &[
"cmd-left",
"cmd-right",
"ctrl-left",
"ctrl-right",
"alt-left",
"alt-right",
"shift-left",
"shift-right",
"super-left",
"super-right",
];
pub fn is_side_specific_modifier_tag(raw: &str) -> bool {
SIDE_MODIFIER_TAGS.contains(&normalize_side_modifier_tag(raw).as_str())
}
pub fn binding_requires_side_aware_hook(binding: &ShortcutBinding) -> bool {
!binding.modifiers.is_empty()
&& binding
.modifiers
.iter()
.any(|tag| is_side_specific_modifier_tag(tag))
}
pub const SIDE_SPECIFIC_NON_DICTATION_MSG: &str =
"Side-specific modifier shortcuts are only supported for dictation start/stop.";
pub fn reject_side_specific_non_dictation(binding: &ShortcutBinding) -> Result<(), String> {
if binding_requires_side_aware_hook(binding) {
return Err(SIDE_SPECIFIC_NON_DICTATION_MSG.to_string());
}
Ok(())
}
fn physical_modifier_class(raw: &str) -> String {
let tag = normalize_side_modifier_tag(raw);
if is_side_specific_modifier_tag(&tag) {
if tag.starts_with("cmd-") || tag.starts_with("super-") {
return "Super".to_string();
}
if tag.starts_with("ctrl-") {
return "Control".to_string();
}
if tag.starts_with("alt-") {
return "Alt".to_string();
}
if tag.starts_with("shift-") {
return "Shift".to_string();
}
}
physical_class_from_generic_tag(&normalize_modifier_tag(raw))
}
fn physical_class_from_generic_tag(tag: &str) -> String {
match tag {
"ctrl" | "control" => "Control".to_string(),
"alt" | "option" | "opt" => "Alt".to_string(),
"shift" => "Shift".to_string(),
#[cfg(target_os = "windows")]
"cmd" | "command" => "Control".to_string(),
#[cfg(target_os = "windows")]
"super" | "meta" | "win" => "Super".to_string(),
#[cfg(not(target_os = "windows"))]
"cmd" | "command" | "super" | "meta" | "win" => "Super".to_string(),
other => other.to_string(),
}
}
fn physical_modifier_set(binding: &ShortcutBinding) -> std::collections::BTreeSet<String> {
binding
.modifiers
.iter()
.map(|raw| physical_modifier_class(raw))
.collect()
}
/// Returns true when two bindings would compete for the same physical shortcut.
pub fn bindings_overlap(left: &ShortcutBinding, right: &ShortcutBinding) -> bool {
let left_legacy = legacy_modifier_trigger(left);
let right_legacy = legacy_modifier_trigger(right);
match (left_legacy, right_legacy) {
(Some(left), Some(right)) => left == right,
(Some(_), None) | (None, Some(_)) => false,
(None, None) => {
if normalize_primary(&left.primary) != normalize_primary(&right.primary) {
return false;
}
let left_side = binding_requires_side_aware_hook(left);
let right_side = binding_requires_side_aware_hook(right);
if left_side && right_side {
let left_mods: std::collections::BTreeSet<String> = left
.modifiers
.iter()
.map(|raw| normalize_side_modifier_tag(raw))
.collect();
let right_mods: std::collections::BTreeSet<String> = right
.modifiers
.iter()
.map(|raw| normalize_side_modifier_tag(raw))
.collect();
return left_mods == right_mods;
}
physical_modifier_set(left) == physical_modifier_set(right)
}
}
}
pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBindingError> {
if legacy_modifier_trigger(binding).is_some() {
return Ok(());
@@ -19,6 +127,15 @@ pub fn validate_binding(binding: &ShortcutBinding) -> Result<(), ShortcutBinding
if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") {
return Ok(());
}
if binding_requires_side_aware_hook(binding) {
parse_primary(&binding.primary)?;
for raw in &binding.modifiers {
if !is_side_specific_modifier_tag(raw) {
return Err(ShortcutBindingError::UnsupportedModifier(raw.clone()));
}
}
return Ok(());
}
parse_global_hotkey(binding)?;
Ok(())
}
@@ -53,6 +170,11 @@ pub fn legacy_modifier_trigger(binding: &ShortcutBinding) -> Option<HotkeyTrigge
"rightcommand" | "rightcmd" | "rightsuper" | "rightmeta" => {
Some(HotkeyTrigger::RightCommand)
}
"leftcommand" | "leftcmd" | "leftsuper" | "leftmeta" => Some(HotkeyTrigger::LeftCommand),
"leftshift" => Some(HotkeyTrigger::LeftShift),
"rightshift" => Some(HotkeyTrigger::RightShift),
"shiftleft" => Some(HotkeyTrigger::LeftShift),
"shiftright" => Some(HotkeyTrigger::RightShift),
"fn" | "function" => Some(HotkeyTrigger::Fn),
"mediaplaypause" | "mediaplay" | "playpause" => Some(HotkeyTrigger::MediaPlayPause),
_ => None,
@@ -66,6 +188,9 @@ pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding {
HotkeyTrigger::RightControl => "RightControl",
HotkeyTrigger::LeftControl => "LeftControl",
HotkeyTrigger::RightCommand => "RightCommand",
HotkeyTrigger::LeftCommand => "LeftCommand",
HotkeyTrigger::LeftShift => "LeftShift",
HotkeyTrigger::RightShift => "RightShift",
HotkeyTrigger::Fn => "Fn",
HotkeyTrigger::MediaPlayPause => "MediaPlayPause",
HotkeyTrigger::Custom => "RightOption",
@@ -76,8 +201,19 @@ pub fn binding_from_legacy_trigger(trigger: HotkeyTrigger) -> ShortcutBinding {
}
}
pub fn normalize_side_modifier_tag(raw: &str) -> String {
match raw.trim().to_ascii_lowercase().as_str() {
"super-left" => "cmd-left".into(),
"super-right" => "cmd-right".into(),
tag => tag.to_string(),
}
}
fn normalize_modifier_tag(raw: &str) -> String {
let tag = raw.trim().to_ascii_lowercase();
if is_side_specific_modifier_tag(&tag) {
return tag;
}
#[cfg(target_os = "windows")]
{
if matches!(tag.as_str(), "cmd" | "command") {
@@ -95,7 +231,7 @@ fn normalize_primary(raw: &str) -> String {
.to_ascii_lowercase()
}
fn parse_primary(raw: &str) -> Result<Code, ShortcutBindingError> {
pub fn parse_primary(raw: &str) -> Result<Code, ShortcutBindingError> {
let trimmed = raw.trim();
if trimmed.is_empty() {
return Err(ShortcutBindingError::UnsupportedKey("(空)".into()));
@@ -232,6 +368,41 @@ mod tests {
legacy_modifier_trigger(&binding),
Some(HotkeyTrigger::RightControl)
);
let left_cmd = ShortcutBinding {
primary: "LeftCommand".into(),
modifiers: vec![],
};
assert_eq!(
legacy_modifier_trigger(&left_cmd),
Some(HotkeyTrigger::LeftCommand)
);
}
#[test]
fn side_specific_combo_requires_side_aware_hook() {
let binding = ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into(), "shift-right".into()],
};
assert!(binding_requires_side_aware_hook(&binding));
assert!(validate_binding(&binding).is_ok());
}
#[test]
fn generic_combo_uses_global_hotkey_path() {
let binding = ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd".into(), "shift".into()],
};
assert!(!binding_requires_side_aware_hook(&binding));
assert!(parse_global_hotkey(&binding).is_ok());
}
#[test]
fn super_side_aliases_normalize_to_cmd() {
assert_eq!(normalize_side_modifier_tag("super-left"), "cmd-left");
assert_eq!(normalize_side_modifier_tag("Super-Right"), "cmd-right");
assert!(is_side_specific_modifier_tag("super-left"));
}
#[test]
@@ -255,4 +426,60 @@ mod tests {
assert!(parsed.mods.contains(Modifiers::SHIFT));
}
}
fn combo(primary: &str, modifiers: Vec<&str>) -> ShortcutBinding {
ShortcutBinding {
primary: primary.into(),
modifiers: modifiers.into_iter().map(str::to_string).collect(),
}
}
#[test]
fn side_combo_overlaps_generic_combo_with_same_physical_modifiers() {
#[cfg(target_os = "windows")]
{
assert!(!bindings_overlap(
&combo("D", vec!["cmd-left"]),
&combo("D", vec!["cmd"]),
));
assert!(bindings_overlap(
&combo("D", vec!["cmd-left"]),
&combo("D", vec!["super"]),
));
}
#[cfg(not(target_os = "windows"))]
{
assert!(bindings_overlap(
&combo("D", vec!["cmd-left"]),
&combo("D", vec!["cmd"]),
));
}
}
#[test]
fn side_combo_does_not_overlap_generic_combo_with_extra_modifier() {
assert!(!bindings_overlap(
&combo("D", vec!["cmd-left"]),
&combo("D", vec!["cmd", "shift"]),
));
}
#[test]
fn side_combos_with_different_sides_do_not_overlap() {
assert!(!bindings_overlap(
&combo("D", vec!["cmd-left"]),
&combo("D", vec!["cmd-right"]),
));
}
#[test]
fn rejects_side_specific_non_dictation_shortcut() {
let binding = combo("D", vec!["cmd-left"]);
assert!(binding_requires_side_aware_hook(&binding));
assert_eq!(
reject_side_specific_non_dictation(&binding).unwrap_err(),
SIDE_SPECIFIC_NON_DICTATION_MSG,
);
assert!(reject_side_specific_non_dictation(&combo("D", vec!["cmd"])).is_ok());
}
}
@@ -0,0 +1,568 @@
//! Side-specific combo hotkey matching (e.g. Left Cmd + D).
//!
//! `global-hotkey` cannot distinguish left/right modifiers. This module maintains
//! physical modifier state and matches combos registered via [`SideAwareComboMonitor`].
use std::sync::mpsc::Sender;
use std::sync::{OnceLock, RwLock};
use parking_lot::Mutex;
use crate::combo_hotkey::ComboHotkeyEvent;
use crate::shortcut_binding::{is_side_specific_modifier_tag, normalize_side_modifier_tag};
use crate::types::ShortcutBinding;
static ACTIVE_MONITOR: OnceLock<RwLock<Option<ActiveSideCombo>>> = OnceLock::new();
struct ActiveSideCombo {
binding: ShortcutBinding,
tx: Sender<ComboHotkeyEvent>,
state: Mutex<SideAwareComboState>,
}
#[derive(Default, Clone, Copy, Debug)]
struct ModifierSideState {
cmd_left: bool,
cmd_right: bool,
ctrl_left: bool,
ctrl_right: bool,
alt_left: bool,
alt_right: bool,
shift_left: bool,
shift_right: bool,
}
#[derive(Debug)]
struct SideAwareComboState {
binding: ShortcutBinding,
modifiers: ModifierSideState,
combo_active: bool,
}
impl SideAwareComboState {
fn new(binding: ShortcutBinding) -> Self {
Self {
binding,
modifiers: ModifierSideState::default(),
combo_active: false,
}
}
fn set_side(&mut self, side: SideModifier, pressed: bool) {
match side {
SideModifier::CmdLeft => self.modifiers.cmd_left = pressed,
SideModifier::CmdRight => self.modifiers.cmd_right = pressed,
SideModifier::CtrlLeft => self.modifiers.ctrl_left = pressed,
SideModifier::CtrlRight => self.modifiers.ctrl_right = pressed,
SideModifier::AltLeft => self.modifiers.alt_left = pressed,
SideModifier::AltRight => self.modifiers.alt_right = pressed,
SideModifier::ShiftLeft => self.modifiers.shift_left = pressed,
SideModifier::ShiftRight => self.modifiers.shift_right = pressed,
}
}
fn expected_modifier_tags(&self) -> Vec<String> {
let mut tags: Vec<String> = self
.binding
.modifiers
.iter()
.map(|raw| normalize_side_modifier_tag(raw))
.collect();
tags.sort();
tags
}
fn pressed_modifier_tags(&self) -> Vec<String> {
let mut tags = Vec::new();
if self.modifiers.cmd_left {
tags.push("cmd-left".into());
}
if self.modifiers.cmd_right {
tags.push("cmd-right".into());
}
if self.modifiers.ctrl_left {
tags.push("ctrl-left".into());
}
if self.modifiers.ctrl_right {
tags.push("ctrl-right".into());
}
if self.modifiers.alt_left {
tags.push("alt-left".into());
}
if self.modifiers.alt_right {
tags.push("alt-right".into());
}
if self.modifiers.shift_left {
tags.push("shift-left".into());
}
if self.modifiers.shift_right {
tags.push("shift-right".into());
}
tags.sort();
tags
}
fn modifiers_match(&self) -> bool {
self.expected_modifier_tags() == self.pressed_modifier_tags()
}
fn on_primary(&mut self, primary: &str, pressed: bool) -> Option<ComboHotkeyEvent> {
if !primary_eq(&self.binding.primary, primary) {
return None;
}
if pressed {
if self.modifiers_match() && !self.combo_active {
self.combo_active = true;
return Some(ComboHotkeyEvent::Pressed);
}
return None;
}
if self.combo_active {
self.combo_active = false;
return Some(ComboHotkeyEvent::Released);
}
None
}
fn on_modifier_release(&mut self, side: SideModifier) -> Option<ComboHotkeyEvent> {
self.set_side(side, false);
if self.combo_active && !self.modifiers_match() {
self.combo_active = false;
return Some(ComboHotkeyEvent::Released);
}
None
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SideModifier {
CmdLeft,
CmdRight,
CtrlLeft,
CtrlRight,
AltLeft,
AltRight,
ShiftLeft,
ShiftRight,
}
pub struct SideAwareComboMonitor;
impl SideAwareComboMonitor {
pub fn start(
binding: ShortcutBinding,
tx: Sender<ComboHotkeyEvent>,
) -> Result<Self, crate::combo_hotkey::ComboHotkeyError> {
if binding.modifiers.is_empty()
|| binding
.modifiers
.iter()
.any(|tag| !is_side_specific_modifier_tag(tag))
{
return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier(
"binding is not side-specific".into(),
));
}
crate::shortcut_binding::parse_primary(&binding.primary).map_err(|e| {
crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(e.to_string())
})?;
let slot = ACTIVE_MONITOR.get_or_init(|| RwLock::new(None));
let mut guard = slot.write().expect("side combo monitor lock poisoned");
*guard = Some(ActiveSideCombo {
binding: binding.clone(),
tx,
state: Mutex::new(SideAwareComboState::new(binding)),
});
Ok(Self)
}
}
impl Drop for SideAwareComboMonitor {
fn drop(&mut self) {
if let Some(slot) = ACTIVE_MONITOR.get() {
*slot.write().expect("side combo monitor lock poisoned") = None;
}
}
}
fn with_active<F, R>(f: F) -> Option<R>
where
F: FnOnce(&ActiveSideCombo) -> R,
{
let slot = ACTIVE_MONITOR.get()?;
let guard = slot.read().ok()?;
guard.as_ref().map(f)
}
fn send_event(tx: &Sender<ComboHotkeyEvent>, evt: ComboHotkeyEvent) {
if let Err(err) = tx.send(evt) {
log::warn!("[side-aware-combo] event send failed: {err}");
}
}
pub fn handle_side_modifier(side: SideModifier, pressed: bool) {
if let Some(evt) = with_active(|active| {
let mut state = active.state.lock();
if pressed {
state.set_side(side, true);
None
} else {
state.on_modifier_release(side)
}
})
.flatten()
{
with_active(|active| send_event(&active.tx, evt));
}
}
pub fn handle_primary_key(primary: &str, pressed: bool) {
if let Some(evt) = with_active(|active| {
let mut state = active.state.lock();
state.on_primary(primary, pressed)
})
.flatten()
{
with_active(|active| send_event(&active.tx, evt));
}
}
fn primary_eq(expected: &str, actual: &str) -> bool {
expected.trim().eq_ignore_ascii_case(actual.trim())
}
#[cfg(target_os = "windows")]
pub mod platform {
use super::*;
use windows::Win32::UI::Input::KeyboardAndMouse::{
VK_BACK, VK_DELETE, VK_DOWN, VK_END, VK_ESCAPE, VK_F1, VK_F10, VK_F11, VK_F12, VK_F2,
VK_F3, VK_F4, VK_F5, VK_F6, VK_F7, VK_F8, VK_F9, VK_HOME, VK_INSERT, VK_LCONTROL,
VK_LEFT, VK_LMENU, VK_LSHIFT, VK_LWIN, VK_OEM_1, VK_OEM_2, VK_OEM_3, VK_OEM_4, VK_OEM_5,
VK_OEM_6, VK_OEM_7, VK_OEM_COMMA, VK_OEM_MINUS, VK_OEM_PERIOD, VK_OEM_PLUS, VK_RETURN,
VK_RIGHT, VK_RCONTROL, VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SPACE, VK_TAB, VK_UP,
};
pub fn dispatch_vk(vk_code: u32, pressed: bool) {
if let Some(side) = side_from_vk(vk_code) {
handle_side_modifier(side, pressed);
return;
}
if let Some(primary) = primary_from_vk(vk_code) {
handle_primary_key(&primary, pressed);
}
}
fn side_from_vk(vk_code: u32) -> Option<SideModifier> {
match vk_code {
x if x == VK_LWIN.0 as u32 => Some(SideModifier::CmdLeft),
x if x == VK_RWIN.0 as u32 => Some(SideModifier::CmdRight),
x if x == VK_LCONTROL.0 as u32 => Some(SideModifier::CtrlLeft),
x if x == VK_RCONTROL.0 as u32 => Some(SideModifier::CtrlRight),
x if x == VK_LMENU.0 as u32 => Some(SideModifier::AltLeft),
x if x == VK_RMENU.0 as u32 => Some(SideModifier::AltRight),
x if x == VK_LSHIFT.0 as u32 => Some(SideModifier::ShiftLeft),
x if x == VK_RSHIFT.0 as u32 => Some(SideModifier::ShiftRight),
_ => None,
}
}
fn primary_from_vk(vk_code: u32) -> Option<String> {
if (0x41..=0x5A).contains(&vk_code) {
return Some(((vk_code as u8) as char).to_string());
}
if (0x30..=0x39).contains(&vk_code) {
return Some(((vk_code as u8) as char).to_string());
}
let named = match vk_code {
x if x == VK_SPACE.0 as u32 => "Space",
x if x == VK_RETURN.0 as u32 => "Enter",
x if x == VK_TAB.0 as u32 => "Tab",
x if x == VK_BACK.0 as u32 => "Backspace",
x if x == VK_DELETE.0 as u32 => "Delete",
x if x == VK_ESCAPE.0 as u32 => "Escape",
x if x == VK_HOME.0 as u32 => "Home",
x if x == VK_END.0 as u32 => "End",
x if x == VK_INSERT.0 as u32 => "Insert",
x if x == VK_UP.0 as u32 => "ArrowUp",
x if x == VK_DOWN.0 as u32 => "ArrowDown",
x if x == VK_LEFT.0 as u32 => "ArrowLeft",
x if x == VK_RIGHT.0 as u32 => "ArrowRight",
x if x == VK_F1.0 as u32 => "F1",
x if x == VK_F2.0 as u32 => "F2",
x if x == VK_F3.0 as u32 => "F3",
x if x == VK_F4.0 as u32 => "F4",
x if x == VK_F5.0 as u32 => "F5",
x if x == VK_F6.0 as u32 => "F6",
x if x == VK_F7.0 as u32 => "F7",
x if x == VK_F8.0 as u32 => "F8",
x if x == VK_F9.0 as u32 => "F9",
x if x == VK_F10.0 as u32 => "F10",
x if x == VK_F11.0 as u32 => "F11",
x if x == VK_F12.0 as u32 => "F12",
x if x == VK_OEM_1.0 as u32 => ";",
x if x == VK_OEM_PLUS.0 as u32 => "=",
x if x == VK_OEM_COMMA.0 as u32 => ",",
x if x == VK_OEM_MINUS.0 as u32 => "-",
x if x == VK_OEM_PERIOD.0 as u32 => ".",
x if x == VK_OEM_2.0 as u32 => "/",
x if x == VK_OEM_3.0 as u32 => "`",
x if x == VK_OEM_4.0 as u32 => "[",
x if x == VK_OEM_5.0 as u32 => "\\",
x if x == VK_OEM_6.0 as u32 => "]",
x if x == VK_OEM_7.0 as u32 => "'",
_ => return None,
};
Some(named.to_string())
}
}
/// macOS virtual keycode → ShortcutBinding.primary (US ANSI layout).
fn macos_keycode_to_primary(keycode: i64) -> Option<&'static str> {
match keycode {
// A-Z (non-contiguous on macOS)
0 => Some("A"),
1 => Some("S"),
2 => Some("D"),
3 => Some("F"),
4 => Some("H"),
5 => Some("G"),
6 => Some("Z"),
7 => Some("X"),
8 => Some("C"),
9 => Some("V"),
11 => Some("B"),
12 => Some("Q"),
13 => Some("W"),
14 => Some("E"),
15 => Some("R"),
16 => Some("Y"),
17 => Some("T"),
31 => Some("O"),
32 => Some("U"),
34 => Some("I"),
35 => Some("P"),
37 => Some("L"),
38 => Some("J"),
40 => Some("K"),
45 => Some("N"),
46 => Some("M"),
// 0-9 top row
18 => Some("1"),
19 => Some("2"),
20 => Some("3"),
21 => Some("4"),
22 => Some("6"),
23 => Some("5"),
25 => Some("9"),
26 => Some("7"),
28 => Some("8"),
29 => Some("0"),
// Punctuation
24 => Some("="),
27 => Some("-"),
30 => Some("]"),
33 => Some("["),
39 => Some("'"),
41 => Some(";"),
42 => Some("\\"),
43 => Some(","),
44 => Some("/"),
47 => Some("."),
50 => Some("`"),
// Special keys
36 => Some("Enter"),
48 => Some("Tab"),
49 => Some("Space"),
51 => Some("Backspace"),
53 => Some("Escape"),
117 => Some("Delete"),
123 => Some("ArrowLeft"),
124 => Some("ArrowRight"),
125 => Some("ArrowDown"),
126 => Some("ArrowUp"),
// Function keys
122 => Some("F1"),
120 => Some("F2"),
99 => Some("F3"),
118 => Some("F4"),
96 => Some("F5"),
97 => Some("F6"),
98 => Some("F7"),
100 => Some("F8"),
101 => Some("F9"),
109 => Some("F10"),
103 => Some("F11"),
111 => Some("F12"),
_ => None,
}
}
#[cfg(target_os = "macos")]
pub mod platform {
use super::*;
use std::os::raw::c_int;
#[link(name = "CoreGraphics", kind = "framework")]
extern "C" {
fn CGEventSourceKeyState(state: c_int, key: u16) -> bool;
}
const COMBINED_SESSION: c_int = 1;
pub fn dispatch_keycode(keycode: i64, flags_changed: bool, _flags: u64, pressed: bool) {
if flags_changed {
if let Some(side) = side_from_keycode(keycode) {
handle_side_modifier(side, keycode_is_down(keycode));
}
return;
}
if let Some(primary) = primary_from_keycode(keycode) {
handle_primary_key(&primary, pressed);
}
}
fn keycode_is_down(keycode: i64) -> bool {
unsafe { CGEventSourceKeyState(COMBINED_SESSION, keycode as u16) }
}
pub(crate) fn side_from_keycode(keycode: i64) -> Option<SideModifier> {
match keycode {
55 => Some(SideModifier::CmdLeft),
54 => Some(SideModifier::CmdRight),
59 => Some(SideModifier::CtrlLeft),
62 => Some(SideModifier::CtrlRight),
58 => Some(SideModifier::AltLeft),
61 => Some(SideModifier::AltRight),
56 => Some(SideModifier::ShiftLeft),
60 => Some(SideModifier::ShiftRight),
_ => None,
}
}
fn primary_from_keycode(keycode: i64) -> Option<String> {
super::macos_keycode_to_primary(keycode).map(str::to_string)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn macos_keycode_2_is_d() {
assert_eq!(macos_keycode_to_primary(2), Some("D"));
}
#[test]
fn macos_keycode_us_ansi_samples() {
assert_eq!(macos_keycode_to_primary(17), Some("T"));
assert_eq!(macos_keycode_to_primary(11), Some("B"));
assert_eq!(macos_keycode_to_primary(24), Some("="));
assert_eq!(macos_keycode_to_primary(29), Some("0"));
assert_eq!(macos_keycode_to_primary(44), Some("/"));
}
#[test]
fn macos_letter_keycodes_are_non_contiguous() {
assert_eq!(macos_keycode_to_primary(0), Some("A"));
assert_eq!(macos_keycode_to_primary(1), Some("S"));
assert_eq!(macos_keycode_to_primary(8), Some("C"));
assert_eq!(macos_keycode_to_primary(9), Some("V"));
assert_eq!(macos_keycode_to_primary(12), Some("Q"));
assert_eq!(macos_keycode_to_primary(17), Some("T"));
}
#[test]
fn side_specific_combo_matches_required_modifiers() {
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
});
state.set_side(SideModifier::CmdLeft, true);
assert!(state.modifiers_match());
let evt = state.on_primary("D", true);
assert_eq!(evt, Some(ComboHotkeyEvent::Pressed));
}
#[test]
fn shift_right_combo_requires_right_shift() {
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["shift-right".into()],
});
state.set_side(SideModifier::ShiftLeft, true);
assert!(!state.modifiers_match());
state.set_side(SideModifier::ShiftLeft, false);
state.set_side(SideModifier::ShiftRight, true);
assert!(state.modifiers_match());
assert_eq!(
state.on_primary("D", true),
Some(ComboHotkeyEvent::Pressed)
);
}
#[test]
fn extra_modifier_blocks_exact_match() {
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
});
state.set_side(SideModifier::CmdLeft, true);
state.set_side(SideModifier::ShiftLeft, true);
assert!(!state.modifiers_match());
assert_eq!(state.on_primary("D", true), None);
}
#[test]
fn releasing_extra_modifier_allows_combo() {
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["cmd-left".into()],
});
state.set_side(SideModifier::CmdLeft, true);
state.set_side(SideModifier::CmdRight, true);
assert!(!state.modifiers_match());
state.set_side(SideModifier::CmdRight, false);
assert!(state.modifiers_match());
}
#[test]
fn super_left_alias_matches_physical_cmd_left() {
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["super-left".into()],
});
state.set_side(SideModifier::CmdLeft, true);
assert!(state.modifiers_match());
assert_eq!(
state.on_primary("D", true),
Some(ComboHotkeyEvent::Pressed)
);
}
#[test]
fn super_left_alias_rejects_extra_modifier() {
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "D".into(),
modifiers: vec!["super-left".into()],
});
state.set_side(SideModifier::CmdLeft, true);
state.set_side(SideModifier::ShiftLeft, true);
assert!(!state.modifiers_match());
assert_eq!(state.on_primary("D", true), None);
}
#[cfg(target_os = "macos")]
#[test]
fn macos_side_keycodes_are_distinct() {
use crate::side_aware_combo::platform::side_from_keycode;
assert_eq!(side_from_keycode(55), Some(SideModifier::CmdLeft));
assert_eq!(side_from_keycode(54), Some(SideModifier::CmdRight));
assert_eq!(side_from_keycode(56), Some(SideModifier::ShiftLeft));
assert_eq!(side_from_keycode(60), Some(SideModifier::ShiftRight));
}
}
+20 -1
View File
@@ -2082,6 +2082,9 @@ pub enum HotkeyTrigger {
RightControl,
LeftControl,
RightCommand,
LeftCommand,
LeftShift,
RightShift,
Fn,
RightAlt, // Windows synonym for RightOption
MediaPlayPause,
@@ -2096,6 +2099,9 @@ impl HotkeyTrigger {
HotkeyTrigger::RightControl => "右 Control",
HotkeyTrigger::LeftControl => "左 Control",
HotkeyTrigger::RightCommand => "右 Command",
HotkeyTrigger::LeftCommand => "左 Command",
HotkeyTrigger::LeftShift => "左 Shift",
HotkeyTrigger::RightShift => "右 Shift",
HotkeyTrigger::Fn => "Fn (地球键)",
HotkeyTrigger::RightAlt => "右 Alt",
HotkeyTrigger::MediaPlayPause => "⏯ Media 播放/暂停",
@@ -2189,6 +2195,9 @@ fn legacy_trigger_code(trigger: HotkeyTrigger) -> &'static str {
HotkeyTrigger::RightControl => "ControlRight",
HotkeyTrigger::LeftControl => "ControlLeft",
HotkeyTrigger::RightCommand => "MetaRight",
HotkeyTrigger::LeftCommand => "MetaLeft",
HotkeyTrigger::LeftShift => "ShiftLeft",
HotkeyTrigger::RightShift => "ShiftRight",
#[cfg(target_os = "windows")]
HotkeyTrigger::Fn => "ControlRight",
#[cfg(not(target_os = "windows"))]
@@ -2309,6 +2318,9 @@ impl HotkeyCapability {
HotkeyTrigger::RightControl,
HotkeyTrigger::LeftControl,
HotkeyTrigger::RightCommand,
HotkeyTrigger::LeftCommand,
HotkeyTrigger::LeftShift,
HotkeyTrigger::RightShift,
HotkeyTrigger::Fn,
HotkeyTrigger::Custom,
],
@@ -2329,6 +2341,9 @@ impl HotkeyCapability {
HotkeyTrigger::RightAlt,
HotkeyTrigger::LeftControl,
HotkeyTrigger::RightCommand,
HotkeyTrigger::LeftCommand,
HotkeyTrigger::LeftShift,
HotkeyTrigger::RightShift,
HotkeyTrigger::MediaPlayPause,
HotkeyTrigger::Custom,
],
@@ -2351,6 +2366,9 @@ impl HotkeyCapability {
HotkeyTrigger::RightAlt,
HotkeyTrigger::RightControl,
HotkeyTrigger::LeftControl,
HotkeyTrigger::LeftCommand,
HotkeyTrigger::LeftShift,
HotkeyTrigger::RightShift,
HotkeyTrigger::Custom,
],
requires_accessibility_permission: false,
@@ -2358,7 +2376,8 @@ impl HotkeyCapability {
supports_side_specific_modifiers: true,
explicit_fallback_available: false,
status_hint: Some(
"Linux 使用 fcitx5 插件监听热键和提交文字;无需桌面环境额外配置。".into(),
"Linux 使用 fcitx5 插件监听热键和提交文字。鼠标/侧别组合键需 evdev 读取 /dev/input/event*;若无权限请将用户加入 input 组(sudo usermod -aG input $USER)后重新登录。"
.into(),
),
}
}
@@ -1,8 +1,8 @@
import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } from 'react';
import { useTranslation } from 'react-i18next';
import { currentPlatform, formatComboLabel } from '../lib/hotkey';
import { formatComboLabel, getHotkeyTriggerLabel, modifiersFromPressedCodes, shortcutFromLegacyTrigger } from '../lib/hotkey';
import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc';
import type { ShortcutBinding } from '../lib/types';
import type { HotkeyTrigger, ShortcutBinding } from '../lib/types';
export function ShortcutRecorder({
value,
@@ -12,6 +12,8 @@ export function ShortcutRecorder({
onDisable,
disableLabel,
comboOnly = false,
modifierPresets = [],
sideSpecificModifiers = false,
}: {
value: ShortcutBinding;
onSave: (binding: ShortcutBinding) => Promise<void>;
@@ -22,12 +24,21 @@ export function ShortcutRecorder({
disableLabel?: string;
/** 仅允许组合键(修饰键+主键 / 功能键);拒绝单修饰键,因为全局热键无法注册它。 */
comboOnly?: boolean;
/** 平台可用的单修饰键快捷选项(如 Fn,WebView 无法录制)。 */
modifierPresets?: HotkeyTrigger[];
/** 听写 start/stop 专用:录制 cmd-left / ctrl-right 等侧向修饰键。 */
sideSpecificModifiers?: boolean;
}) {
const { t } = useTranslation();
const [recording, setRecording] = useState(false);
const [error, setError] = useState<string | null>(null);
const pendingModifier = useRef<ShortcutBinding | null>(null);
const pendingTimer = useRef<number | null>(null);
const pressedCodes = useRef<Set<string>>(new Set());
const clearPressedCodes = () => {
pressedCodes.current.clear();
};
const clearPendingModifier = () => {
if (pendingTimer.current !== null) {
@@ -37,8 +48,13 @@ export function ShortcutRecorder({
pendingModifier.current = null;
};
useEffect(() => () => {
const resetRecordingState = () => {
clearPendingModifier();
clearPressedCodes();
};
useEffect(() => () => {
resetRecordingState();
void setShortcutRecordingActive(false);
}, []);
@@ -52,14 +68,14 @@ export function ShortcutRecorder({
useEffect(() => {
if (!disabled || !recording) return;
setRecording(false);
clearPendingModifier();
resetRecordingState();
}, [disabled, recording]);
const finish = async (binding: ShortcutBinding) => {
try {
await validateShortcutBinding(binding);
await onSave(binding);
clearPendingModifier();
resetRecordingState();
setRecording(false);
setError(null);
} catch {
@@ -74,13 +90,12 @@ export function ShortcutRecorder({
if (e.key === 'Escape') {
setRecording(false);
setError(null);
clearPendingModifier();
resetRecordingState();
return;
}
if (isModifierKey(e.key)) {
// comboOnly:快速 Agent 等全局热键不支持单修饰键,提示用户配真正的组合键。
pressedCodes.current.add(e.code);
if (comboOnly) {
setError(t('settings.recording.comboNeedKey', '请配组合键(如 ⌘⇧J),不支持单独的修饰键'));
return;
}
const primary = modifierPrimaryFromCode(e.code, e.key);
@@ -97,13 +112,17 @@ export function ShortcutRecorder({
}
clearPendingModifier();
const primary = primaryFromKeyboardEvent(e);
if (primary) void finish({ primary, modifiers: modifiersFromKeyboardEvent(e) });
if (primary) {
void finish({ primary, modifiers: modifiersFromPressedCodes(pressedCodes.current, sideSpecificModifiers) });
}
};
const onKeyUp = (e: KeyboardEvent<HTMLDivElement>) => {
if (!recording || disabled || !isModifierKey(e.key)) return;
e.preventDefault();
e.stopPropagation();
pressedCodes.current.delete(e.code);
if (comboOnly) return;
const primary = modifierPrimaryFromCode(e.code, e.key);
if (primary && pendingModifier.current?.primary === primary) {
const binding = pendingModifier.current;
@@ -137,7 +156,6 @@ export function ShortcutRecorder({
cursor: recording || disabled ? 'default' : 'pointer',
opacity: disabled ? 0.68 : 1,
};
// 「停用」旋钮:与「录制快捷键」同高、紧贴在它左边,组成两个并排的小旋钮。
const disableKnobStyle: CSSProperties = {
fontSize: 12,
padding: '5px 12px',
@@ -149,13 +167,24 @@ export function ShortcutRecorder({
fontWeight: 500,
cursor: recording ? 'default' : 'pointer',
};
// 录制按钮(+ 可选停用旋钮)成组靠右,保证「停用」永远贴着「录制」。
const controlsGroupStyle: CSSProperties = {
display: 'inline-flex',
alignItems: 'center',
gap: 8,
marginLeft: alignRecordButton ? 'auto' : undefined,
};
const presetChipStyle: CSSProperties = {
fontSize: 11,
padding: '4px 10px',
borderRadius: 999,
border: '0.5px solid var(--ol-line-strong)',
background: 'transparent',
color: 'var(--ol-ink-3)',
fontFamily: 'inherit',
cursor: disabled || recording ? 'default' : 'pointer',
};
const presetTriggers = modifierPresets.filter(t => t !== 'custom' && t !== 'mediaPlayPause');
return (
<div style={rootStyle}>
@@ -181,7 +210,7 @@ export function ShortcutRecorder({
if (disabled) return;
setRecording(true);
setError(null);
clearPendingModifier();
resetRecordingState();
}}
disabled={recording || disabled}
style={recordButtonStyle}
@@ -190,6 +219,24 @@ export function ShortcutRecorder({
</button>
</div>
</div>
{!comboOnly && presetTriggers.length > 0 && (
<div style={{ display: 'flex', flexWrap: 'wrap', gap: 6 }}>
<span style={{ fontSize: 11, color: 'var(--ol-ink-4)', alignSelf: 'center' }}>
{t('settings.recording.modifierPresetsLabel', '常用单键:')}
</span>
{presetTriggers.map(trigger => (
<button
key={trigger}
type="button"
disabled={disabled || recording}
style={presetChipStyle}
onClick={() => void finish(shortcutFromLegacyTrigger(trigger))}
>
{getHotkeyTriggerLabel(trigger)}
</button>
))}
</div>
)}
{recording && (
<div
tabIndex={-1}
@@ -207,26 +254,20 @@ export function ShortcutRecorder({
);
}
function modifiersFromKeyboardEvent(e: KeyboardEvent): string[] {
const modifiers: string[] = [];
if (e.metaKey && e.key !== 'Meta') modifiers.push(currentPlatform().isMac ? 'cmd' : 'super');
if (e.ctrlKey && e.key !== 'Control') modifiers.push('ctrl');
if (e.altKey && e.key !== 'Alt') modifiers.push('alt');
if (e.shiftKey && e.key !== 'Shift') modifiers.push('shift');
return modifiers;
}
function isModifierKey(key: string): boolean {
return key === 'Control' || key === 'Alt' || key === 'Shift' || key === 'Meta';
}
function modifierPrimaryFromCode(code: string, key: string): string {
if (key === 'Shift') return 'Shift';
if (code === 'ShiftLeft') return 'LeftShift';
if (code === 'ShiftRight') return 'RightShift';
if (code === 'ControlRight') return 'RightControl';
if (code === 'ControlLeft') return 'LeftControl';
if (code === 'AltRight') return 'RightOption';
if (code === 'AltLeft') return 'LeftOption';
if (code === 'MetaRight' || code === 'MetaLeft') return 'RightCommand';
if (code === 'MetaRight') return 'RightCommand';
if (code === 'MetaLeft') return 'LeftCommand';
if (key === 'Shift') return 'Shift';
return '';
}
+108 -2
View File
@@ -181,6 +181,12 @@ function legacyTriggerCode(trigger: HotkeyTrigger | null | undefined): string |
return 'ControlLeft';
case 'rightCommand':
return 'MetaRight';
case 'leftCommand':
return 'MetaLeft';
case 'leftShift':
return 'ShiftLeft';
case 'rightShift':
return 'ShiftRight';
case 'fn':
return 'Fn';
case 'mediaPlayPause':
@@ -190,16 +196,40 @@ function legacyTriggerCode(trigger: HotkeyTrigger | null | undefined): string |
}
}
export function shortcutFromLegacyTrigger(trigger: HotkeyTrigger): ShortcutBinding {
const map: Record<Exclude<HotkeyTrigger, 'custom'>, string> = {
rightOption: 'RightOption',
leftOption: 'LeftOption',
rightControl: 'RightControl',
leftControl: 'LeftControl',
rightCommand: 'RightCommand',
leftCommand: 'LeftCommand',
leftShift: 'LeftShift',
rightShift: 'RightShift',
fn: 'Fn',
rightAlt: 'RightOption',
mediaPlayPause: 'MediaPlayPause',
};
return {
primary: map[trigger as Exclude<HotkeyTrigger, 'custom'>] ?? 'RightOption',
modifiers: [],
};
}
/** 把 ComboBinding 或 QaHotkeyBinding 格式化为可读标签,如 "⌘⇧D" / "Ctrl+Shift+D"。 */
export function formatComboLabel(binding: ComboBinding | QaHotkeyBinding | ShortcutBinding): string {
const parts: string[] = [];
const platform = currentPlatform();
// 固定输出顺序:Ctrl/Cmd → Alt/Option → Shift → Super
const modifierOrder = ['cmd', 'ctrl', 'alt', 'shift', 'super'] as const;
const modifierOrder = [
'cmd-left', 'cmd-right', 'cmd', 'ctrl-left', 'ctrl-right', 'ctrl',
'alt-left', 'alt-right', 'alt', 'shift-left', 'shift-right', 'shift',
'super-left', 'super-right', 'super',
] as const;
for (const tag of modifierOrder) {
if (binding.modifiers.some(m => m.toLowerCase() === tag)) {
parts.push(modifierDisplayName(tag, platform));
parts.push(sideModifierDisplayName(tag, platform));
}
}
@@ -217,7 +247,80 @@ export function currentPlatform(): { isMac: boolean; isWindows: boolean } {
};
}
/** Build side-specific modifier tags from Web KeyboardEvent.code values. */
export function sideModifiersFromPressedCodes(codes: Iterable<string>): string[] {
const set = codes instanceof Set ? codes : new Set(codes);
const modifiers: string[] = [];
if (set.has('MetaLeft')) modifiers.push('cmd-left');
else if (set.has('MetaRight')) modifiers.push('cmd-right');
if (set.has('ControlLeft')) modifiers.push('ctrl-left');
else if (set.has('ControlRight')) modifiers.push('ctrl-right');
if (set.has('AltLeft')) modifiers.push('alt-left');
else if (set.has('AltRight')) modifiers.push('alt-right');
if (set.has('ShiftLeft')) modifiers.push('shift-left');
else if (set.has('ShiftRight')) modifiers.push('shift-right');
return modifiers;
}
/** Build generic modifier tags (cmd/super/ctrl/alt/shift) from pressed key codes. */
export function genericModifiersFromPressedCodes(codes: Iterable<string>): string[] {
const set = codes instanceof Set ? codes : new Set(codes);
const modifiers: string[] = [];
const { isMac } = currentPlatform();
if (isMac) {
if (set.has('MetaLeft') || set.has('MetaRight')) modifiers.push('cmd');
} else if (
set.has('MetaLeft')
|| set.has('MetaRight')
|| set.has('OSLeft')
|| set.has('OSRight')
) {
modifiers.push('super');
}
if (set.has('ControlLeft') || set.has('ControlRight')) modifiers.push('ctrl');
if (set.has('AltLeft') || set.has('AltRight')) modifiers.push('alt');
if (set.has('ShiftLeft') || set.has('ShiftRight')) modifiers.push('shift');
return modifiers;
}
export function modifiersFromPressedCodes(
codes: Iterable<string>,
sideSpecific = false,
): string[] {
return sideSpecific
? sideModifiersFromPressedCodes(codes)
: genericModifiersFromPressedCodes(codes);
}
function modifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string {
return sideModifierDisplayName(tag, platform);
}
function sideModifierDisplayName(tag: string, platform: { isMac: boolean; isWindows: boolean }): string {
const sideLabels: Record<string, string> = platform.isMac
? {
'cmd-left': '左 ⌘',
'cmd-right': '右 ⌘',
'ctrl-left': '左 ⌃',
'ctrl-right': '右 ⌃',
'alt-left': '左 ⌥',
'alt-right': '右 ⌥',
'shift-left': '左 ⇧',
'shift-right': '右 ⇧',
}
: {
'cmd-left': platform.isWindows ? '左 Win' : '左 Super',
'cmd-right': platform.isWindows ? '右 Win' : '右 Super',
'ctrl-left': '左 Ctrl',
'ctrl-right': '右 Ctrl',
'alt-left': '左 Alt',
'alt-right': '右 Alt',
'shift-left': '左 Shift',
'shift-right': '右 Shift',
'super-left': platform.isWindows ? '左 Win' : '左 Super',
'super-right': platform.isWindows ? '右 Win' : '右 Super',
};
if (sideLabels[tag]) return sideLabels[tag];
if (platform.isMac) {
switch (tag) {
case 'cmd': return '\u2318';
@@ -274,6 +377,9 @@ function formatPrimary(primary: string): string {
case 'rightcontrol': return isMac ? 'Right ⌃' : 'Right Ctrl';
case 'leftcontrol': return isMac ? 'Left ⌃' : 'Left Ctrl';
case 'rightcommand': return isMac ? 'Right ⌘' : (currentPlatform().isWindows ? 'Right Win' : 'Right Super');
case 'leftcommand': return isMac ? 'Left ⌘' : (currentPlatform().isWindows ? 'Left Win' : 'Left Super');
case 'leftshift': return isMac ? 'Left ⇧' : 'Left Shift';
case 'rightshift': return isMac ? 'Right ⇧' : 'Right Shift';
case 'fn': return 'Fn';
case 'mediaplaypause': return '⏯ Media';
case 'shift': return isMac ? '⇧' : 'Shift';
@@ -0,0 +1,75 @@
import {
genericModifiersFromPressedCodes,
modifiersFromPressedCodes,
shortcutFromLegacyTrigger,
sideModifiersFromPressedCodes,
} from './hotkey';
function assertEqual<T>(actual: T, expected: T, message: string) {
if (actual !== expected) {
throw new Error(`${message}: expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`);
}
}
function assertDeepEqual(actual: string[], expected: string[], message: string) {
assertEqual(actual.join(','), expected.join(','), message);
}
function mockNavigator(platform: string, userAgent = '') {
Object.defineProperty(globalThis, 'navigator', {
value: { platform, userAgent },
configurable: true,
});
}
assertEqual(
shortcutFromLegacyTrigger('leftCommand').primary,
'LeftCommand',
'maps leftCommand trigger primary',
);
assertDeepEqual(
sideModifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])),
['cmd-left', 'shift-right'],
'MetaLeft+ShiftRight produces side tags',
);
assertDeepEqual(
modifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight']), true),
['cmd-left', 'shift-right'],
'side-specific mode uses side tags',
);
mockNavigator('MacIntel', 'Macintosh');
assertDeepEqual(
genericModifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])),
['cmd', 'shift'],
'macOS generic mode maps Meta to cmd',
);
mockNavigator('Win32', 'Windows');
assertDeepEqual(
genericModifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])),
['super', 'shift'],
'non-mac generic mode maps Meta to super',
);
assertDeepEqual(
modifiersFromPressedCodes(new Set(['MetaLeft', 'ShiftRight'])),
['super', 'shift'],
'default recording on non-mac uses super',
);
assertDeepEqual(
sideModifiersFromPressedCodes(new Set(['MetaRight', 'KeyD'])),
['cmd-right'],
'MetaRight+D binding uses cmd-right only',
);
assertDeepEqual(
sideModifiersFromPressedCodes(new Set(['MetaLeft', 'MetaRight'])),
['cmd-left'],
'left cmd wins when both meta keys are tracked',
);
console.log('hotkeySideModifiers.test.ts passed');
+5 -2
View File
@@ -81,6 +81,9 @@ export type HotkeyTrigger =
| 'rightControl'
| 'leftControl'
| 'rightCommand'
| 'leftCommand'
| 'leftShift'
| 'rightShift'
| 'fn'
| 'rightAlt'
| 'mediaPlayPause'
@@ -125,9 +128,9 @@ export interface HotkeyStatus {
}
export interface ShortcutBinding {
/** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "Shift" */
/** 主键,例如 "D" / "Space" / "F1" / "RightOption" / "LeftShift" */
primary: string;
/** 修饰符列表,元素小写:"cmd" | "shift" | "alt" | "ctrl"。 */
/** 修饰符:泛化 tag(cmd/ctrl/…)或侧别 tag(cmd-left/ctrl-right/…)。 */
modifiers: string[];
}
@@ -180,6 +180,8 @@ export function RecordingInputSection() {
<SettingRow label={t('settings.recording.hotkeyLabel')}>
<ShortcutRecorder
value={prefs.dictationHotkey}
modifierPresets={capability?.availableTriggers ?? []}
sideSpecificModifiers
onSave={async binding => {
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });
@@ -66,6 +66,8 @@ export function ShortcutsSection() {
<ShortcutRecorder
value={prefs.dictationHotkey}
alignRecordButton
sideSpecificModifiers
modifierPresets={capability?.availableTriggers ?? []}
onSave={async binding => {
await setDictationHotkey(binding);
await savePrefs({ ...prefs, dictationHotkey: binding });