diff --git a/openless-all/app/package.json b/openless-all/app/package.json index 60d0cefb..3c045e32 100644 --- a/openless-all/app/package.json +++ b/openless-all/app/package.json @@ -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" }, diff --git a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs index a3760ccb..60154db3 100644 --- a/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs +++ b/openless-all/app/src-tauri/backend-tests/tests/backend_rust.rs @@ -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"))] diff --git a/openless-all/app/src-tauri/src/combo_hotkey.rs b/openless-all/app/src-tauri/src/combo_hotkey.rs index c0152e99..cc3c3977 100644 --- a/openless-all/app/src-tauri/src/combo_hotkey.rs +++ b/openless-all/app/src-tauri/src/combo_hotkey.rs @@ -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, diff --git a/openless-all/app/src-tauri/src/commands/hotkeys.rs b/openless-all/app/src-tauri/src/commands/hotkeys.rs index b22d875e..ddb26d4f 100644 --- a/openless-all/app/src-tauri/src/commands/hotkeys.rs +++ b/openless-all/app/src-tauri/src/commands/hotkeys.rs @@ -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()); + } } diff --git a/openless-all/app/src-tauri/src/commands/qa.rs b/openless-all/app/src-tauri/src/commands/qa.rs index acc64238..e4558b64 100644 --- a/openless-all/app/src-tauri/src/commands/qa.rs +++ b/openless-all/app/src-tauri/src/commands/qa.rs @@ -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()); } diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index 9218eef7..476011be 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -282,6 +282,7 @@ struct Inner { /// 自定义组合键监听器(global-hotkey crate)。当 `prefs.hotkey.trigger == Custom` 时 /// 代替 modifier-only 的 hotkey monitor。`None` 表示不使用自定义组合键或还没成功安装。 combo_hotkey: Mutex>, + side_aware_combo: Mutex>, translation_hotkey: Mutex>, switch_style_hotkey: Mutex>, open_app_hotkey: Mutex>, @@ -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::(); + 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; diff --git a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs index 7b964f0a..a8208550 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -488,21 +488,48 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { // 读当前 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::(); + 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) { .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, diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 7fa0eaea..b686f19e 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -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, status_tx: StartupTx>, ) { - 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); + } } } diff --git a/openless-all/app/src-tauri/src/lib.rs b/openless-all/app/src-tauri/src/lib.rs index 8b75b5df..a96986c4 100644 --- a/openless-all/app/src-tauri/src/lib.rs +++ b/openless-all/app/src-tauri/src/lib.rs @@ -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; diff --git a/openless-all/app/src-tauri/src/linux_fcitx.rs b/openless-all/app/src-tauri/src/linux_fcitx.rs index 16069c6a..73430ab9 100644 --- a/openless-all/app/src-tauri/src/linux_fcitx.rs +++ b/openless-all/app/src-tauri/src/linux_fcitx.rs @@ -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!(), diff --git a/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs b/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs index 7946a44d..f9796d7e 100644 --- a/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs +++ b/openless-all/app/src-tauri/src/mobile_stubs/shortcut_binding.rs @@ -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 { 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", diff --git a/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs b/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs new file mode 100644 index 00000000..48cafa58 --- /dev/null +++ b/openless-all/app/src-tauri/src/mobile_stubs/side_aware_combo.rs @@ -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, + ) -> Result { + 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) {} +} diff --git a/openless-all/app/src-tauri/src/shortcut_binding.rs b/openless-all/app/src-tauri/src/shortcut_binding.rs index 6b9ad733..155cd379 100644 --- a/openless-all/app/src-tauri/src/shortcut_binding.rs +++ b/openless-all/app/src-tauri/src/shortcut_binding.rs @@ -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 { + 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 = left + .modifiers + .iter() + .map(|raw| normalize_side_modifier_tag(raw)) + .collect(); + let right_mods: std::collections::BTreeSet = 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 { 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 { +pub fn parse_primary(raw: &str) -> Result { 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()); + } } diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs new file mode 100644 index 00000000..99de07f5 --- /dev/null +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -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>> = OnceLock::new(); + +struct ActiveSideCombo { + binding: ShortcutBinding, + tx: Sender, + state: Mutex, +} + +#[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 { + let mut tags: Vec = self + .binding + .modifiers + .iter() + .map(|raw| normalize_side_modifier_tag(raw)) + .collect(); + tags.sort(); + tags + } + + fn pressed_modifier_tags(&self) -> Vec { + 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 { + 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 { + 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, + ) -> Result { + 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: F) -> Option +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, 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 { + 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 { + 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 { + 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 { + 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)); + } +} diff --git a/openless-all/app/src-tauri/src/types.rs b/openless-all/app/src-tauri/src/types.rs index c63c65a1..0e1db281 100644 --- a/openless-all/app/src-tauri/src/types.rs +++ b/openless-all/app/src-tauri/src/types.rs @@ -2073,6 +2073,9 @@ pub enum HotkeyTrigger { RightControl, LeftControl, RightCommand, + LeftCommand, + LeftShift, + RightShift, Fn, RightAlt, // Windows synonym for RightOption MediaPlayPause, @@ -2087,6 +2090,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 播放/暂停", @@ -2180,6 +2186,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"))] @@ -2300,6 +2309,9 @@ impl HotkeyCapability { HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::Fn, HotkeyTrigger::Custom, ], @@ -2320,6 +2332,9 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::LeftControl, HotkeyTrigger::RightCommand, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::MediaPlayPause, HotkeyTrigger::Custom, ], @@ -2342,6 +2357,9 @@ impl HotkeyCapability { HotkeyTrigger::RightAlt, HotkeyTrigger::RightControl, HotkeyTrigger::LeftControl, + HotkeyTrigger::LeftCommand, + HotkeyTrigger::LeftShift, + HotkeyTrigger::RightShift, HotkeyTrigger::Custom, ], requires_accessibility_permission: false, @@ -2349,7 +2367,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(), ), } } diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index fad6e257..f5b046e3 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -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; @@ -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(null); const pendingModifier = useRef(null); const pendingTimer = useRef(null); + const pressedCodes = useRef>(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) => { 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 (
@@ -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({
+ {!comboOnly && presetTriggers.length > 0 && ( +
+ + {t('settings.recording.modifierPresetsLabel', '常用单键:')} + + {presetTriggers.map(trigger => ( + + ))} +
+ )} {recording && (
, 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] ?? '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[] { + 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[] { + 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, + 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 = 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'; diff --git a/openless-all/app/src/lib/hotkeySideModifiers.test.ts b/openless-all/app/src/lib/hotkeySideModifiers.test.ts new file mode 100644 index 00000000..7b9e85ce --- /dev/null +++ b/openless-all/app/src/lib/hotkeySideModifiers.test.ts @@ -0,0 +1,75 @@ +import { + genericModifiersFromPressedCodes, + modifiersFromPressedCodes, + shortcutFromLegacyTrigger, + sideModifiersFromPressedCodes, +} from './hotkey'; + +function assertEqual(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'); diff --git a/openless-all/app/src/lib/types.ts b/openless-all/app/src/lib/types.ts index 17b52bd7..10793ce2 100644 --- a/openless-all/app/src/lib/types.ts +++ b/openless-all/app/src/lib/types.ts @@ -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[]; } diff --git a/openless-all/app/src/pages/settings/RecordingInputSection.tsx b/openless-all/app/src/pages/settings/RecordingInputSection.tsx index c66530a9..9d156e8d 100644 --- a/openless-all/app/src/pages/settings/RecordingInputSection.tsx +++ b/openless-all/app/src/pages/settings/RecordingInputSection.tsx @@ -180,6 +180,8 @@ export function RecordingInputSection() { { await setDictationHotkey(binding); await savePrefs({ ...prefs, dictationHotkey: binding }); diff --git a/openless-all/app/src/pages/settings/ShortcutsSection.tsx b/openless-all/app/src/pages/settings/ShortcutsSection.tsx index 0b8e9f8b..063d14b4 100644 --- a/openless-all/app/src/pages/settings/ShortcutsSection.tsx +++ b/openless-all/app/src/pages/settings/ShortcutsSection.tsx @@ -66,6 +66,8 @@ export function ShortcutsSection() { { await setDictationHotkey(binding); await savePrefs({ ...prefs, dictationHotkey: binding });