Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion openless-all/app/crates/openless-core/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
42 changes: 42 additions & 0 deletions openless-all/app/crates/openless-core/src/shortcut_types.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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(
Expand Down
7 changes: 4 additions & 3 deletions openless-all/app/src-tauri/src/coordinator.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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::<ComboHotkeyEvent>();
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) {
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
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)");
}
Expand Down
7 changes: 4 additions & 3 deletions openless-all/app/src-tauri/src/coordinator/hotkey_loops.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1225,14 +1225,15 @@ pub(super) fn combo_hotkey_supervisor_loop(inner: Arc<Inner>) {
if inner.side_aware_combo.lock().is_some() {
return;
}
let (tx, rx) = mpsc::channel::<ComboHotkeyEvent>();
match crate::side_aware_combo::SideAwareComboMonitor::start(binding, tx) {
let (tx, rx) = mpsc::channel::<HotkeyEvent>();
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;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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::<HotkeyEvent>();
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);
}
Expand Down
58 changes: 55 additions & 3 deletions openless-all/app/src-tauri/src/hotkey.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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) 吞掉),宿主应用收不到,
Expand All @@ -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);
}

Expand Down Expand Up @@ -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);
Expand All @@ -2172,14 +2176,62 @@ mod platform {

assert!(matches!(
combo_rx.recv().unwrap(),
ComboHotkeyEvent::Pressed { .. }
HotkeyEvent::Pressed { .. }
));
assert!(hotkey_rx
.try_iter()
.any(|evt| evt == HotkeyEvent::TranslationModifierPressed));

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);
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)]
Expand All @@ -22,7 +23,8 @@ pub struct SideAwareComboMonitor;
impl SideAwareComboMonitor {
pub fn start(
_binding: ShortcutBinding,
_tx: Sender<ComboHotkeyEvent>,
_tx: Sender<HotkeyEvent>,
_combo_tx: Sender<HotkeyCombinedEdge>,
) -> Result<Self, ComboHotkeyError> {
Err(ComboHotkeyError::RegisterFailed(
"Side-specific combo hotkeys are not available on mobile".into(),
Expand All @@ -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) {}
Expand Down
Loading