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 92e436b4d..2fd5e7d4f 100644 --- a/openless-all/app/crates/openless-core/src/shortcut_types.rs +++ b/openless-all/app/crates/openless-core/src/shortcut_types.rs @@ -313,6 +313,7 @@ fn validate_primary(raw: &str) -> Result<(), ShortcutBindingError> { | "ESC" | "ESCAPE" | "SPACE" + | "SCROLLLOCK" | "BACKSPACE" | "DELETE" | "DEL" diff --git a/openless-all/app/crates/openless-core/tests/scrolllock_hotkeys.rs b/openless-all/app/crates/openless-core/tests/scrolllock_hotkeys.rs new file mode 100644 index 000000000..199886d1a --- /dev/null +++ b/openless-all/app/crates/openless-core/tests/scrolllock_hotkeys.rs @@ -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()); +} diff --git a/openless-all/app/src-tauri/backend-tests/Cargo.toml b/openless-all/app/src-tauri/backend-tests/Cargo.toml index 1b3248046..36c4165c0 100644 --- a/openless-all/app/src-tauri/backend-tests/Cargo.toml +++ b/openless-all/app/src-tauri/backend-tests/Cargo.toml @@ -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] diff --git a/openless-all/app/src-tauri/backend-tests/tests/scrolllock_hotkeys.rs b/openless-all/app/src-tauri/backend-tests/tests/scrolllock_hotkeys.rs new file mode 100644 index 000000000..2a5e89675 --- /dev/null +++ b/openless-all/app/src-tauri/backend-tests/tests/scrolllock_hotkeys.rs @@ -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; diff --git a/openless-all/app/src-tauri/src/shortcut_binding.rs b/openless-all/app/src-tauri/src/shortcut_binding.rs index 5f64fedb2..2f9c080fa 100644 --- a/openless-all/app/src-tauri/src/shortcut_binding.rs +++ b/openless-all/app/src-tauri/src/shortcut_binding.rs @@ -87,6 +87,7 @@ pub fn parse_primary(raw: &str) -> Result { "TAB" => Code::Tab, "ESC" | "ESCAPE" => Code::Escape, "SPACE" => Code::Space, + "SCROLLLOCK" => Code::ScrollLock, "BACKSPACE" => Code::Backspace, "DELETE" | "DEL" => Code::Delete, "HOME" => Code::Home, @@ -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 { 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 859110c23..5a116d37a 100644 --- a/openless-all/app/src-tauri/src/side_aware_combo.rs +++ b/openless-all/app/src-tauri/src/side_aware_combo.rs @@ -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 { @@ -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", @@ -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). diff --git a/openless-all/app/src/lib/hotkeyRecorder.test.ts b/openless-all/app/src/lib/hotkeyRecorder.test.ts index 5e5888406..50247bfc9 100644 --- a/openless-all/app/src/lib/hotkeyRecorder.test.ts +++ b/openless-all/app/src/lib/hotkeyRecorder.test.ts @@ -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'); diff --git a/openless-all/app/src/lib/hotkeyRecorder.ts b/openless-all/app/src/lib/hotkeyRecorder.ts index ffd126e3c..a242d480d 100644 --- a/openless-all/app/src/lib/hotkeyRecorder.ts +++ b/openless-all/app/src/lib/hotkeyRecorder.ts @@ -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);