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:
1 parent
346942e1bb
commit
fa09eaf605
21 files changed
+1366
-58
No files matched your search
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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 '';
|
||||
}
|
||||
|
||||
|
||||
@@ -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');
|
||||
@@ -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 });
|
||||
|
||||
Reference in new issue
Block a user