From 1fafa4af8d7a3a91abf7e7a3c99eb8c6a5d69070 Mon Sep 17 00:00:00 2001 From: leolulu <35873316+leolulu@users.noreply.github.com> Date: Wed, 23 Sep 2026 22:16:45 +0800 Subject: [PATCH] feat(hotkey): support side-specific modifier-only dictation chords --- .../app/crates/openless-core/src/lib.rs | 3 +- .../openless-core/src/shortcut_types.rs | 42 +++ openless-all/app/src-tauri/src/coordinator.rs | 7 +- .../src-tauri/src/coordinator/hotkey_loops.rs | 7 +- .../src/coordinator/native_dictation_key.rs | 13 +- openless-all/app/src-tauri/src/hotkey.rs | 58 ++++- .../src/mobile_stubs/side_aware_combo.rs | 8 +- .../app/src-tauri/src/side_aware_combo.rs | 245 +++++++++++++----- .../app/src/components/ShortcutRecorder.tsx | 26 +- openless-all/app/src/lib/hotkey.ts | 6 +- .../app/src/lib/hotkeySideModifiers.test.ts | 11 + 11 files changed, 339 insertions(+), 87 deletions(-) diff --git a/openless-all/app/crates/openless-core/src/lib.rs b/openless-all/app/crates/openless-core/src/lib.rs index bf3db2275..4b192d4ac 100644 --- a/openless-all/app/crates/openless-core/src/lib.rs +++ b/openless-all/app/crates/openless-core/src/lib.rs @@ -325,7 +325,8 @@ pub use shared_types::{ }; pub use shortcut_types::{ binding_from_legacy_trigger, binding_requires_side_aware_hook, bindings_overlap, - is_side_specific_modifier_tag, legacy_modifier_trigger, normalize_side_modifier_tag, + is_modifier_chord_binding, is_side_specific_modifier_tag, legacy_modifier_trigger, + normalize_side_modifier_tag, reconcile_hotkey_collisions, reject_bare_shift_dictation_shortcut, reject_dictation_qa_hotkey_overlap, reject_dictation_translation_hotkey_overlap, reject_hotkey_collisions, reject_modifier_only_action_shortcut, diff --git a/openless-all/app/crates/openless-core/src/shortcut_types.rs b/openless-all/app/crates/openless-core/src/shortcut_types.rs index f56c66d69..cc1e5e2d2 100644 --- a/openless-all/app/crates/openless-core/src/shortcut_types.rs +++ b/openless-all/app/crates/openless-core/src/shortcut_types.rs @@ -27,6 +27,15 @@ const SIDE_MODIFIER_TAGS: &[&str] = &[ "super-right", ]; +const MODIFIER_CHORD_PRIMARY: &str = "ModifierChord"; + +pub fn is_modifier_chord_binding(binding: &ShortcutBinding) -> bool { + binding + .primary + .trim() + .eq_ignore_ascii_case(MODIFIER_CHORD_PRIMARY) +} + pub fn normalize_side_modifier_tag(raw: &str) -> String { match raw.trim().to_ascii_lowercase().as_str() { "super-left" => "cmd-left".into(), @@ -206,6 +215,24 @@ pub fn validate_shortcut_binding(binding: &ShortcutBinding) -> Result<(), Shortc if binding.modifiers.is_empty() && binding.primary.eq_ignore_ascii_case("shift") { return Ok(()); } + if is_modifier_chord_binding(binding) { + if binding.modifiers.len() < 2 { + return Err(ShortcutBindingError::UnsupportedKey( + binding.primary.trim().to_string(), + )); + } + let mut unique = BTreeSet::new(); + for raw in &binding.modifiers { + if !is_side_specific_modifier_tag(raw) { + return Err(ShortcutBindingError::UnsupportedModifier(raw.clone())); + } + let normalized = normalize_side_modifier_tag(raw); + if !unique.insert(normalized) { + return Err(ShortcutBindingError::UnsupportedModifier(raw.clone())); + } + } + return Ok(()); + } validate_primary(&binding.primary)?; for raw in &binding.modifiers { @@ -827,6 +854,21 @@ mod tests { assert!(validate_shortcut_binding(&combo("D", &["cmd-left", "shift"])).is_err()); } + #[test] + fn modifier_chords_require_multiple_unique_side_specific_modifiers() { + let chord = combo("ModifierChord", &["ctrl-left", "super-left"]); + assert!(validate_shortcut_binding(&chord).is_ok()); + assert!(binding_requires_side_aware_hook(&chord)); + assert!(is_modifier_chord_binding(&chord)); + + assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl-left"])).is_err()); + assert!(validate_shortcut_binding(&combo("ModifierChord", &["ctrl", "super-left"])).is_err()); + assert!( + validate_shortcut_binding(&combo("ModifierChord", &["cmd-left", "super-left"])) + .is_err() + ); + } + #[test] fn overlap_and_legacy_conversion_have_one_shared_implementation() { assert!(bindings_overlap( diff --git a/openless-all/app/src-tauri/src/coordinator.rs b/openless-all/app/src-tauri/src/coordinator.rs index cf7aca06f..3d762328b 100644 --- a/openless-all/app/src-tauri/src/coordinator.rs +++ b/openless-all/app/src-tauri/src/coordinator.rs @@ -1080,14 +1080,15 @@ impl Coordinator { 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) { + let (tx, rx) = mpsc::channel::(); + let combo_tx = spawn_combo_abort_bridge(&self.inner, handle_trigger_combined); + match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_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)) + .spawn(move || hotkey_bridge_loop(bridge_inner, rx)) .ok(); log::info!("[coord] side-aware combo hotkey listener installed (via update)"); } 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 63af12fdb..087a7f69c 100644 --- a/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs +++ b/openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs @@ -1225,14 +1225,15 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc) { if inner.side_aware_combo.lock().is_some() { return; } - let (tx, rx) = mpsc::channel::(); - match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) { + let (tx, rx) = mpsc::channel::(); + let combo_tx = spawn_combo_abort_bridge(&inner, handle_trigger_combined); + match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx, combo_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)) + .spawn(move || hotkey_bridge_loop(inner_clone, rx)) .ok(); return; } diff --git a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs index f9176f218..df8102544 100644 --- a/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs +++ b/openless-all/app/src-tauri/src/coordinator/native_dictation_key.rs @@ -47,14 +47,17 @@ impl Coordinator { .update_binding(binding) .map_err(|error| error.to_string())?; } else { - let (tx, rx) = mpsc::channel(); - let monitor = - crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) - .map_err(|error| error.to_string())?; + let (tx, rx) = mpsc::channel::(); + let combo_tx = + spawn_combo_abort_bridge(&inner, handle_trigger_combined); + let monitor = crate::side_aware_combo::SideAwareComboMonitor::start( + binding, tx, combo_tx, + ) + .map_err(|error| error.to_string())?; let bridge_inner = Arc::clone(&inner); std::thread::Builder::new() .name("openless-side-combo-bridge".into()) - .spawn(move || combo_hotkey_bridge_loop(bridge_inner, rx)) + .spawn(move || hotkey_bridge_loop(bridge_inner, rx)) .map_err(|error| error.to_string())?; *slot = Some(monitor); } diff --git a/openless-all/app/src-tauri/src/hotkey.rs b/openless-all/app/src-tauri/src/hotkey.rs index 8b57097b1..b10661e90 100644 --- a/openless-all/app/src-tauri/src/hotkey.rs +++ b/openless-all/app/src-tauri/src/hotkey.rs @@ -931,6 +931,7 @@ mod platform { fn handle_key_down(ctx: &CallbackContext, event: CgEventRef) { let keycode = unsafe { CGEventGetIntegerValueField(event, KEYBOARD_EVENT_KEYCODE) }; + crate::side_aware_combo::handle_companion_key_down(); if keycode == ESC_KEYCODE { note_companion_key_down(ctx); send_cancel_or_log(&ctx.cancel_tx); @@ -1570,6 +1571,7 @@ mod platform { } let pressed = matches!(message, WM_KEYDOWN | WM_SYSKEYDOWN); if vk_code == VK_ESCAPE && (message == WM_KEYDOWN || message == WM_SYSKEYDOWN) { + crate::side_aware_combo::handle_companion_key_down(); note_companion_key_down(ctx); send_cancel_or_log(&ctx.cancel_tx); // 会话激活期间独占消费 Esc(返回 true → LRESULT(1) 吞掉),宿主应用收不到, @@ -1580,6 +1582,7 @@ mod platform { crate::side_aware_combo::platform::dispatch_vk(vk_code, pressed); if pressed && !is_modifier_vk(vk_code) { + crate::side_aware_combo::handle_companion_key_down(); note_companion_key_down(ctx); } @@ -2153,16 +2156,17 @@ mod platform { #[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 (abort_tx, _abort_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 monitor = + SideAwareComboMonitor::start(binding, combo_tx, abort_tx).expect("start monitor"); let shared = shared(HotkeyTrigger::Custom); let (ctx, hotkey_rx) = callback_context(shared); @@ -2172,7 +2176,7 @@ mod platform { assert!(matches!( combo_rx.recv().unwrap(), - ComboHotkeyEvent::Pressed { .. } + HotkeyEvent::Pressed { .. } )); assert!(hotkey_rx .try_iter() @@ -2180,6 +2184,54 @@ mod platform { drop(monitor); } + + #[test] + fn windows_modifier_chord_uses_existing_companion_abort_semantics() { + use crate::side_aware_combo::SideAwareComboMonitor; + use crate::types::ShortcutBinding; + + let (tx, rx) = mpsc::channel(); + let (abort_tx, abort_rx) = mpsc::channel(); + let binding = ShortcutBinding { + primary: "ModifierChord".into(), + modifiers: vec!["ctrl-left".into(), "cmd-left".into()], + }; + let monitor = + SideAwareComboMonitor::start(binding, tx, abort_tx).expect("start monitor"); + + let shared = shared(HotkeyTrigger::Custom); + let (ctx, _main_rx) = callback_context(shared); + + dispatch_keyboard_event(&ctx, VK_LCONTROL, WM_KEYDOWN); + assert!(rx.try_recv().is_err()); + dispatch_keyboard_event(&ctx, VK_LWIN, WM_KEYDOWN); + let press_id = match rx.recv().unwrap() { + HotkeyEvent::Pressed { press_id, .. } => press_id, + other => panic!("expected modifier chord Pressed, got {other:?}"), + }; + + dispatch_keyboard_event(&ctx, 0x44, WM_KEYDOWN); + assert!(matches!( + abort_rx.recv().unwrap(), + HotkeyCombinedEdge { + press_id: combined_id, + .. + } if combined_id == press_id + )); + dispatch_keyboard_event(&ctx, 0x44, WM_KEYDOWN); + assert!(abort_rx.try_recv().is_err()); + + dispatch_keyboard_event(&ctx, VK_LWIN, WM_KEYUP); + assert!(matches!( + rx.recv().unwrap(), + HotkeyEvent::Released { + press_id: released_id, + .. + } if released_id == press_id + )); + + drop(monitor); + } } } 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 index 48cafa583..070c19abb 100644 --- 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 @@ -2,7 +2,8 @@ use std::sync::mpsc::Sender; -use crate::combo_hotkey::{ComboHotkeyError, ComboHotkeyEvent}; +use crate::combo_hotkey::ComboHotkeyError; +use crate::hotkey::{HotkeyCombinedEdge, HotkeyEvent}; use crate::types::ShortcutBinding; #[derive(Debug, Clone, Copy)] @@ -22,7 +23,8 @@ pub struct SideAwareComboMonitor; impl SideAwareComboMonitor { pub fn start( _binding: ShortcutBinding, - _tx: Sender, + _tx: Sender, + _combo_tx: Sender, ) -> Result { Err(ComboHotkeyError::RegisterFailed( "Side-specific combo hotkeys are not available on mobile".into(), @@ -34,6 +36,8 @@ pub fn handle_side_modifier(_side: SideModifier, _pressed: bool) {} pub fn handle_primary_key(_primary: &str, _pressed: bool) {} +pub fn handle_companion_key_down() {} + #[cfg(target_os = "macos")] pub mod platform { pub fn dispatch_keycode(_keycode: i64, _flags_changed: bool, _flags: u64, _pressed: bool) {} diff --git a/openless-all/app/src-tauri/src/side_aware_combo.rs b/openless-all/app/src-tauri/src/side_aware_combo.rs index ab335b454..b4790e263 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -1,7 +1,7 @@ //! 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`]. +//! physical modifier state and matches side-aware bindings registered via [`SideAwareComboMonitor`]. use std::sync::mpsc::Sender; use std::sync::{OnceLock, RwLock}; @@ -9,14 +9,16 @@ use std::time::Instant; use parking_lot::Mutex; -use crate::combo_hotkey::ComboHotkeyEvent; +use crate::hotkey::{HotkeyCombinedEdge, HotkeyEvent}; use crate::shortcut_binding::{is_side_specific_modifier_tag, normalize_side_modifier_tag}; use crate::types::ShortcutBinding; +use openless_core::is_modifier_chord_binding; static ACTIVE_MONITOR: OnceLock>> = OnceLock::new(); struct ActiveSideCombo { - tx: Sender, + tx: Sender, + combo_tx: Sender, state: Mutex, } @@ -36,7 +38,8 @@ struct ModifierSideState { struct SideAwareComboState { binding: ShortcutBinding, modifiers: ModifierSideState, - combo_active: bool, + active_press_id: Option, + companion_seen: bool, } impl SideAwareComboState { @@ -44,7 +47,8 @@ impl SideAwareComboState { Self { binding, modifiers: ModifierSideState::default(), - combo_active: false, + active_press_id: None, + companion_seen: false, } } @@ -106,51 +110,74 @@ impl SideAwareComboState { self.expected_modifier_tags() == self.pressed_modifier_tags() } - fn on_primary(&mut self, primary: &str, pressed: bool) -> Option { + fn activate(&mut self) -> Option { + if self.active_press_id.is_some() { + return None; + } + let press_id = crate::hotkey::next_press_id(); + self.active_press_id = Some(press_id); + self.companion_seen = false; + Some(HotkeyEvent::Pressed { + at: Instant::now(), + press_id, + }) + } + + fn release_active(&mut self) -> Option { + let press_id = self.active_press_id.take()?; + self.companion_seen = false; + Some(HotkeyEvent::Released { + at: Instant::now(), + press_id, + }) + } + + fn on_modifier_press(&mut self, side: SideModifier) -> Option { + self.set_side(side, true); + if is_modifier_chord_binding(&self.binding) && self.modifiers_match() { + return self.activate(); + } + None + } + + fn on_primary(&mut self, primary: &str, pressed: bool) -> Option { + if is_modifier_chord_binding(&self.binding) { + return None; + } if !primary_eq(&self.binding.primary, primary) { return None; } if pressed { if self.modifiers_match() { - // `modifiers_match()` is the authoritative activation gate. If - // `combo_active` is still true here, the previous `Released` was - // dropped by the OS: we must NOT emit a second `Pressed` (that - // would break the pairing invariant), so treat the flag as - // already reflecting an active combo and swallow this edge. - if !self.combo_active { - self.combo_active = true; - return Some(ComboHotkeyEvent::Pressed { at: Instant::now() }); - } - return None; + return self.activate(); } - // Modifiers no longer match — this is the single authoritative reset - // condition. If `combo_active` is stuck true (a modifier release was - // dropped so no `Released` was ever emitted), self-heal by emitting - // the terminal `Released` now so the recording latch cannot stick. - if self.combo_active { - self.combo_active = false; - return Some(ComboHotkeyEvent::Released { at: Instant::now() }); + if self.active_press_id.is_some() { + return self.release_active(); } return None; } - // Primary key up is the absolute termination signal for the combo. - if self.combo_active { - self.combo_active = false; - return Some(ComboHotkeyEvent::Released { at: Instant::now() }); - } - None + self.release_active() } - fn on_modifier_release(&mut self, side: SideModifier) -> Option { + fn on_modifier_release(&mut self, side: SideModifier) -> Option { self.set_side(side, false); - // Modifiers no longer matching is the authoritative reset condition: - // once the required side-modifier set is broken, the combo is over. - if self.combo_active && !self.modifiers_match() { - self.combo_active = false; - return Some(ComboHotkeyEvent::Released { at: Instant::now() }); + if self.active_press_id.is_some() && !self.modifiers_match() { + return self.release_active(); } None } + + fn on_companion_key_down(&mut self) -> Option { + if !is_modifier_chord_binding(&self.binding) || self.companion_seen { + return None; + } + let press_id = self.active_press_id?; + self.companion_seen = true; + Some(HotkeyCombinedEdge { + at: Instant::now(), + press_id, + }) + } } #[derive(Debug, Clone, Copy, PartialEq, Eq)] @@ -170,7 +197,8 @@ pub struct SideAwareComboMonitor; impl SideAwareComboMonitor { pub fn start( binding: ShortcutBinding, - tx: Sender, + tx: Sender, + combo_tx: Sender, ) -> Result { // Linux has no side-aware platform dispatch (no CGEventTap / WH_KEYBOARD_LL // equivalent wired here). Accepting the binding would leave the user with a @@ -178,7 +206,7 @@ impl SideAwareComboMonitor { // so the caller can surface an actionable error instead. #[cfg(target_os = "linux")] { - let _ = (&binding, &tx); + let _ = (&binding, &tx, &combo_tx); return Err(crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier( "侧向修饰键组合键在 Linux 暂不支持".into(), )); @@ -192,6 +220,7 @@ impl SideAwareComboMonitor { let mut guard = slot.write().expect("side combo monitor lock poisoned"); *guard = Some(ActiveSideCombo { tx, + combo_tx, state: Mutex::new(SideAwareComboState::new(binding)), }); Ok(Self) @@ -213,6 +242,17 @@ fn validate_side_binding( "binding is not side-specific".into(), )); } + if is_modifier_chord_binding(binding) { + return openless_core::validate_shortcut_binding(binding).map_err(|error| match error { + openless_core::ShortcutBindingError::UnsupportedModifier(value) => { + crate::combo_hotkey::ComboHotkeyError::UnsupportedModifier(value) + } + openless_core::ShortcutBindingError::UnsupportedKey(value) => { + crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(value) + } + }); + } + crate::shortcut_binding::parse_primary(&binding.primary) .map_err(|e| crate::combo_hotkey::ComboHotkeyError::UnsupportedKey(e.to_string()))?; @@ -261,18 +301,23 @@ where guard.as_ref().map(f) } -fn send_event(tx: &Sender, evt: ComboHotkeyEvent) { +fn send_event(tx: &Sender, evt: HotkeyEvent) { if let Err(err) = tx.send(evt) { log::warn!("[side-aware-combo] event send failed: {err}"); } } +fn send_combo_abort(tx: &Sender, edge: HotkeyCombinedEdge) { + if let Err(err) = tx.send(edge) { + log::warn!("[side-aware-combo] abort 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 + state.on_modifier_press(side) } else { state.on_modifier_release(side) } @@ -294,6 +339,12 @@ pub fn handle_primary_key(primary: &str, pressed: bool) { } } +pub fn handle_companion_key_down() { + if let Some(edge) = with_active(|active| active.state.lock().on_companion_key_down()).flatten() { + with_active(|active| send_combo_abort(&active.combo_tx, edge)); + } +} + fn primary_eq(expected: &str, actual: &str) -> bool { expected.trim().eq_ignore_ascii_case(actual.trim()) } @@ -594,7 +645,8 @@ mod tests { modifiers: vec!["ctrl-right".into()], }; let (tx, rx) = mpsc::channel(); - let monitor = SideAwareComboMonitor::start(binding.clone(), tx).unwrap(); + let (combo_tx, _combo_rx) = mpsc::channel(); + let monitor = SideAwareComboMonitor::start(binding.clone(), tx, combo_tx).unwrap(); let press_and_release = |primary: &str| { handle_side_modifier(SideModifier::CtrlRight, true); handle_primary_key(primary, true); @@ -602,11 +654,11 @@ mod tests { handle_side_modifier(SideModifier::CtrlRight, false); assert!(matches!( rx.try_recv(), - Ok(ComboHotkeyEvent::Pressed { .. }) + Ok(HotkeyEvent::Pressed { .. }) )); assert!(matches!( rx.try_recv(), - Ok(ComboHotkeyEvent::Released { .. }) + Ok(HotkeyEvent::Released { .. }) )); assert!(rx.try_recv().is_err()); }; @@ -686,7 +738,7 @@ mod tests { state.set_side(SideModifier::CmdLeft, true); assert!(state.modifiers_match()); let evt = state.on_primary("D", true); - assert!(matches!(evt, Some(ComboHotkeyEvent::Pressed { .. }))); + assert!(matches!(evt, Some(HotkeyEvent::Pressed { .. }))); } #[test] @@ -702,7 +754,7 @@ mod tests { assert!(state.modifiers_match()); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); } @@ -741,7 +793,7 @@ mod tests { assert!(state.modifiers_match()); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); } @@ -757,6 +809,63 @@ mod tests { assert_eq!(state.on_primary("D", true), None); } + #[test] + fn modifier_chord_activates_on_final_modifier_and_releases_with_same_generation() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "ModifierChord".into(), + modifiers: vec!["ctrl-left".into(), "cmd-left".into()], + }); + + assert_eq!(state.on_modifier_press(SideModifier::CtrlLeft), None); + let press_id = match state.on_modifier_press(SideModifier::CmdLeft) { + Some(HotkeyEvent::Pressed { press_id, .. }) => press_id, + other => panic!("expected modifier chord Pressed, got {other:?}"), + }; + assert_ne!(press_id, 0); + assert_eq!(state.on_modifier_press(SideModifier::CmdLeft), None); + + assert!(matches!( + state.on_modifier_release(SideModifier::CtrlLeft), + Some(HotkeyEvent::Released { press_id: released_id, .. }) if released_id == press_id + )); + assert!(state.active_press_id.is_none()); + } + + #[test] + fn modifier_chord_companion_aborts_once_but_still_pairs_release() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "ModifierChord".into(), + modifiers: vec!["ctrl-left".into(), "cmd-left".into()], + }); + state.on_modifier_press(SideModifier::CtrlLeft); + let press_id = match state.on_modifier_press(SideModifier::CmdLeft) { + Some(HotkeyEvent::Pressed { press_id, .. }) => press_id, + other => panic!("expected modifier chord Pressed, got {other:?}"), + }; + + assert!(matches!( + state.on_companion_key_down(), + Some(HotkeyCombinedEdge { press_id: combined_id, .. }) if combined_id == press_id + )); + assert_eq!(state.on_companion_key_down(), None); + assert!(matches!( + state.on_modifier_release(SideModifier::CmdLeft), + Some(HotkeyEvent::Released { press_id: released_id, .. }) if released_id == press_id + )); + } + + #[test] + fn extra_modifier_does_not_activate_modifier_chord_when_it_is_released() { + let mut state = SideAwareComboState::new(ShortcutBinding { + primary: "ModifierChord".into(), + modifiers: vec!["ctrl-left".into(), "cmd-left".into()], + }); + assert_eq!(state.on_modifier_press(SideModifier::CtrlLeft), None); + assert_eq!(state.on_modifier_press(SideModifier::ShiftLeft), None); + assert_eq!(state.on_modifier_press(SideModifier::CmdLeft), None); + assert_eq!(state.on_modifier_release(SideModifier::ShiftLeft), None); + assert!(state.active_press_id.is_none()); + } #[cfg(target_os = "macos")] #[test] fn macos_side_keycodes_are_distinct() { @@ -782,16 +891,16 @@ mod tests { state.set_side(SideModifier::CmdLeft, true); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); // Primary key up terminates the combo with exactly one Released. assert!(matches!( state.on_primary("D", false), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); // No trailing events; a second key-up must not emit anything. assert_eq!(state.on_primary("D", false), None); - assert!(!state.combo_active); + assert!(state.active_press_id.is_none()); } #[test] @@ -800,50 +909,50 @@ mod tests { state.set_side(SideModifier::CmdLeft, true); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); // Modifier lifts while primary is still down -> combo terminates once. assert!(matches!( state.on_modifier_release(SideModifier::CmdLeft), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); - assert!(!state.combo_active); + assert!(state.active_press_id.is_none()); // A now-orphaned primary key-up must NOT emit a second Released. assert_eq!(state.on_primary("D", false), None); } #[test] fn dropped_modifier_release_is_recovered_on_primary_up() { - // Simulate the OS dropping the modifier-up event: combo_active stays true + // Simulate the OS dropping the modifier-up event: the active press stays latched // and modifiers still "match" from the state's perspective. The primary // key-up (absolute termination) must still emit the paired Released. let mut state = cmd_left_d_state(); state.set_side(SideModifier::CmdLeft, true); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); // Modifier physically released but the release event never arrived, so the // side flag is still set here. Primary up is the fallback terminator. assert!(matches!( state.on_primary("D", false), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); - assert!(!state.combo_active); + assert!(state.active_press_id.is_none()); } #[test] fn stale_combo_active_reset_when_modifiers_stop_matching() { - // Reproduce a stuck latch: a prior Released was lost so combo_active is true, + // Reproduce a stuck latch: a prior Released was lost so an active press is still latched, // yet the required modifier is no longer held (modifiers_match() == false). // The next primary-down must self-heal by emitting the terminal Released // (NOT swallow it, and NOT emit a second Pressed) so recording can't stick. let mut state = cmd_left_d_state(); - state.combo_active = true; // stale flag from a dropped Released + state.active_press_id = Some(crate::hotkey::next_press_id()); // stale flag from a dropped Released assert!(!state.modifiers_match()); // cmd-left is not held let evt = state.on_primary("D", true); - assert!(matches!(evt, Some(ComboHotkeyEvent::Released { .. }))); - assert!(!state.combo_active); + assert!(matches!(evt, Some(HotkeyEvent::Released { .. }))); + assert!(state.active_press_id.is_none()); } #[test] @@ -852,26 +961,26 @@ mod tests { // must be able to fire a fresh Pressed. Guards against the combo becoming // permanently unrepeatable (the historical #545/#468 stuck-latch class). let mut state = cmd_left_d_state(); - state.combo_active = true; // leftover from a dropped Released + state.active_press_id = Some(crate::hotkey::next_press_id()); // leftover from a dropped Released // Releasing the required side-modifier breaks the match, so the stale latch // self-heals by emitting the terminal Released here (pairing the Pressed whose - // Released was dropped). Either way combo_active must end up cleared. + // Released was dropped). Either way the active press must end up cleared. assert!(matches!( state.on_modifier_release(SideModifier::CmdLeft), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); - assert!(!state.combo_active); + assert!(state.active_press_id.is_none()); // Fresh, clean press cycle now behaves normally. state.set_side(SideModifier::CmdLeft, true); assert!(matches!( state.on_primary("D", true), - Some(ComboHotkeyEvent::Pressed { .. }) + Some(HotkeyEvent::Pressed { .. }) )); assert!(matches!( state.on_primary("D", false), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); } @@ -882,13 +991,13 @@ mod tests { // must NOT emit a second Pressed — that would break the pairing invariant. let mut state = cmd_left_d_state(); state.set_side(SideModifier::CmdLeft, true); - state.combo_active = true; // pretend previous Released was dropped + state.active_press_id = Some(crate::hotkey::next_press_id()); // pretend previous Released was dropped assert!(state.modifiers_match()); assert_eq!(state.on_primary("D", true), None); // The real terminator (primary up) still yields exactly one Released. assert!(matches!( state.on_primary("D", false), - Some(ComboHotkeyEvent::Released { .. }) + Some(HotkeyEvent::Released { .. }) )); } diff --git a/openless-all/app/src/components/ShortcutRecorder.tsx b/openless-all/app/src/components/ShortcutRecorder.tsx index f5f368625..4960c1864 100644 --- a/openless-all/app/src/components/ShortcutRecorder.tsx +++ b/openless-all/app/src/components/ShortcutRecorder.tsx @@ -2,7 +2,11 @@ import { useEffect, useRef, useState, type CSSProperties, type KeyboardEvent } f import { AnimatePresence, motion } from 'framer-motion'; import { ChevronDown } from 'lucide-react'; import { useTranslation } from 'react-i18next'; -import { formatComboParts, modifiersFromPressedCodes } from '../lib/hotkey'; +import { + formatComboParts, + MODIFIER_CHORD_PRIMARY, + modifiersFromPressedCodes, +} from '../lib/hotkey'; import { functionKeyPrimaryFromEvent } from '../lib/hotkeyRecorder'; import { KbdGroup } from './Kbd'; import { setShortcutRecordingActive, validateShortcutBinding } from '../lib/ipc'; @@ -194,6 +198,20 @@ export function ShortcutRecorder({ if (comboOnly) { return; } + if (sideSpecificModifiers) { + const modifiers = modifiersFromPressedCodes(pressedCodes.current, true); + if (modifiers.length >= 2) { + clearPendingModifier(); + const binding = { primary: MODIFIER_CHORD_PRIMARY, modifiers }; + pendingModifier.current = binding; + pendingTimer.current = window.setTimeout(() => { + if (pendingModifier.current === binding) { + void finish(binding); + } + }, 650); + return; + } + } const primary = modifierPrimaryFromCode(e.code, e.key); if (!primary || pendingModifier.current?.primary === primary) return; clearPendingModifier(); @@ -222,6 +240,12 @@ export function ShortcutRecorder({ e.stopPropagation(); pressedCodes.current.delete(e.code); if (comboOnly) return; + if (pendingModifier.current?.primary === MODIFIER_CHORD_PRIMARY) { + const binding = pendingModifier.current; + clearPendingModifier(); + void finish(binding); + return; + } const primary = modifierPrimaryFromCode(e.code, e.key); if (primary && pendingModifier.current?.primary === primary) { const binding = pendingModifier.current; diff --git a/openless-all/app/src/lib/hotkey.ts b/openless-all/app/src/lib/hotkey.ts index 94b8780fb..37f807598 100644 --- a/openless-all/app/src/lib/hotkey.ts +++ b/openless-all/app/src/lib/hotkey.ts @@ -8,6 +8,8 @@ import type { ShortcutBinding, } from './types'; +export const MODIFIER_CHORD_PRIMARY = 'ModifierChord'; + export function defaultQaShortcut(): ShortcutBinding { return { primary: ';', @@ -269,7 +271,9 @@ export function formatComboParts( } } - parts.push(formatPrimary(binding.primary)); + if (binding.primary !== MODIFIER_CHORD_PRIMARY) { + parts.push(formatPrimary(binding.primary)); + } return parts; } diff --git a/openless-all/app/src/lib/hotkeySideModifiers.test.ts b/openless-all/app/src/lib/hotkeySideModifiers.test.ts index 9cd898938..243145178 100644 --- a/openless-all/app/src/lib/hotkeySideModifiers.test.ts +++ b/openless-all/app/src/lib/hotkeySideModifiers.test.ts @@ -1,5 +1,7 @@ import { + formatComboParts, genericModifiersFromPressedCodes, + MODIFIER_CHORD_PRIMARY, modifiersFromPressedCodes, shortcutFromLegacyTrigger, sideModifiersFromPressedCodes, @@ -62,6 +64,15 @@ assertDeepEqual( 'default recording on non-mac uses super', ); +assertDeepEqual( + formatComboParts({ + primary: MODIFIER_CHORD_PRIMARY, + modifiers: ['ctrl-left', 'cmd-left'], + }), + ['左 Win', '左 Ctrl'], + 'modifier chord display contains only its physical modifiers', +); + assertDeepEqual( sideModifiersFromPressedCodes(new Set(['MetaRight', 'KeyD'])), ['cmd-right'],