Skip to content
Open
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
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,7 @@ fn validate_primary(raw: &str) -> Result<(), ShortcutBindingError> {
| "ESC"
| "ESCAPE"
| "SPACE"
| "SCROLLLOCK"
| "BACKSPACE"
| "DELETE"
| "DEL"
Expand Down
75 changes: 75 additions & 0 deletions openless-all/app/crates/openless-core/tests/scrolllock_hotkeys.rs
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
use openless_core::shared_types::{HotkeyMode, HotkeyTrigger, ShortcutBinding};
use openless_core::{
binding_requires_mouse_hook, binding_requires_side_aware_hook, legacy_modifier_trigger,
reconcile_hotkey_collisions, reject_hotkey_collisions, sync_dictation_hotkey_legacy_fields,
validate_shortcut_binding, ShortcutBindingError, UserPreferences,
};

fn binding(primary: &str, modifiers: &[&str]) -> ShortcutBinding {
ShortcutBinding {
primary: primary.into(),
modifiers: modifiers.iter().map(|modifier| (*modifier).into()).collect(),
}
}

#[test]
fn scroll_lock_accepts_bare_generic_and_side_specific_bindings() {
for primary in ["ScrollLock", " scrolllock ", "SCROLLLOCK"] {
for modifiers in [vec![], vec!["ctrl"], vec!["ctrl-left"], vec!["ctrl-right"]] {
let shortcut = binding(primary, &modifiers);
assert!(validate_shortcut_binding(&shortcut).is_ok());
assert!(legacy_modifier_trigger(&shortcut).is_none());
assert!(!binding_requires_mouse_hook(&shortcut));
assert_eq!(
binding_requires_side_aware_hook(&shortcut),
modifiers.iter().any(|modifier| modifier.contains('-'))
);
}
}
assert_eq!(
validate_shortcut_binding(&binding("ScrollLock", &["hyper"])),
Err(ShortcutBindingError::UnsupportedModifier("hyper".into()))
);
assert!(validate_shortcut_binding(&binding("ScrllLock", &[])).is_err());
}

#[test]
fn scroll_lock_preferences_round_trip_without_a_new_trigger_or_schema() {
for mode in [HotkeyMode::Toggle, HotkeyMode::Hold, HotkeyMode::Auto] {
let mut prefs = UserPreferences {
dictation_hotkey: binding("ScrollLock", &[]),
..UserPreferences::default()
};
prefs.hotkey.mode = mode;
sync_dictation_hotkey_legacy_fields(&mut prefs);
assert_eq!(prefs.hotkey.trigger, HotkeyTrigger::Custom);
let combo = prefs.custom_combo_hotkey.as_ref().unwrap();
assert_eq!(combo.primary, "ScrollLock");
assert!(combo.modifiers.is_empty());
assert!(reject_hotkey_collisions(&prefs).is_ok());

let bytes = serde_json::to_vec(&prefs).unwrap();
let loaded: UserPreferences = serde_json::from_slice(&bytes).unwrap();
assert_eq!(loaded.dictation_hotkey, prefs.dictation_hotkey);
assert_eq!(loaded.hotkey.mode, mode);
assert_eq!(loaded.hotkey.trigger, HotkeyTrigger::Custom);
assert_eq!(loaded.custom_combo_hotkey.unwrap().primary, "ScrollLock");

let previous = prefs.clone();
prefs.show_capsule = !prefs.show_capsule;
reconcile_hotkey_collisions(&mut prefs, &previous);
assert_eq!(prefs.dictation_hotkey.primary, "ScrollLock");
}
}

#[test]
fn scroll_lock_keeps_existing_collision_checks() {
let mut prefs = UserPreferences {
dictation_hotkey: binding("ScrollLock", &[]),
translation_hotkey: binding("ScrollLock", &[]),
..UserPreferences::default()
};
assert!(reject_hotkey_collisions(&prefs).is_err());
prefs.translation_hotkey = binding("ScrollLock", &["ctrl"]);
assert!(reject_hotkey_collisions(&prefs).is_ok());
}
1 change: 1 addition & 0 deletions openless-all/app/src-tauri/backend-tests/Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ parking_lot = "0.12"
futures-util = "0.3"

[target.'cfg(windows)'.dev-dependencies]
parking_lot = "0.12"
windows = { version = "0.58", features = ["Win32_Foundation", "Win32_System_Threading", "Win32_UI_WindowsAndMessaging", "Win32_UI_Input_KeyboardAndMouse"] }

[target.'cfg(target_os = "linux")'.dev-dependencies]
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
//! Execute the production Windows side-aware mapper without Tauri's native ASR DLLs.
#![cfg(target_os = "windows")]

#[path = "../../../crates/openless-core/tests/scrolllock_hotkeys.rs"]
mod core_contract;

mod types {
pub use openless_core::shared_types::{
HotkeyAdapterKind, HotkeyBinding, HotkeyCapability, HotkeyInstallError, HotkeyTrigger,
ShortcutBinding,
};
}

mod shortcut_binding {
pub use openless_core::{is_side_specific_modifier_tag, normalize_side_modifier_tag};

// The monitor needs input validation only; the native parser has its own app tests.
pub fn parse_primary(primary: &str) -> Result<(), openless_core::ShortcutBindingError> {
openless_core::validate_shortcut_binding(&crate::types::ShortcutBinding {
primary: primary.into(),
modifiers: vec![],
})
}
}

// Reuse only the event/error DTOs, not these stubs' registration methods.
#[allow(dead_code)]
#[path = "../../src/mobile_stubs/hotkey.rs"]
mod hotkey;

#[allow(dead_code)]
#[path = "../../src/mobile_stubs/combo_hotkey.rs"]
mod combo_hotkey;

#[allow(dead_code)]
#[path = "../../src/recording_companion.rs"]
mod recording_companion;

#[allow(dead_code)]
#[path = "../../src/side_aware_combo.rs"]
mod side_aware_combo;
25 changes: 25 additions & 0 deletions openless-all/app/src-tauri/src/shortcut_binding.rs
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,7 @@ pub fn parse_primary(raw: &str) -> Result<Code, ShortcutBindingError> {
"TAB" => Code::Tab,
"ESC" | "ESCAPE" => Code::Escape,
"SPACE" => Code::Space,
"SCROLLLOCK" => Code::ScrollLock,
"BACKSPACE" => Code::Backspace,
"DELETE" | "DEL" => Code::Delete,
"HOME" => Code::Home,
Expand Down Expand Up @@ -248,6 +249,30 @@ mod tests {
assert_eq!(parsed.key, Code::F8);
}

#[test]
fn scroll_lock_uses_the_existing_custom_hotkey_path() {
for primary in ["ScrollLock", " scrolllock ", "SCROLLLOCK"] {
for modifiers in [vec![], vec!["ctrl".into()]] {
let binding = ShortcutBinding {
primary: primary.into(),
modifiers,
};
assert!(validate_binding(&binding).is_ok());
assert!(legacy_modifier_trigger(&binding).is_none());
assert!(!binding_requires_side_aware_hook(&binding));
let parsed = parse_global_hotkey(&binding).unwrap();
assert_eq!(parsed.key, Code::ScrollLock);
assert_eq!(
parsed.mods.contains(Modifiers::CONTROL),
!binding.modifiers.is_empty()
);
}
}
let binding = combo("ScrollLock", vec!["ctrl-right"]);
assert!(validate_binding(&binding).is_ok());
assert!(binding_requires_side_aware_hook(&binding));
}

#[test]
fn detects_legacy_modifier_only() {
let binding = ShortcutBinding {
Expand Down
48 changes: 47 additions & 1 deletion openless-all/app/src-tauri/src/side_aware_combo.rs
Original file line number Diff line number Diff line change
Expand Up @@ -359,7 +359,7 @@ pub mod platform {
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_RCONTROL, VK_RETURN, VK_RIGHT,
VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SPACE, VK_TAB, VK_UP,
VK_RMENU, VK_RSHIFT, VK_RWIN, VK_SCROLL, VK_SPACE, VK_TAB, VK_UP,
};

pub fn dispatch_vk(vk_code: u32, pressed: bool) -> bool {
Expand Down Expand Up @@ -398,6 +398,7 @@ pub mod platform {
}
let named = match vk_code {
x if x == VK_SPACE.0 as u32 => "Space",
x if x == VK_SCROLL.0 as u32 => "ScrollLock",
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",
Expand Down Expand Up @@ -445,6 +446,51 @@ pub mod platform {
};
Some(named.to_string())
}

#[cfg(test)]
mod tests {
use super::*;

#[test]
fn scroll_lock_maps_to_the_canonical_primary() {
assert_eq!(
primary_from_vk(VK_SCROLL.0 as u32).as_deref(),
Some("ScrollLock")
);
}

#[test]
fn scroll_lock_side_combo_pairs_edges_and_ignores_repeat() {
let primary = primary_from_vk(VK_SCROLL.0 as u32).unwrap();
let mut state = SideAwareComboState::new(ShortcutBinding {
primary: "ScrollLock".into(),
modifiers: vec!["ctrl-right".into()],
});
state.set_side(SideModifier::CtrlLeft, true);
assert_eq!(state.on_primary(&primary, true), None);
state.set_side(SideModifier::CtrlLeft, false);
state.set_side(SideModifier::CtrlRight, true);
let press_id = match state.on_primary(&primary, true) {
Some(HotkeyEvent::Pressed { press_id, .. }) => press_id,
other => panic!("expected ScrollLock Pressed, got {other:?}"),
};
assert_eq!(state.on_primary(&primary, true), None);
assert!(matches!(
state.on_primary(&primary, false),
Some(HotkeyEvent::Released { press_id: released_id, .. }) if released_id == press_id
));
assert_eq!(state.on_primary(&primary, false), None);
assert!(matches!(
state.on_primary(&primary, true),
Some(HotkeyEvent::Pressed { .. })
));
assert!(matches!(
state.on_modifier_release(SideModifier::CtrlRight),
Some(HotkeyEvent::Released { .. })
));
assert_eq!(state.on_primary(&primary, false), None);
}
}
}

/// macOS virtual keycode → ShortcutBinding.primary (US ANSI layout).
Expand Down
20 changes: 20 additions & 0 deletions openless-all/app/src/lib/hotkeyRecorder.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -207,5 +207,25 @@ assertEqual(
'Space',
'Space code with Space key name',
);
for (const key of ['ScrollLock', 'Unidentified', '', 'x', 'F1', ' ']) {
assertEqual(
primaryFromKeyboardEvent({ code: 'ScrollLock', key }),
'ScrollLock',
'physical ScrollLock uses its canonical primary',
);
}
assertEqual(
primaryFromKeyboardEvent({ code: '', key: 'ScrollLock' }),
'ScrollLock',
'named ScrollLock fallback',
);
{
let result = apply(undefined, 'ScrollLock', true);
result = apply(result.state, 'ScrollLock', true);
assertEqual(result.commitCodes, null, 'ScrollLock repeat does not commit early');
result = apply(result.state, 'ScrollLock', false);
assertDeepEqual(result.commitCodes, ['ScrollLock'], 'ScrollLock commits once on release');
assertDeepEqual(result.state, createHotkeyRecorderState(), 'ScrollLock recording resets');
}
assertEqual(primaryFromKeyboardEvent({ code: 'KeyA', key: 'a' }), 'A', 'letter from code');
assertEqual(primaryFromKeyboardEvent({ code: 'Digit1', key: '!' }), '1', 'digit from code');
1 change: 1 addition & 0 deletions openless-all/app/src/lib/hotkeyRecorder.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export function functionKeyPrimaryFromEvent(event: { code: string; key: string }
* be trimmed to empty by backend validate_primary/parse_primary (#1109).
*/
export function primaryFromKeyboardEvent(event: { code: string; key: string }): string {
if (event.code === 'ScrollLock') return 'ScrollLock';
const functionKey = functionKeyPrimaryFromEvent(event);
if (functionKey) return functionKey;
const printable = primaryFromPrintableCode(event.code);
Expand Down
Loading