diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 97ddea09..e849b126 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -399,6 +399,8 @@ impl KeyCombo { pub const MOD_CTRL: u8 = 1 << 2; /// Bit for the ⌥ Option/Alt modifier in [`Self::modifiers`]. pub const MOD_OPTION: u8 = 1 << 3; + /// Bit for the macOS Fn/Globe modifier in [`Self::modifiers`]. + pub const MOD_FN: u8 = 1 << 4; /// Build the human-readable label from the modifier bitmask + key code. /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the @@ -410,6 +412,9 @@ impl KeyCombo { return self.display.clone(); } let mut out = String::new(); + if self.modifiers & Self::MOD_FN != 0 { + out.push_str("fn"); + } if self.modifiers & Self::MOD_CTRL != 0 { out.push('⌃'); } @@ -442,6 +447,12 @@ impl KeyCombo { 0x22 => out.push('I'), 0x1F => out.push('O'), 0x23 => out.push('P'), + 0x74 => out.push_str("Page Up"), + 0x79 => out.push_str("Page Down"), + 0x7B => out.push('←'), + 0x7C => out.push('→'), + 0x7D => out.push('↓'), + 0x7E => out.push('↑'), _ => { use std::fmt::Write as _; let _ = write!(out, "key 0x{:02X}", self.key_code); @@ -1005,6 +1016,40 @@ mod tests { assert_eq!(combo.rendered_label(), "⇧⌘P"); } + #[test] + fn key_combo_rendered_label_includes_fn_and_navigation_key() { + let combo = KeyCombo { + modifiers: KeyCombo::MOD_FN, + key_code: 0x7D, // Down Arrow + display: String::new(), + }; + assert_eq!(combo.rendered_label(), "fn↓"); + } + + #[test] + fn key_combo_rendered_label_places_fn_before_other_modifiers() { + let combo = KeyCombo { + modifiers: KeyCombo::MOD_FN + | KeyCombo::MOD_CTRL + | KeyCombo::MOD_OPTION + | KeyCombo::MOD_SHIFT + | KeyCombo::MOD_CMD, + key_code: 0x7D, // Down Arrow + display: String::new(), + }; + assert_eq!(combo.rendered_label(), "fn⌃⌥⇧⌘↓"); + } + + #[test] + fn fn_custom_shortcut_roundtrips_toml() { + let action = Action::CustomShortcut(KeyCombo { + modifiers: KeyCombo::MOD_FN, + key_code: 0x7E, // Up Arrow + display: String::new(), + }); + assert_eq!(roundtrip(&action), action); + } + // ── Category tests ──────────────────────────────────────────────────────── #[test] diff --git a/crates/openlogi-inject/examples/inject_action.rs b/crates/openlogi-inject/examples/inject_action.rs index 16653532..b679e329 100644 --- a/crates/openlogi-inject/examples/inject_action.rs +++ b/crates/openlogi-inject/examples/inject_action.rs @@ -210,7 +210,7 @@ fn print_usage() { ScrollUp ScrollDown HorizontalScrollLeft HorizontalScrollRight\n\ CustomShortcut::\n\ \n\ - CustomShortcut modifier bits: 0x01=Cmd/Ctrl 0x02=Shift 0x04=Ctrl 0x08=Option/Alt\n\ + CustomShortcut modifier bits: 0x01=Cmd/Ctrl 0x02=Shift 0x04=Ctrl 0x08=Option/Alt 0x10=Fn (macOS)\n\ CustomShortcut key_hex: macOS kVK_* code (e.g. 0x08=C, 0x09=V, 0x7E=Up)\n\ \n\ Examples:\n\ diff --git a/crates/openlogi-inject/src/inject/macos.rs b/crates/openlogi-inject/src/inject/macos.rs index a492d62a..cfd57cae 100644 --- a/crates/openlogi-inject/src/inject/macos.rs +++ b/crates/openlogi-inject/src/inject/macos.rs @@ -7,7 +7,7 @@ use core_graphics::event::{ use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; use core_graphics::geometry::CGPoint; -use openlogi_core::binding::Action; +use openlogi_core::binding::{Action, KeyCombo}; // NX_KEYTYPE_* constants from . const NX_KEYTYPE_SOUND_UP: i32 = 0; @@ -31,11 +31,56 @@ const VK_W: u16 = 0x0D; const VK_X: u16 = 0x07; const VK_Z: u16 = 0x06; const VK_TAB: u16 = 0x30; +const VK_HOME: u16 = 0x73; +const VK_PAGE_UP: u16 = 0x74; +const VK_END: u16 = 0x77; +const VK_PAGE_DOWN: u16 = 0x79; +const VK_LEFT_ARROW: u16 = 0x7B; +const VK_RIGHT_ARROW: u16 = 0x7C; +const VK_DOWN_ARROW: u16 = 0x7D; +const VK_UP_ARROW: u16 = 0x7E; + +/// Translate a stored shortcut into the virtual key and flags macOS expects. +/// +/// macOS reports Fn+Arrow as the corresponding Home/End/Page Up/Page Down +/// virtual key with the SecondaryFn flag, rather than as a flagged arrow-key +/// event. Reproduce both parts of that representation so apps receive native +/// navigation behavior instead of treating the event like an ordinary arrow. +fn custom_shortcut_event(combo: &KeyCombo) -> (u16, CGEventFlags) { + let mut flags = CGEventFlags::CGEventFlagNull; + if combo.modifiers & KeyCombo::MOD_CMD != 0 { + flags |= CGEventFlags::CGEventFlagCommand; + } + if combo.modifiers & KeyCombo::MOD_SHIFT != 0 { + flags |= CGEventFlags::CGEventFlagShift; + } + if combo.modifiers & KeyCombo::MOD_CTRL != 0 { + flags |= CGEventFlags::CGEventFlagControl; + } + if combo.modifiers & KeyCombo::MOD_OPTION != 0 { + flags |= CGEventFlags::CGEventFlagAlternate; + } + if combo.modifiers & KeyCombo::MOD_FN != 0 + || matches!(combo.key_code, VK_PAGE_UP | VK_PAGE_DOWN) + { + flags |= CGEventFlags::CGEventFlagSecondaryFn; + } + let key_code = if combo.modifiers & KeyCombo::MOD_FN != 0 { + match combo.key_code { + VK_LEFT_ARROW => VK_HOME, + VK_RIGHT_ARROW => VK_END, + VK_UP_ARROW => VK_PAGE_UP, + VK_DOWN_ARROW => VK_PAGE_DOWN, + key_code => key_code, + } + } else { + combo.key_code + }; + (key_code, flags) +} /// macOS implementation: dispatch to the appropriate event helper. pub(super) fn execute(action: &Action) { - use openlogi_core::binding::KeyCombo; - // Modifier bit shorthands. let cmd = CGEventFlags::CGEventFlagCommand; let shift = CGEventFlags::CGEventFlagShift; @@ -131,20 +176,8 @@ pub(super) fn execute(action: &Action) { ); return; } - let mut flags = CGEventFlags::CGEventFlagNull; - if combo.modifiers & KeyCombo::MOD_CMD != 0 { - flags |= CGEventFlags::CGEventFlagCommand; - } - if combo.modifiers & KeyCombo::MOD_SHIFT != 0 { - flags |= CGEventFlags::CGEventFlagShift; - } - if combo.modifiers & KeyCombo::MOD_CTRL != 0 { - flags |= CGEventFlags::CGEventFlagControl; - } - if combo.modifiers & KeyCombo::MOD_OPTION != 0 { - flags |= CGEventFlags::CGEventFlagAlternate; - } - post_key(combo.key_code, flags); + let (key_code, flags) = custom_shortcut_event(combo); + post_key(key_code, flags); } } } @@ -433,6 +466,59 @@ mod dock { } } +#[cfg(test)] +mod tests { + use openlogi_core::binding::KeyCombo; + + use super::custom_shortcut_event; + + #[test] + fn fn_arrows_use_navigation_keycodes_and_the_secondary_fn_flag() { + for (arrow, navigation_key) in [(0x7B, 0x73), (0x7C, 0x77), (0x7E, 0x74), (0x7D, 0x79)] { + let combo = KeyCombo { + modifiers: KeyCombo::MOD_FN, + key_code: arrow, + display: String::new(), + }; + + let (key_code, flags) = custom_shortcut_event(&combo); + + assert_eq!(key_code, navigation_key); + assert!(flags.contains(core_graphics::event::CGEventFlags::CGEventFlagSecondaryFn)); + } + } + + #[test] + fn fn_modifier_preserves_non_arrow_keycodes() { + let combo = KeyCombo { + modifiers: KeyCombo::MOD_FN, + key_code: 0x23, // kVK_ANSI_P + display: String::new(), + }; + + let (key_code, flags) = custom_shortcut_event(&combo); + + assert_eq!(key_code, 0x23); + assert!(flags.contains(core_graphics::event::CGEventFlags::CGEventFlagSecondaryFn)); + } + + #[test] + fn page_keys_preserve_the_secondary_fn_flag_used_by_macos() { + for key_code in [0x74, 0x79] { + let combo = KeyCombo { + modifiers: 0, + key_code, + display: String::new(), + }; + + let (actual_key_code, flags) = custom_shortcut_event(&combo); + + assert_eq!(actual_key_code, key_code); + assert!(flags.contains(core_graphics::event::CGEventFlags::CGEventFlagSecondaryFn)); + } + } +} + /// macOS Space switching actions. /// /// Use the system symbolic hotkey records for "Move left a space" (79) and diff --git a/docs/CONFIGURATION.md b/docs/CONFIGURATION.md index 555d169d..3f079cc9 100644 --- a/docs/CONFIGURATION.md +++ b/docs/CONFIGURATION.md @@ -87,3 +87,17 @@ brightness = 80 Action names are the catalog's variant names (`LeftClick`, `MouseBack`, `Copy`, `PlayPause`, `CycleDpiPresets`, …). Custom keyboard shortcuts are currently hand-authored as a `CustomShortcut` table in the TOML file. + +The modifier mask uses `1` for Command, `2` for Shift, `4` for Control, `8` +for Option, and `16` for Fn/Globe on macOS. For example, these bindings send +Fn+Down and Fn+Up: + +```toml +[devices.2b042.bindings] +Back = { CustomShortcut = { modifiers = 16, key_code = 125, display = "fn↓" } } +Forward = { CustomShortcut = { modifiers = 16, key_code = 126, display = "fn↑" } } +``` + +macOS Page Down (`key_code = 121`) and Page Up (`key_code = 116`) shortcuts +also preserve the SecondaryFn event flag automatically, matching the events +produced by the corresponding physical Fn+arrow combinations.