diff --git a/crates/openlogi-agent-core/src/hook_runtime.rs b/crates/openlogi-agent-core/src/hook_runtime.rs index 07419d3e..c16b732b 100644 --- a/crates/openlogi-agent-core/src/hook_runtime.rs +++ b/crates/openlogi-agent-core/src/hook_runtime.rs @@ -12,7 +12,10 @@ use std::sync::{Arc, RwLock}; use openlogi_core::binding::{ Action, ButtonId, GestureDirection, SwipeAccumulator, default_binding, }; +use openlogi_core::config::AppSettings; use openlogi_hid::CaptureChannel; +#[cfg(target_os = "macos")] +use openlogi_hook::ScrollTransform; use openlogi_hook::{EventDisposition, Hook, MouseEvent}; use tracing::{info, warn}; @@ -38,6 +41,38 @@ pub struct HookMaps { /// (orchestrator), the OS-hook callback, and the gesture watcher. pub type SharedHookMaps = Arc>; +/// App-wide scroll-wheel preferences mirrored from config into the hook callback. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScrollSettings { + pub inverted: bool, + pub strength: u8, + pub tactility: u8, +} + +impl Default for ScrollSettings { + fn default() -> Self { + Self { + inverted: false, + strength: 1, + tactility: 0, + } + } +} + +impl ScrollSettings { + #[must_use] + pub fn from_app_settings(settings: &AppSettings) -> Self { + Self { + inverted: settings.wheel_inverted, + strength: settings.wheel_strength.max(1), + tactility: settings.wheel_tactility, + } + } +} + +/// Shared scroll-wheel preferences read by the OS-hook callback. +pub type SharedScrollSettings = Arc>; + /// Tracks which OS-hook button (Middle/Back/Forward) is mid-hold and defers the /// swipe detection itself to a shared [`SwipeAccumulator`], which commits a swipe /// *mid-motion* like the HID++ thumb-pad path in `openlogi-hid`. This wrapper @@ -101,6 +136,7 @@ pub fn start( hooks: SharedHookMaps, dpi_cycle: Arc>, capture: CaptureChannel, + scroll_settings: SharedScrollSettings, ) -> Option { if !Hook::has_accessibility() { warn!( @@ -210,7 +246,39 @@ pub fn start( HOLD.with_borrow_mut(HoldState::cancel); EventDisposition::PassThrough } - MouseEvent::Scroll { .. } => EventDisposition::PassThrough, + MouseEvent::Scroll { + delta_x, + delta_y, + is_continuous, + } => { + let settings = scroll_settings + .read() + .map(|guard| *guard) + .unwrap_or_default(); + if settings == ScrollSettings::default() || is_continuous { + return EventDisposition::PassThrough; + } + + #[cfg(target_os = "macos")] + { + let _ = (delta_x, delta_y); + EventDisposition::TransformScroll(ScrollTransform { + inverted: settings.inverted, + strength: settings.strength, + tactility: settings.tactility, + }) + } + + #[cfg(not(target_os = "macos"))] + { + let (v, h) = transform_scroll(delta_x, delta_y, settings); + if v == 0 && h == 0 { + return EventDisposition::PassThrough; + } + openlogi_core::binding::post_scroll_delta(v, h); + EventDisposition::Suppress + } + } }); match result { @@ -241,6 +309,37 @@ fn resolve_gesture_click( .unwrap_or_else(|| default_binding(id)) } +/// Apply the app-wide scroll preferences to a captured wheel event, returning +/// vertical (axis 1) and horizontal (axis 2) line deltas to re-inject. +fn transform_scroll(delta_x: f32, delta_y: f32, settings: ScrollSettings) -> (i32, i32) { + let strength = f32::from(settings.strength.max(1)); + let tactility = i32::from(settings.tactility.min(10)); + + let mut h = delta_x * strength; + let mut v = delta_y * strength; + if settings.inverted { + h = -h; + v = -v; + } + + (quantize_scroll(v, tactility), quantize_scroll(h, tactility)) +} + +fn quantize_scroll(value: f32, tactility: i32) -> i32 { + let v = value.round() as i32; + if tactility <= 1 { + return v; + } + + let step = tactility; + let abs = v.abs(); + let snapped = ((abs + step / 2) / step) * step; + if snapped == 0 && v != 0 { + return v.signum() * step; + } + if v < 0 { -snapped } else { snapped } +} + /// Whether `action` is just `id`'s own native event — i.e. the button is mapped /// to the very click (or extra-button press) it already produces. In that case /// the hook should pass the event through to the OS rather than suppress and @@ -311,6 +410,45 @@ mod tests { use super::*; use openlogi_core::binding::GESTURE_SWIPE_THRESHOLD; + #[test] + fn transform_scroll_preserves_axis_order() { + let settings = ScrollSettings { + inverted: false, + strength: 2, + tactility: 0, + }; + + assert_eq!(transform_scroll(3.0, -4.0, settings), (-8, 6)); + } + + #[test] + fn transform_scroll_inverts_both_axes() { + let settings = ScrollSettings { + inverted: true, + strength: 1, + tactility: 0, + }; + + assert_eq!(transform_scroll(2.0, -3.0, settings), (3, -2)); + } + + #[test] + fn quantize_scroll_keeps_small_non_zero_motion() { + assert_eq!(quantize_scroll(1.0, 4), 4); + assert_eq!(quantize_scroll(-1.0, 4), -4); + } + + #[test] + fn transform_scroll_rounds_micro_motion_to_zero_for_native_passthrough() { + let settings = ScrollSettings { + inverted: true, + strength: 1, + tactility: 0, + }; + + assert_eq!(transform_scroll(0.2, -0.3, settings), (0, 0)); + } + // The mid-swipe gate itself is unit-tested on `SwipeAccumulator` in // `openlogi-core`; these cover only what `HoldState` adds on top — tagging a // commit with the held button, and matching the button on release. diff --git a/crates/openlogi-agent-core/src/orchestrator.rs b/crates/openlogi-agent-core/src/orchestrator.rs index 4fd394af..59a83189 100644 --- a/crates/openlogi-agent-core/src/orchestrator.rs +++ b/crates/openlogi-agent-core/src/orchestrator.rs @@ -22,7 +22,7 @@ use tracing::warn; use crate::DpiCycleState; use crate::bindings::{bindings_for, gesture_bindings_for, oshook_gestures_for}; use crate::device_order::DeviceStableId; -use crate::hook_runtime::{HookMaps, SharedHookMaps}; +use crate::hook_runtime::{HookMaps, ScrollSettings, SharedHookMaps, SharedScrollSettings}; use crate::ipc::InventoryHealth; use crate::watchers::gesture::GestureBindings; @@ -54,6 +54,7 @@ pub struct SharedRuntime { pub gesture_bindings: GestureBindings, pub dpi_cycle: Arc>, pub thumbwheel_sensitivity: Arc, + pub scroll_settings: SharedScrollSettings, pub capture_channel: CaptureChannel, /// Set while a pairing session runs: the gesture watcher then releases its /// capture session so `run_pairing` can own the receiver's HID node (one @@ -109,6 +110,9 @@ impl Orchestrator { thumbwheel_sensitivity: Arc::new(AtomicI32::new( config.app_settings.thumbwheel_sensitivity, )), + scroll_settings: Arc::new(RwLock::new(ScrollSettings::from_app_settings( + &config.app_settings, + ))), capture_channel: Arc::new(RwLock::new(None)), pairing_active: Arc::new(AtomicBool::new(false)), capture_idle: Arc::new(AtomicBool::new(true)), @@ -182,6 +186,11 @@ impl Orchestrator { self.config.app_settings.thumbwheel_sensitivity, Ordering::Relaxed, ); + write_value( + &self.shared.scroll_settings, + ScrollSettings::from_app_settings(&self.config.app_settings), + "scroll_settings", + ); } /// Apply a fresh inventory snapshot. Always refreshes the snapshot the IPC diff --git a/crates/openlogi-agent/src/main.rs b/crates/openlogi-agent/src/main.rs index 2dbd7ec4..165ddfef 100644 --- a/crates/openlogi-agent/src/main.rs +++ b/crates/openlogi-agent/src/main.rs @@ -201,6 +201,7 @@ async fn run(config: Config) { shared.hook_maps.clone(), shared.dpi_cycle.clone(), shared.capture_channel.clone(), + shared.scroll_settings.clone(), ); hook_installed.store(hook.is_some(), Ordering::Relaxed); } diff --git a/crates/openlogi-core/src/binding.rs b/crates/openlogi-core/src/binding.rs index 5b29587a..c9485d79 100644 --- a/crates/openlogi-core/src/binding.rs +++ b/crates/openlogi-core/src/binding.rs @@ -31,7 +31,7 @@ pub enum ButtonId { /// the click (see `mouse_model::geometry`). Thumbwheel, /// Rotating the thumb wheel "up" (positive rotation). Bound, by default, to - /// continuous horizontal scroll; see the agent-core `watchers`-side dispatch. + /// continuous horizontal scroll; see [`crate::watchers`]-side dispatch. ThumbwheelScrollUp, /// Rotating the thumb wheel "down" (negative rotation). ThumbwheelScrollDown, @@ -344,9 +344,17 @@ impl Category { /// on-disk `config.toml` schema. New variants may be appended freely; removing /// or renaming a variant requires a `schema_version` bump and a migration. /// -/// This type is pure config data: OS-level event synthesis for each variant -/// lives in the `openlogi-inject` crate (`openlogi_inject::execute`), keeping -/// this crate platform- and IO-free. +/// `Action::execute` synthesizes the OS-level event for each variant. +/// On macOS it posts the event via `CGEventPost(kCGHIDEventTap, …)`. +/// On other platforms it logs a warning and returns immediately — the binary +/// compiles on all targets. +/// +/// # Manual verification +/// +/// `execute` is intentionally excluded from the automated test suite because +/// it would need to intercept the OS event queue. Smoke-test it manually: +/// bind a button to any action in the GUI and confirm the expected system event +/// fires when the button is pressed. #[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)] pub enum Action { // ── System ─────────────────────────────────────────────────────────────── @@ -471,8 +479,8 @@ pub enum Action { // ── Custom ─────────────────────────────────────────────────────────────── /// Replay an arbitrary recorded key chord (P1.3). /// - /// Holds the structured chord data so `openlogi_inject::execute` can post the - /// real keystroke (macOS: CGEventPost with the encoded modifier flags). + /// Holds the structured chord data so `execute` can post the real + /// keystroke (macOS: CGEventPost with the encoded modifier flags). /// The `display` field is used by [`Action::label`] so the popover /// shows the user-friendly chord name. CustomShortcut(KeyCombo), @@ -483,8 +491,8 @@ pub enum Action { /// /// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format /// is a compact integer, not a string. `key_code` is the macOS virtual key -/// (`kVK_*`); on Linux, `openlogi-inject` maps it to an evdev `KeyCode` when it -/// synthesizes the chord. +/// (`kVK_*`); on Linux, `Action::execute` maps it to an evdev `KeyCode` via +/// `linux::macos_vk_to_linux`. /// /// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it /// from the captured chord; we keep it in the struct so older configs @@ -495,7 +503,7 @@ pub struct KeyCombo { pub modifiers: u8, /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for /// modifier-only placeholders that the recorder UI rejects. On Linux, - /// `openlogi-inject` translates this to an evdev `KeyCode`. + /// `Action::execute` translates this to an evdev `KeyCode`. pub key_code: u16, /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a /// generated label at runtime. @@ -633,7 +641,7 @@ impl Binding { /// Promote a [`Single`](Binding::Single) binding in place to a /// [`Gesture`](Binding::Gesture), keeping its action as the /// [`GestureDirection::Click`] entry and leaving the swipe arms unbound. - /// A no-op when this is already a [`Gesture`](Binding::Gesture). + /// A no-op when this is already a [`Gesture`]. pub fn upgrade_to_gesture(&mut self) { if let Binding::Single(action) = self { let mut map = BTreeMap::new(); @@ -830,6 +838,889 @@ impl Action { Action::HorizontalScrollRight, ] } + + /// Synthesise the OS-level event for this action. + /// + /// On macOS, key events are posted via `CGEventPost(kCGHIDEventTap, …)` + /// using virtual key codes from the standard US keyboard layout, and the + /// `LeftClick`/`RightClick`/`MiddleClick` variants synthesise a mouse click + /// at the current cursor location. The WindowServer actions (`MissionControl`, + /// `AppExpose`, `ShowDesktop`, `LaunchpadShow`) are posted straight to the + /// Dock via `CoreDockSendNotification`. Device-side actions (`CycleDpiPresets`, + /// `SetDpiPreset`, `ToggleSmartShift`) have no CGEvent equivalent and are + /// handled at the hook/HID layer, logging a trace here. + /// + /// On Linux, key and scroll events are injected via a lazily-created `uinput` + /// virtual device. Mouse clicks inject `BTN_*` events. macOS-only window + /// manager actions (`MissionControl`, `AppExpose`, `ShowDesktop`, + /// `LaunchpadShow`) have no universal Linux equivalent and are silently + /// skipped (debug-logged). `CustomShortcut` maps macOS `kVK_*` codes to + /// Linux key codes; macOS Cmd maps to Ctrl. + /// + /// On Windows, key and mouse events are synthesised via `SendInput`. The + /// macOS window-manager actions map to their Windows equivalents (e.g. + /// `MissionControl` → Win+Tab, `ShowDesktop` → Win+D); `CustomShortcut` + /// maps macOS `kVK_*` codes to Windows virtual-key codes, with Cmd mapped to + /// Ctrl. + /// + /// On other platforms a warning is logged and the function returns + /// immediately — the binary compiles clean on all targets. + pub fn execute(&self) { + #[cfg(target_os = "macos")] + self.execute_macos(); + + #[cfg(target_os = "linux")] + self.execute_linux(); + + #[cfg(target_os = "windows")] + self.execute_windows(); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + { + tracing::warn!( + action = self.label(), + "Action::execute unsupported on this platform" + ); + } + } + + /// Linux implementation: inject events via a shared `uinput` virtual device. + #[cfg(target_os = "linux")] + fn execute_linux(&self) { + use evdev::{KeyCode, RelativeAxisCode}; + let ctrl = KeyCode::KEY_LEFTCTRL; + let shift = KeyCode::KEY_LEFTSHIFT; + let alt = KeyCode::KEY_LEFTALT; + match self { + // ── Mouse clicks ────────────────────────────────────────────────── + Action::LeftClick => linux::click(KeyCode::BTN_LEFT), + Action::RightClick => linux::click(KeyCode::BTN_RIGHT), + Action::MiddleClick => linux::click(KeyCode::BTN_MIDDLE), + // Extra mouse buttons: BTN_SIDE/BTN_EXTRA are the evdev side + // buttons ("back"/"forward") browsers handle natively. + Action::MouseBack => linux::click(KeyCode::BTN_SIDE), + Action::MouseForward => linux::click(KeyCode::BTN_EXTRA), + // ── Editing ─────────────────────────────────────────────────────── + Action::Copy => linux::press_key(&[ctrl], KeyCode::KEY_C), + Action::Paste => linux::press_key(&[ctrl], KeyCode::KEY_V), + Action::Cut => linux::press_key(&[ctrl], KeyCode::KEY_X), + Action::Undo => linux::press_key(&[ctrl], KeyCode::KEY_Z), + // Redo is Ctrl+Shift+Z on Linux (matches macOS ⌘⇧Z convention). + Action::Redo => linux::press_key(&[ctrl, shift], KeyCode::KEY_Z), + Action::SelectAll => linux::press_key(&[ctrl], KeyCode::KEY_A), + Action::Find => linux::press_key(&[ctrl], KeyCode::KEY_F), + Action::Save => linux::press_key(&[ctrl], KeyCode::KEY_S), + // ── Browser / Navigation ────────────────────────────────────────── + Action::BrowserBack => linux::press_key(&[alt], KeyCode::KEY_LEFT), + Action::BrowserForward => linux::press_key(&[alt], KeyCode::KEY_RIGHT), + Action::NewTab => linux::press_key(&[ctrl], KeyCode::KEY_T), + Action::CloseTab => linux::press_key(&[ctrl], KeyCode::KEY_W), + Action::ReopenTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_T), + Action::NextTab => linux::press_key(&[ctrl], KeyCode::KEY_TAB), + Action::PrevTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_TAB), + Action::ReloadPage => linux::press_key(&[ctrl], KeyCode::KEY_R), + // ── Navigation — macOS-specific ─────────────────────────────────── + // No universal Linux equivalent; the compositor shortcut varies. + Action::MissionControl + | Action::AppExpose + | Action::ShowDesktop + | Action::LaunchpadShow => { + tracing::debug!( + action = self.label(), + "no Linux equivalent — action skipped" + ); + } + // Ctrl+Alt+←/→ is the default in GNOME and KDE. + Action::PreviousDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_LEFT), + Action::NextDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_RIGHT), + // ── System ──────────────────────────────────────────────────────── + // logind LockSessions() via the system bus; falls back to Super+L. + Action::LockScreen => linux::lock_screen(), + Action::Screenshot => linux::press_key(&[], KeyCode::KEY_SYSRQ), + // ── Media ───────────────────────────────────────────────────────── + // MPRIS targets the running media player; XF86 volume keys go to the + // system mixer (PulseAudio/PipeWire) which is what users expect. + Action::PlayPause => linux::mpris_command("PlayPause"), + Action::NextTrack => linux::mpris_command("Next"), + Action::PrevTrack => linux::mpris_command("Previous"), + Action::VolumeUp => linux::press_key(&[], KeyCode::KEY_VOLUMEUP), + Action::VolumeDown => linux::press_key(&[], KeyCode::KEY_VOLUMEDOWN), + Action::MuteVolume => linux::press_key(&[], KeyCode::KEY_MUTE), + // ── DPI / SmartShift: handled at hook/HID layer ─────────────────── + Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => { + tracing::debug!( + action = self.label(), + "device action handled by hook/HID layer" + ); + } + // ── Scroll ──────────────────────────────────────────────────────── + Action::ScrollUp => linux::scroll(RelativeAxisCode::REL_WHEEL, 3), + Action::ScrollDown => linux::scroll(RelativeAxisCode::REL_WHEEL, -3), + Action::HorizontalScrollLeft => linux::scroll(RelativeAxisCode::REL_HWHEEL, -3), + Action::HorizontalScrollRight => linux::scroll(RelativeAxisCode::REL_HWHEEL, 3), + // ── No-op ───────────────────────────────────────────────────────── + Action::None => {} + // ── Custom shortcut ─────────────────────────────────────────────── + Action::CustomShortcut(combo) => { + if combo.key_code == 0 { + tracing::warn!( + chord = %combo.rendered_label(), + "CustomShortcut with no key code — press ignored" + ); + return; + } + let Some(key) = linux::macos_vk_to_linux(combo.key_code) else { + tracing::warn!( + key_code = combo.key_code, + "CustomShortcut key code has no Linux mapping — press ignored" + ); + return; + }; + linux::press_key(&linux::modifiers_to_keycodes(combo.modifiers), key); + } + } + } + + /// macOS implementation: dispatch to the appropriate event helper. + #[cfg(target_os = "macos")] + fn execute_macos(&self) { + use core_graphics::event::{CGEventFlags, CGMouseButton}; + + // Modifier bit shorthands. + let cmd = CGEventFlags::CGEventFlagCommand; + let shift = CGEventFlags::CGEventFlagShift; + let ctrl = CGEventFlags::CGEventFlagControl; + + match self { + // Suppressed input: captured but deliberately produces no event. + Action::None => {} + // ── Mouse clicks: synthesise a click at the cursor ──────────────── + // Remapping a *different* button to a click lands here (e.g. Back → + // MiddleClick). A button left on its own native click never reaches + // this — the hook passes it straight through to the OS. + Action::LeftClick => macos::post_click(CGMouseButton::Left), + Action::RightClick => macos::post_click(CGMouseButton::Right), + Action::MiddleClick => macos::post_click(CGMouseButton::Center), + // Extra mouse buttons: post the real button4/5 the OS treats as + // back/forward. Button numbers are 0-indexed (3 = back / "button 4", + // 4 = forward / "button 5"). + Action::MouseBack => macos::post_other_button(3), + Action::MouseForward => macos::post_other_button(4), + // ── Editing ─────────────────────────────────────────────────────── + Action::Copy => macos::post_key(VK_C, cmd), + Action::Paste => macos::post_key(VK_V, cmd), + Action::Cut => macos::post_key(VK_X, cmd), + Action::Undo => macos::post_key(VK_Z, cmd), + Action::Redo => macos::post_key(VK_Z, cmd | shift), + Action::SelectAll => macos::post_key(VK_A, cmd), + Action::Find => macos::post_key(VK_F, cmd), + Action::Save => macos::post_key(VK_S, cmd), + // ── Browser / Navigation ────────────────────────────────────────── + // BrowserBack/Forward: Cmd+[ / Cmd+] as keyboard fallback; hook + // layer handles the physical mouse buttons directly. + // kVK_ANSI_LeftBracket = 0x21, kVK_ANSI_RightBracket = 0x1E + Action::BrowserBack => macos::post_key(0x21, cmd), + Action::BrowserForward => macos::post_key(0x1E, cmd), + Action::NewTab => macos::post_key(VK_T, cmd), + Action::CloseTab => macos::post_key(VK_W, cmd), + Action::ReopenTab => macos::post_key(VK_T, cmd | shift), + Action::NextTab => macos::post_key(VK_TAB, ctrl), + Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift), + Action::ReloadPage => macos::post_key(VK_R, cmd), + // ── Navigation / Window: posted straight to the Dock ────────────── + // Synthesising these shortcuts is unreliable — the WindowServer + // matcher needs the exact configured key (incl. the Fn flag) and + // Show Desktop ignores synthetic events entirely — so they go to the + // Dock via `CoreDockSendNotification`, which fires regardless of the + // user's keyboard settings. + Action::MissionControl => macos::mission_control(), + Action::AppExpose => macos::app_expose(), + Action::PreviousDesktop => macos::previous_desktop(), + Action::NextDesktop => macos::next_desktop(), + Action::ShowDesktop => macos::show_desktop(), + Action::LaunchpadShow => macos::launchpad(), + // ── System ──────────────────────────────────────────────────────── + // Lock screen = Cmd+Ctrl+Q (kVK_ANSI_Q = 0x0C) + Action::LockScreen => macos::post_key(0x0C, cmd | ctrl), + // Screenshot = Cmd+Shift+3 (kVK_ANSI_3 = 0x14) + Action::Screenshot => macos::post_key(0x14, cmd | shift), + // ── Media ───────────────────────────────────────────────────────── + // Media/volume controls are NX system-defined keys, not ordinary + // keyboard virtual-key events. Posting kVK_Volume* through + // CGEventCreateKeyboardEvent is ignored by macOS' volume handler. + Action::PlayPause => macos::post_media_key(macos::NX_KEYTYPE_PLAY), + Action::NextTrack => macos::post_media_key(macos::NX_KEYTYPE_NEXT), + Action::PrevTrack => macos::post_media_key(macos::NX_KEYTYPE_PREVIOUS), + Action::VolumeUp => macos::post_media_key(macos::NX_KEYTYPE_SOUND_UP), + Action::VolumeDown => macos::post_media_key(macos::NX_KEYTYPE_SOUND_DOWN), + Action::MuteVolume => macos::post_media_key(macos::NX_KEYTYPE_MUTE), + // ── DPI / SmartShift: handled at hook/HID layer ─────────────────── + Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => { + tracing::debug!( + action = self.label(), + "device action handled by hook/HID layer" + ); + } + // ── Scroll ──────────────────────────────────────────────────────── + Action::ScrollUp + | Action::ScrollDown + | Action::HorizontalScrollLeft + | Action::HorizontalScrollRight => macos::post_scroll(self), + // ── Custom ──────────────────────────────────────────────────────── + Action::CustomShortcut(combo) => { + // P1.3: post the recorded chord. `key_code == 0` is the + // "modifier-only placeholder" the recorder UI rejects; + // skip it here too so a malformed config doesn't fire + // bare modifier presses. + if combo.key_code == 0 { + tracing::warn!( + chord = %combo.rendered_label(), + "CustomShortcut with no key code — press ignored" + ); + 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; + } + macos::post_key(combo.key_code, flags); + } + } + } + + /// Windows implementation: synthesise events via `SendInput`. macOS + /// window-manager actions map to their Windows equivalents; `CustomShortcut` + /// maps macOS `kVK_*` codes to Windows virtual-key codes (Cmd → Ctrl). + #[cfg(target_os = "windows")] + fn execute_windows(&self) { + match self { + Action::LeftClick => windows::post_click(windows::MouseButton::Left), + Action::RightClick => windows::post_click(windows::MouseButton::Right), + Action::MiddleClick => windows::post_click(windows::MouseButton::Middle), + Action::MouseBack => windows::post_click(windows::MouseButton::Back), + Action::MouseForward => windows::post_click(windows::MouseButton::Forward), + Action::Copy => windows::post_key(windows::VK_C, &[windows::VK_CONTROL]), + Action::Paste => windows::post_key(windows::VK_V, &[windows::VK_CONTROL]), + Action::Cut => windows::post_key(windows::VK_X, &[windows::VK_CONTROL]), + Action::Undo => windows::post_key(windows::VK_Z, &[windows::VK_CONTROL]), + Action::Redo => windows::post_key(windows::VK_Y, &[windows::VK_CONTROL]), + Action::SelectAll => windows::post_key(windows::VK_A, &[windows::VK_CONTROL]), + Action::Find => windows::post_key(windows::VK_F, &[windows::VK_CONTROL]), + Action::Save => windows::post_key(windows::VK_S, &[windows::VK_CONTROL]), + Action::BrowserBack => windows::post_key(windows::VK_BROWSER_BACK, &[]), + Action::BrowserForward => windows::post_key(windows::VK_BROWSER_FORWARD, &[]), + Action::NewTab => windows::post_key(windows::VK_T, &[windows::VK_CONTROL]), + Action::CloseTab => windows::post_key(windows::VK_W, &[windows::VK_CONTROL]), + Action::ReopenTab => { + windows::post_key(windows::VK_T, &[windows::VK_CONTROL, windows::VK_SHIFT]); + } + Action::NextTab => windows::post_key(windows::VK_TAB, &[windows::VK_CONTROL]), + Action::PrevTab => { + windows::post_key(windows::VK_TAB, &[windows::VK_CONTROL, windows::VK_SHIFT]); + } + Action::ReloadPage => windows::post_key(windows::VK_R, &[windows::VK_CONTROL]), + Action::MissionControl | Action::AppExpose => { + windows::post_key(windows::VK_TAB, &[windows::VK_LWIN]); + } + Action::PreviousDesktop => { + windows::post_key(windows::VK_LEFT, &[windows::VK_LWIN, windows::VK_CONTROL]); + } + Action::NextDesktop => { + windows::post_key(windows::VK_RIGHT, &[windows::VK_LWIN, windows::VK_CONTROL]); + } + Action::ShowDesktop => windows::post_key(windows::VK_D, &[windows::VK_LWIN]), + Action::LaunchpadShow => windows::post_key(windows::VK_LWIN, &[]), + Action::LockScreen => windows::post_key(windows::VK_L, &[windows::VK_LWIN]), + Action::Screenshot => { + windows::post_key(windows::VK_S, &[windows::VK_LWIN, windows::VK_SHIFT]); + } + Action::PlayPause => windows::post_key(windows::VK_MEDIA_PLAY_PAUSE, &[]), + Action::NextTrack => windows::post_key(windows::VK_MEDIA_NEXT_TRACK, &[]), + Action::PrevTrack => windows::post_key(windows::VK_MEDIA_PREV_TRACK, &[]), + Action::VolumeUp => windows::post_key(windows::VK_VOLUME_UP, &[]), + Action::VolumeDown => windows::post_key(windows::VK_VOLUME_DOWN, &[]), + Action::MuteVolume => windows::post_key(windows::VK_VOLUME_MUTE, &[]), + Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => { + tracing::debug!( + action = self.label(), + "device action handled by hook/HID layer" + ); + } + Action::ScrollUp + | Action::ScrollDown + | Action::HorizontalScrollLeft + | Action::HorizontalScrollRight => windows::post_scroll(self), + Action::CustomShortcut(combo) => windows::post_custom_shortcut(combo), + Action::None => {} + } + } +} + +/// Re-inject a captured scroll-wheel event after applying user preferences. +/// +/// `v` is vertical axis 1, `h` is horizontal axis 2. On macOS this posts at the +/// session tap rather than the HID tap so OpenLogi's own HID event tap does not +/// capture and transform the synthetic event again. +pub fn post_scroll_delta(v: i32, h: i32) { + if v == 0 && h == 0 { + return; + } + + #[cfg(target_os = "macos")] + macos::post_scroll_delta(v, h); + + #[cfg(target_os = "linux")] + { + if v != 0 { + linux::scroll(evdev::RelativeAxisCode::REL_WHEEL, v); + } + if h != 0 { + linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, h); + } + } + + #[cfg(target_os = "windows")] + windows::post_scroll_delta(v, h); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + let _ = (v, h); +} + +/// Synthesise a horizontal scroll of `delta` wheel lines at the current focus. +/// +/// Used by the gesture/thumbwheel capture watcher to re-inject the MX thumb +/// wheel's scrolling after the wheel has been diverted over HID++ to capture its +/// click. `delta` is the device's raw rotation; its sign follows the wheel's +/// rotation convention and its magnitude (one line per rotation increment) may +/// need tuning per device, since the diverted resolution differs from native. +/// +/// No-op (logs nothing) on platforms without a supported injection mechanism. + +pub fn post_horizontal_scroll(delta: i32) { + #[cfg(target_os = "macos")] + macos::post_horizontal_scroll(delta); + + // `delta` is already in "one line per rotation increment" units (see doc + // above), which matches REL_HWHEEL's convention of one unit per detent. + // This is intentionally different from Action::HorizontalScrollLeft/Right, + // which hardcode ±3 as a fixed "scroll tick" with no device delta involved. + #[cfg(target_os = "linux")] + linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, delta); + + #[cfg(target_os = "windows")] + windows::post_horizontal_scroll(delta); + + #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))] + let _ = delta; +} + +/// Return the `/dev/input/eventN` node for the action-injector uinput device, +/// initialising it if needed. +/// +/// Intended for debugging and manual smoke-testing (e.g. attaching `evtest` +/// before firing `Action::execute`). Returns `None` on non-Linux platforms or +/// when the device could not be created (e.g. `/dev/uinput` not writable). +#[cfg(target_os = "linux")] +#[must_use] +pub fn action_device_path() -> Option { + linux::device_node() +} + +// ── macOS virtual key codes ──────────────────────────────────────────────── +// Source: kVK_* constants. Values are layout-independent +// for the US ANSI keyboard. +#[cfg(target_os = "macos")] +const VK_A: u16 = 0x00; +#[cfg(target_os = "macos")] +const VK_C: u16 = 0x08; +#[cfg(target_os = "macos")] +const VK_F: u16 = 0x03; +#[cfg(target_os = "macos")] +const VK_R: u16 = 0x0F; +#[cfg(target_os = "macos")] +const VK_S: u16 = 0x01; +#[cfg(target_os = "macos")] +const VK_T: u16 = 0x11; +#[cfg(target_os = "macos")] +const VK_V: u16 = 0x09; +#[cfg(target_os = "macos")] +const VK_W: u16 = 0x0D; +#[cfg(target_os = "macos")] +const VK_X: u16 = 0x07; +#[cfg(target_os = "macos")] +const VK_Z: u16 = 0x06; +#[cfg(target_os = "macos")] +const VK_TAB: u16 = 0x30; + +/// Stamped into the `EVENT_SOURCE_USER_DATA` field of every mouse event +/// [`Action::execute`] synthesizes on macOS, so OpenLogi's own `CGEventTap` can +/// recognize and skip its own injections. Without it, a gesture/button action +/// that posts a mouse button (e.g. a remapped `MiddleClick`) would re-enter the +/// hook — and for a gesture button, be misread as a fresh hold, looping. The +/// value is arbitrary but distinctive ("OLGI"); real events carry `0` here. +pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x4F4C_4749; + +/// Platform helpers for synthesising OS-level input events on macOS. +#[cfg(target_os = "macos")] +mod macos { + use core_graphics::event::{ + CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, EventField, + ScrollEventUnit, + }; + use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; + use core_graphics::geometry::CGPoint; + + use crate::binding::Action; + + // NX_KEYTYPE_* constants from . + pub(super) const NX_KEYTYPE_SOUND_UP: i32 = 0; + pub(super) const NX_KEYTYPE_SOUND_DOWN: i32 = 1; + pub(super) const NX_KEYTYPE_MUTE: i32 = 7; + pub(super) const NX_KEYTYPE_PLAY: i32 = 16; + pub(super) const NX_KEYTYPE_NEXT: i32 = 17; + pub(super) const NX_KEYTYPE_PREVIOUS: i32 = 18; + + /// Post a mouse-down + mouse-up pair for `button` at the cursor's current + /// location. + /// + /// Posted at the HID tap location, so OpenLogi's own event tap sees the + /// synthetic click too: a `LeftClick`/`RightClick` flows straight through + /// (the tap never owns the primary buttons), and a `MiddleClick` is left + /// alone unless the user has *also* remapped the middle button. + pub(super) fn post_click(button: CGMouseButton) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for click"); + return; + }; + // A fresh event reports the current pointer location; mouse events need + // an explicit position or they land at (0, 0). + let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location()); + let (down, up) = match button { + CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp), + CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp), + CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp), + }; + for (kind, phase) in [(down, "down"), (up, "up")] { + if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) { + tag_synthetic(&ev); + ev.post(CGEventTapLocation::HID); + } else { + tracing::warn!(phase, "CGEvent::new_mouse_event failed"); + } + } + } + + /// Post a down + up pair for an "extra" mouse button by its raw button + /// number (3 = back / "button 4", 4 = forward / "button 5"). These are the + /// native events browsers and most apps interpret as back/forward. + /// + /// `CGMouseButton` only names Left/Right/Center, so we create an + /// `OtherMouse` event and override `MOUSE_EVENT_BUTTON_NUMBER` to address + /// buttons ≥ 3. Tagged via [`tag_synthetic`] so OpenLogi's own event tap + /// ignores it instead of re-translating it into a Back/Forward press. + pub(super) fn post_other_button(button_number: i64) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for extra mouse button"); + return; + }; + let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location()); + for (kind, phase) in [ + (CGEventType::OtherMouseDown, "down"), + (CGEventType::OtherMouseUp, "up"), + ] { + if let Ok(ev) = + CGEvent::new_mouse_event(src.clone(), kind, location, CGMouseButton::Center) + { + ev.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, button_number); + tag_synthetic(&ev); + ev.post(CGEventTapLocation::HID); + } else { + tracing::warn!(phase, "CGEvent::new_mouse_event failed for extra button"); + } + } + } + + /// Stamp [`SYNTHETIC_EVENT_USER_DATA`](super::SYNTHETIC_EVENT_USER_DATA) + /// into the event's source user-data so OpenLogi's own event tap recognises + /// and skips its own injections instead of treating them as fresh input + /// (e.g. re-translating a synthesized button 4/5 into a Back/Forward press, + /// or misreading a remapped click as a new gesture hold). + fn tag_synthetic(ev: &CGEvent) { + ev.set_integer_value_field( + EventField::EVENT_SOURCE_USER_DATA, + super::SYNTHETIC_EVENT_USER_DATA, + ); + } + + /// Post a key-down + key-up pair for `vk` with `flags` set. + pub(super) fn post_key(vk: u16, flags: CGEventFlags) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed"); + return; + }; + let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else { + tracing::warn!("CGEvent::new_keyboard_event(down) failed"); + return; + }; + down.set_flags(flags); + down.post(CGEventTapLocation::HID); + let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else { + tracing::warn!("CGEvent::new_keyboard_event(up) failed"); + return; + }; + up.set_flags(flags); + up.post(CGEventTapLocation::HID); + } + + /// Post a media/system key event (play/pause, track navigation, volume). + /// + /// Runs on the hook/gesture dispatch threads, which have no run loop to + /// drain autorelease pools, and both `NSEvent` creation and the `CGEvent` + /// getter autorelease temporaries — so the exchange sits inside an + /// explicit `autoreleasepool`, same as the hook's `frontmost_bundle_id`. + pub(super) fn post_media_key(nx_key: i32) { + use objc2::rc::autoreleasepool; + use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType}; + use objc2_core_graphics::{CGEvent, CGEventTapLocation}; + use objc2_foundation::NSPoint; + + const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8; + const NX_KEY_DOWN: i32 = 0x0A; + const NX_KEY_UP: i32 = 0x0B; + + autoreleasepool(|_| { + for (state, phase) in [(NX_KEY_DOWN, "down"), (NX_KEY_UP, "up")] { + // data1 layout for subtype 8: high word is NX_KEYTYPE_*, next byte + // is key state (0x0A down, 0x0B up), low bit is repeat (0 here). + let data1 = ((nx_key << 16) | (state << 8)) as isize; + let Some(ns_event) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2( + NSEventType::SystemDefined, + NSPoint::new(0.0, 0.0), + NSEventModifierFlags::empty(), + 0.0, + 0, + None, + NX_SUBTYPE_AUX_CONTROL_BUTTONS, + data1, + 0, + ) else { + tracing::warn!(nx_key, phase, "NSEvent::otherEventWithType failed"); + return; + }; + let Some(cg_event) = ns_event.CGEvent() else { + tracing::warn!(nx_key, phase, "NSEvent::CGEvent failed"); + return; + }; + CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&cg_event)); + } + }); + } + + /// Post a synthetic scroll event for `action` (one of the `Scroll*` variants). + pub(super) fn post_scroll(action: &Action) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for scroll"); + return; + }; + let (v, h): (i32, i32) = match action { + Action::ScrollUp => (3, 0), + Action::ScrollDown => (-3, 0), + Action::HorizontalScrollLeft => (0, -3), + Action::HorizontalScrollRight => (0, 3), + _ => return, + }; + let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else { + tracing::warn!("CGEvent::new_scroll_event failed"); + return; + }; + ev.post(CGEventTapLocation::HID); + } + + /// Post a scroll event with explicit vertical / horizontal line deltas. + pub(super) fn post_scroll_delta(v: i32, h: i32) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for scroll"); + return; + }; + let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, v, h, 0) else { + tracing::warn!("CGEvent::new_scroll_event failed"); + return; + }; + ev.post(CGEventTapLocation::Session); + } + + /// Post a horizontal scroll of `delta` lines (wheel2 axis). Line units suit + /// the thumb wheel's ratchet-like increments better than pixels. + pub(super) fn post_horizontal_scroll(delta: i32) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for thumbwheel scroll"); + return; + }; + let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else { + tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel"); + return; + }; + ev.post(CGEventTapLocation::HID); + } + + pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop}; + pub(super) use symbolic_hotkey::{next_desktop, previous_desktop}; + + use app_services::symbol as app_services_symbol; + + /// Shared resolver for private ApplicationServices SPI used by the Dock and + /// symbolic-hotkey helpers. + #[allow( + unsafe_code, + reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI" + )] + mod app_services { + use std::ffi::{CStr, c_char, c_int, c_void}; + use std::sync::OnceLock; + + /// Resolve a symbol from ApplicationServices, caching the `dlopen` + /// handle for the process lifetime. Returns `None` if the framework or + /// symbol is unavailable on this macOS version. + pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> { + const RTLD_LAZY: c_int = 0x1; + const APP_SERVICES: &CStr = + c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices"; + static HANDLE: OnceLock = OnceLock::new(); + + // SAFETY: `dlopen`/`dlsym` come from libSystem; APP_SERVICES and + // `symbol` are valid C strings. The handle is cached and + // intentionally never closed. + let sym = unsafe { + let handle = + *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize); + if handle == 0 { + return None; + } + dlsym(handle as *mut c_void, symbol.as_ptr()) + }; + (!sym.is_null()).then_some(sym) + } + + unsafe extern "C" { + fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void; + fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void; + } + } + + /// WindowServer window/space actions (Mission Control, App Exposé, Show + /// Desktop, Launchpad). + /// + /// These are driven by the Dock, and synthesising their keyboard shortcut is + /// unreliable — the WindowServer matcher needs the exact configured key + /// (incl. the Fn flag) and Show Desktop's in particular doesn't respond. So + /// we post the action straight to the Dock via the private + /// `CoreDockSendNotification` SPI, which fires it regardless of the user's + /// Keyboard settings. + /// + /// Isolated in its own submodule so the `unsafe` the `dlopen`/`dlsym` FFI + /// needs is scoped here rather than spread across the platform helpers. + #[allow( + unsafe_code, + reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI" + )] + mod dock { + use std::ffi::{c_int, c_void}; + + use core_foundation::base::TCFType; + use core_foundation::string::CFString; + + use super::app_services_symbol; + + /// Show all windows across spaces (Mission Control). + pub(crate) fn mission_control() { + send("com.apple.expose.awake"); + } + + /// Show the front app's windows (App Exposé). + pub(crate) fn app_expose() { + send("com.apple.expose.front.awake"); + } + + /// Move all windows aside to reveal the desktop. + pub(crate) fn show_desktop() { + send("com.apple.showdesktop.awake"); + } + + /// Toggle Launchpad. A no-op on macOS 26, which removed Launchpad. + pub(crate) fn launchpad() { + send("com.apple.launchpad.toggle"); + } + + /// Post `notification` to the Dock. Logs and returns on any failure. + fn send(notification: &str) { + let Some(core_dock_send) = core_dock_send_notification() else { + tracing::warn!(notification, "CoreDockSendNotification unavailable"); + return; + }; + let name = CFString::new(notification); + // SAFETY: resolved AppServices symbol called with its documented + // signature; `name` is a live CFString for the call's duration. + let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) }; + if err != 0 { + tracing::warn!(notification, err, "CoreDockSendNotification failed"); + } + } + + type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int; + + /// Resolve `CoreDockSendNotification` from `ApplicationServices`, caching + /// the `dlopen` handle for the process lifetime. `None` if unavailable. + fn core_dock_send_notification() -> Option { + let sym = app_services_symbol(c"CoreDockSendNotification")?; + // SAFETY: the symbol, when present, has the documented signature. + Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) }) + } + } + + /// macOS Space switching actions. + /// + /// Use the system symbolic hotkey records for "Move left a space" (79) and + /// "Move right a space" (81). That respects the user's configured shortcut + /// instead of assuming Ctrl+Left/Right, and temporarily enables the symbolic + /// hotkey when the user has disabled it. + #[allow( + unsafe_code, + reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI" + )] + mod symbolic_hotkey { + use std::ffi::{c_int, c_uint, c_ushort, c_void}; + + use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation}; + use core_graphics::event_source::{CGEventSource, CGEventSourceStateID}; + + use super::app_services_symbol; + + const SPACE_LEFT: u32 = 79; + const SPACE_RIGHT: u32 = 81; + + /// Switch to the previous desktop / Space. + pub(crate) fn previous_desktop() { + post_symbolic_hotkey(SPACE_LEFT); + } + + /// Switch to the next desktop / Space. + pub(crate) fn next_desktop() { + post_symbolic_hotkey(SPACE_RIGHT); + } + + fn post_symbolic_hotkey(hotkey: u32) { + let Some(cgs) = cgs_hotkey_api() else { + tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable"); + return; + }; + + let mut key_equivalent = 0_u16; + let mut virtual_key = 0_u16; + let mut modifiers = 0_u32; + + // SAFETY: resolved AppServices symbols are called with their + // expected signatures and valid out-parameters. + let err = unsafe { + (cgs.get_value)( + hotkey, + &raw mut key_equivalent, + &raw mut virtual_key, + &raw mut modifiers, + ) + }; + if err != 0 { + tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed"); + return; + } + + // SAFETY: resolved AppServices symbol called with its expected + // signature. + let was_enabled = unsafe { (cgs.is_enabled)(hotkey) }; + if !was_enabled { + // SAFETY: resolved AppServices symbol called with its expected + // signature. + let err = unsafe { (cgs.set_enabled)(hotkey, true) }; + if err != 0 { + tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed"); + } + } + + post_key(virtual_key, modifiers); + + if !was_enabled { + // SAFETY: resolved AppServices symbol called with its expected + // signature. + let err = unsafe { (cgs.set_enabled)(hotkey, false) }; + if err != 0 { + tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed"); + } + } + } + + fn post_key(vk: u16, modifiers: u32) { + let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else { + tracing::warn!("CGEventSource::new failed for symbolic hotkey"); + return; + }; + let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else { + tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed"); + return; + }; + let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers)); + down.set_flags(flags); + down.post(CGEventTapLocation::Session); + + let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else { + tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed"); + return; + }; + up.set_flags(flags); + up.post(CGEventTapLocation::Session); + } + + #[derive(Clone, Copy)] + struct CgsHotkeyApi { + get_value: CgsGetSymbolicHotKeyValueFn, + is_enabled: CgsIsSymbolicHotKeyEnabledFn, + set_enabled: CgsSetSymbolicHotKeyEnabledFn, + } + + type CgsGetSymbolicHotKeyValueFn = + unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int; + type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool; + type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int; + + fn cgs_hotkey_api() -> Option { + let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?; + let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?; + let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?; + + // SAFETY: the symbols, when present, have the private SPI + // signatures declared above. + Some(unsafe { + CgsHotkeyApi { + get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>( + get_value, + ), + is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>( + is_enabled, + ), + set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>( + set_enabled, + ), + } + }) + } + } } /// Sensible defaults for a fresh device so the panel isn't empty on first run. @@ -906,6 +1797,709 @@ pub fn default_binding_for(button: ButtonId) -> Binding { } } +/// Linux helpers for synthesising OS-level input events via a shared `uinput` +/// virtual device. +/// +/// The device is created lazily on first use. If `/dev/uinput` is inaccessible +/// (missing group membership or udev rule) every call logs a `warn` and returns +/// without panicking. +#[cfg(target_os = "linux")] +mod linux { + use std::io; + use std::sync::{LazyLock, Mutex}; + + use evdev::uinput::VirtualDevice; + use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode}; + use zbus::blocking::Connection as DbusConn; + + const DEVICE_NAME: &str = "OpenLogi action injector"; + + static VIRTUAL_INPUT: LazyLock>> = LazyLock::new(|| { + build() + .map(Mutex::new) + .map_err(|e| tracing::warn!("failed to create uinput action device: {e}")) + .ok() + }); + + #[rustfmt::skip] + const KEY_CAPABILITIES: &[KeyCode] = &[ + // Letters + KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D, + KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H, + KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L, + KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P, + KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T, + KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X, + KeyCode::KEY_Y, KeyCode::KEY_Z, + // Digits + KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3, + KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7, + KeyCode::KEY_8, KeyCode::KEY_9, + // Punctuation / symbols + KeyCode::KEY_MINUS, KeyCode::KEY_EQUAL, KeyCode::KEY_LEFTBRACE, + KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON, + KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE, KeyCode::KEY_COMMA, + KeyCode::KEY_DOT, KeyCode::KEY_SLASH, + // Navigation / editing + KeyCode::KEY_LEFT, KeyCode::KEY_RIGHT, KeyCode::KEY_UP, KeyCode::KEY_DOWN, + KeyCode::KEY_HOME, KeyCode::KEY_END, KeyCode::KEY_PAGEUP, KeyCode::KEY_PAGEDOWN, + KeyCode::KEY_TAB, KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE, + KeyCode::KEY_ESC, KeyCode::KEY_SPACE, + // Modifiers (KEY_LEFTMETA used by the LockScreen Super+L fallback) + KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA, + // Function keys + KeyCode::KEY_F1, KeyCode::KEY_F2, KeyCode::KEY_F3, KeyCode::KEY_F4, + KeyCode::KEY_F5, KeyCode::KEY_F6, KeyCode::KEY_F7, KeyCode::KEY_F8, + KeyCode::KEY_F9, KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12, + // System + KeyCode::KEY_SYSRQ, + // Multimedia + KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG, + KeyCode::KEY_VOLUMEUP, KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE, + // Mouse buttons (injected as EV_KEY with BTN_* codes). The side pair + // must be registered here or the kernel silently drops their events. + KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE, + KeyCode::BTN_SIDE, KeyCode::BTN_EXTRA, + ]; + + fn build() -> io::Result { + let mut keys = AttributeSet::::default(); + for &k in KEY_CAPABILITIES { + keys.insert(k); + } + + // Only scroll axes: the device never emits cursor movement, so leaving + // out REL_X/REL_Y keeps libinput from classifying it as a pointer — + // which can otherwise cause injected key/wheel events to be grabbed by + // pointer-grabbing X11 clients or routed oddly by some Wayland compositors. + let mut axes = AttributeSet::::default(); + for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] { + axes.insert(a); + } + + VirtualDevice::builder()? + .name(DEVICE_NAME) + .with_keys(&keys)? + .with_relative_axes(&axes)? + .build() + } + + fn emit(events: &[InputEvent]) { + if let Some(m) = &*VIRTUAL_INPUT { + if let Ok(mut guard) = m.lock() { + if let Err(e) = guard.emit(events) { + tracing::warn!("uinput action emit failed: {e}"); + } + } else { + tracing::warn!("uinput action device mutex poisoned"); + } + } else { + // Device creation failed at init; already logged once in LazyLock. + tracing::debug!("uinput action device unavailable — action skipped"); + } + } + + fn syn() -> InputEvent { + InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0) + } + + fn key_ev(code: KeyCode, value: i32) -> InputEvent { + InputEvent::new(EventType::KEY.0, code.0, value) + } + + fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent { + InputEvent::new(EventType::RELATIVE.0, axis.0, value) + } + + /// Inject modifier-down + key-down in one SYN frame, then key-up + + /// modifier-up in a second SYN frame. + /// + /// Two separate frames give the kernel distinct timestamps for press and + /// release, which matches what the kernel `uinput` docs show and avoids + /// toolkits treating a zero-duration event as invalid. + pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) { + // Down phase. + let mut down: Vec = Vec::with_capacity(mods.len() + 2); + for &m in mods { + down.push(key_ev(m, 1)); + } + down.push(key_ev(key, 1)); + down.push(syn()); + emit(&down); + + // Up phase. + let mut up: Vec = Vec::with_capacity(mods.len() + 2); + up.push(key_ev(key, 0)); + for &m in mods.iter().rev() { + up.push(key_ev(m, 0)); + } + up.push(syn()); + emit(&up); + } + + /// Inject a button-down in one SYN frame and button-up in a second. + pub(super) fn click(button: KeyCode) { + emit(&[key_ev(button, 1), syn()]); + emit(&[key_ev(button, 0), syn()]); + } + + /// Inject a single relative-axis delta followed by `SYN_REPORT`. + pub(super) fn scroll(axis: RelativeAxisCode, value: i32) { + emit(&[rel_ev(axis, value), syn()]); + } + + /// Force the virtual device to initialise (if it hasn't already) and return + /// its `/dev/input/eventN` node path. + /// + /// Uses `VirtualDevice::enumerate_dev_nodes()` which returns the correct + /// `/dev/input/eventN` path directly. Returns `None` if the device couldn't + /// be created or if the node hasn't appeared yet (udev typically creates it + /// within a few milliseconds of the `ioctl`). + pub(super) fn device_node() -> Option { + // Touch the LazyLock to force initialisation. + let _ = &*VIRTUAL_INPUT; + // Give udev a moment to create the /dev node. + std::thread::sleep(std::time::Duration::from_millis(150)); + if let Some(m) = &*VIRTUAL_INPUT + && let Ok(mut guard) = m.lock() + { + return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next(); + } + None + } + + /// Convert a [`KeyCombo`] modifier bitmask to the evdev keys to hold. + /// + /// macOS Cmd (`MOD_CMD`) and Ctrl (`MOD_CTRL`) both map to `KEY_LEFTCTRL`; + /// the bitwise-OR check deduplicates them so at most one Ctrl is pushed. + /// Order is canonical: Ctrl → Shift → Alt. + pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec { + use crate::binding::KeyCombo; + let mut mods = Vec::new(); + if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 { + mods.push(KeyCode::KEY_LEFTCTRL); + } + if modifiers & KeyCombo::MOD_SHIFT != 0 { + mods.push(KeyCode::KEY_LEFTSHIFT); + } + if modifiers & KeyCombo::MOD_OPTION != 0 { + mods.push(KeyCode::KEY_LEFTALT); + } + mods + } + + /// Map a macOS `kVK_*` virtual key code to the corresponding Linux `KeyCode`. + /// + /// Source: `HIToolbox/Events.h` (macOS side) and + /// `linux/input-event-codes.h` (Linux side). Only the codes the recorder UI + /// is likely to produce are mapped; unknown codes return `None`. + pub(super) fn macos_vk_to_linux(vk: u16) -> Option { + Some(match vk { + 0x00 => KeyCode::KEY_A, // kVK_ANSI_A + 0x01 => KeyCode::KEY_S, // kVK_ANSI_S + 0x02 => KeyCode::KEY_D, // kVK_ANSI_D + 0x03 => KeyCode::KEY_F, // kVK_ANSI_F + 0x04 => KeyCode::KEY_H, // kVK_ANSI_H + 0x05 => KeyCode::KEY_G, // kVK_ANSI_G + 0x06 => KeyCode::KEY_Z, // kVK_ANSI_Z + 0x07 => KeyCode::KEY_X, // kVK_ANSI_X + 0x08 => KeyCode::KEY_C, // kVK_ANSI_C + 0x09 => KeyCode::KEY_V, // kVK_ANSI_V + 0x0B => KeyCode::KEY_B, // kVK_ANSI_B + 0x0C => KeyCode::KEY_Q, // kVK_ANSI_Q + 0x0D => KeyCode::KEY_W, // kVK_ANSI_W + 0x0E => KeyCode::KEY_E, // kVK_ANSI_E + 0x0F => KeyCode::KEY_R, // kVK_ANSI_R + 0x10 => KeyCode::KEY_Y, // kVK_ANSI_Y + 0x11 => KeyCode::KEY_T, // kVK_ANSI_T + 0x12 => KeyCode::KEY_1, // kVK_ANSI_1 + 0x13 => KeyCode::KEY_2, // kVK_ANSI_2 + 0x14 => KeyCode::KEY_3, // kVK_ANSI_3 + 0x15 => KeyCode::KEY_4, // kVK_ANSI_4 + 0x16 => KeyCode::KEY_6, // kVK_ANSI_6 + 0x17 => KeyCode::KEY_5, // kVK_ANSI_5 + 0x18 => KeyCode::KEY_EQUAL, // kVK_ANSI_Equal + 0x19 => KeyCode::KEY_9, // kVK_ANSI_9 + 0x1A => KeyCode::KEY_7, // kVK_ANSI_7 + 0x1B => KeyCode::KEY_MINUS, // kVK_ANSI_Minus + 0x1C => KeyCode::KEY_8, // kVK_ANSI_8 + 0x1D => KeyCode::KEY_0, // kVK_ANSI_0 + 0x1E => KeyCode::KEY_RIGHTBRACE, // kVK_ANSI_RightBracket + 0x1F => KeyCode::KEY_O, // kVK_ANSI_O + 0x20 => KeyCode::KEY_U, // kVK_ANSI_U + 0x21 => KeyCode::KEY_LEFTBRACE, // kVK_ANSI_LeftBracket + 0x22 => KeyCode::KEY_I, // kVK_ANSI_I + 0x23 => KeyCode::KEY_P, // kVK_ANSI_P + 0x24 => KeyCode::KEY_ENTER, // kVK_Return + 0x25 => KeyCode::KEY_L, // kVK_ANSI_L + 0x26 => KeyCode::KEY_J, // kVK_ANSI_J + 0x27 => KeyCode::KEY_APOSTROPHE, // kVK_ANSI_Quote + 0x28 => KeyCode::KEY_K, // kVK_ANSI_K + 0x29 => KeyCode::KEY_SEMICOLON, // kVK_ANSI_Semicolon + 0x2A => KeyCode::KEY_BACKSLASH, // kVK_ANSI_Backslash + 0x2B => KeyCode::KEY_COMMA, // kVK_ANSI_Comma + 0x2C => KeyCode::KEY_SLASH, // kVK_ANSI_Slash + 0x2D => KeyCode::KEY_N, // kVK_ANSI_N + 0x2E => KeyCode::KEY_M, // kVK_ANSI_M + 0x2F => KeyCode::KEY_DOT, // kVK_ANSI_Period + 0x30 => KeyCode::KEY_TAB, // kVK_Tab + 0x31 => KeyCode::KEY_SPACE, // kVK_Space + 0x32 => KeyCode::KEY_GRAVE, // kVK_ANSI_Grave + 0x33 => KeyCode::KEY_BACKSPACE, // kVK_Delete (= Backspace on macOS) + 0x35 => KeyCode::KEY_ESC, // kVK_Escape + 0x60 => KeyCode::KEY_F5, // kVK_F5 + 0x61 => KeyCode::KEY_F6, // kVK_F6 + 0x62 => KeyCode::KEY_F7, // kVK_F7 + 0x63 => KeyCode::KEY_F3, // kVK_F3 + 0x64 => KeyCode::KEY_F8, // kVK_F8 + 0x65 => KeyCode::KEY_F9, // kVK_F9 + 0x67 => KeyCode::KEY_F11, // kVK_F11 + 0x6D => KeyCode::KEY_F10, // kVK_F10 + 0x6F => KeyCode::KEY_F12, // kVK_F12 + 0x76 => KeyCode::KEY_F4, // kVK_F4 + 0x78 => KeyCode::KEY_F2, // kVK_F2 + 0x7A => KeyCode::KEY_F1, // kVK_F1 + 0x73 => KeyCode::KEY_HOME, // kVK_Home + 0x77 => KeyCode::KEY_END, // kVK_End + 0x74 => KeyCode::KEY_PAGEUP, // kVK_PageUp + 0x79 => KeyCode::KEY_PAGEDOWN, // kVK_PageDown + 0x75 => KeyCode::KEY_DELETE, // kVK_ForwardDelete + 0x7B => KeyCode::KEY_LEFT, // kVK_LeftArrow + 0x7C => KeyCode::KEY_RIGHT, // kVK_RightArrow + 0x7D => KeyCode::KEY_DOWN, // kVK_DownArrow + 0x7E => KeyCode::KEY_UP, // kVK_UpArrow + _ => return None, + }) + } + + // ── D-Bus helpers ──────────────────────────────────────────────────────── + + static SESSION_BUS: LazyLock> = LazyLock::new(|| { + DbusConn::session() + .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}")) + .ok() + }); + + static SYSTEM_BUS: LazyLock> = LazyLock::new(|| { + DbusConn::system() + .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}")) + .ok() + }); + + /// Lock the screen via logind `LockSession($XDG_SESSION_ID)` on the system + /// bus, falling back to Super+L. + /// + /// Only the session identified by `$XDG_SESSION_ID` is locked; if the + /// variable is unset the D-Bus path is skipped entirely to avoid locking + /// all sessions on the machine. Super+L covers non-systemd systems and the + /// no-session-id case. + pub(super) fn lock_screen() { + if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) { + match conn.call_method( + Some("org.freedesktop.login1"), + "/org/freedesktop/login1", + Some("org.freedesktop.login1.Manager"), + "LockSession", + &(id.as_str(),), + ) { + Ok(_) => { + tracing::debug!("LockScreen via logind"); + return; + } + Err(e) => tracing::warn!("logind LockSession failed: {e}"), + } + } + // Super+L is the standard lock shortcut on GNOME and KDE. + tracing::debug!("LockScreen via Super+L key combo"); + press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L); + } + + /// Send `command` to the first MPRIS-capable media player on the session bus, + /// falling back to the corresponding XF86 multimedia key only if no MPRIS + /// player is found. When a player is found but the call fails, the fallback + /// is suppressed to avoid double-toggling (the player likely handles the + /// XF86 key too). + pub(super) fn mpris_command(command: &str) { + if try_mpris_command(command).is_none() { + let fallback = match command { + "PlayPause" => KeyCode::KEY_PLAYPAUSE, + "Next" => KeyCode::KEY_NEXTSONG, + "Previous" => KeyCode::KEY_PREVIOUSSONG, + _ => return, + }; + press_key(&[], fallback); + } + } + + fn try_mpris_command(command: &str) -> Option<()> { + let conn = SESSION_BUS.as_ref()?; + let reply = conn + .call_method( + Some("org.freedesktop.DBus"), + "/org/freedesktop/DBus", + Some("org.freedesktop.DBus"), + "ListNames", + &(), + ) + .ok()?; + let names = reply.body().deserialize::>().ok()?; + let Some(player) = names + .iter() + .find(|n| n.starts_with("org.mpris.MediaPlayer2.")) + else { + tracing::debug!("no MPRIS player found — {command} via XF86 key fallback"); + return None; + }; + match conn.call_method( + Some(player.as_str()), + "/org/mpris/MediaPlayer2", + Some("org.mpris.MediaPlayer2.Player"), + command, + &(), + ) { + Ok(_) => { + tracing::debug!("MPRIS {command} via {player}"); + Some(()) + } + Err(e) => { + // Player was identified — suppress XF86 fallback to avoid + // double-toggling if the player also handles multimedia keys. + tracing::warn!("MPRIS {command} on {player} failed: {e}"); + Some(()) + } + } + } +} + +/// Translate a macOS virtual key code (`kVK_*`, captured when a `CustomShortcut` +/// was recorded on macOS) to the equivalent Windows virtual-key code, so a chord +/// synced from a Mac fires the right key on Windows. +/// +/// Covers letters, digits, the ANSI punctuation keys, whitespace/editing keys, +/// navigation, and F1–F20 — every key a shortcut realistically uses. Modifier +/// keys are applied separately from `KeyCombo::modifiers`; the numeric keypad, +/// media, and volume keys are intentionally omitted (they are modifiers or +/// already have dedicated actions). `None` for an unmapped code, which +/// `post_custom_shortcut` warns-and-drops. +/// +/// Source codes: `` kVK_* constants. Targets: Win32 +/// virtual-key codes (letters/digits are their ASCII values; F1 = 0x70). +#[cfg_attr( + not(target_os = "windows"), + allow( + dead_code, + reason = "pure key-code table is exercised by host unit tests; its only runtime caller is the Windows-gated post_custom_shortcut" + ) +)] +fn mac_virtual_key_to_windows(key_code: u16) -> Option { + Some(match key_code { + // ── Letters (Windows VK_A..VK_Z = ASCII 'A'..'Z') ── + 0x00 => 0x41, // A + 0x0B => 0x42, // B + 0x08 => 0x43, // C + 0x02 => 0x44, // D + 0x0E => 0x45, // E + 0x03 => 0x46, // F + 0x05 => 0x47, // G + 0x04 => 0x48, // H + 0x22 => 0x49, // I + 0x26 => 0x4A, // J + 0x28 => 0x4B, // K + 0x25 => 0x4C, // L + 0x2E => 0x4D, // M + 0x2D => 0x4E, // N + 0x1F => 0x4F, // O + 0x23 => 0x50, // P + 0x0C => 0x51, // Q + 0x0F => 0x52, // R + 0x01 => 0x53, // S + 0x11 => 0x54, // T + 0x20 => 0x55, // U + 0x09 => 0x56, // V + 0x0D => 0x57, // W + 0x07 => 0x58, // X + 0x10 => 0x59, // Y + 0x06 => 0x5A, // Z + // ── Digits (Windows VK_0..VK_9 = ASCII '0'..'9') ── + 0x1D => 0x30, // 0 + 0x12 => 0x31, // 1 + 0x13 => 0x32, // 2 + 0x14 => 0x33, // 3 + 0x15 => 0x34, // 4 + 0x17 => 0x35, // 5 + 0x16 => 0x36, // 6 + 0x1A => 0x37, // 7 + 0x1C => 0x38, // 8 + 0x19 => 0x39, // 9 + // ── ANSI punctuation (Windows VK_OEM_*) ── + 0x1B => 0xBD, // - VK_OEM_MINUS + 0x18 => 0xBB, // = VK_OEM_PLUS + 0x21 => 0xDB, // [ VK_OEM_4 + 0x1E => 0xDD, // ] VK_OEM_6 + 0x2A => 0xDC, // \ VK_OEM_5 + 0x29 => 0xBA, // ; VK_OEM_1 + 0x27 => 0xDE, // ' VK_OEM_7 + 0x2B => 0xBC, // , VK_OEM_COMMA + 0x2F => 0xBE, // . VK_OEM_PERIOD + 0x2C => 0xBF, // / VK_OEM_2 + 0x32 => 0xC0, // ` VK_OEM_3 + // ── Whitespace / editing ── + 0x24 => 0x0D, // Return VK_RETURN + 0x30 => 0x09, // Tab VK_TAB + 0x31 => 0x20, // Space VK_SPACE + 0x33 => 0x08, // Backspace VK_BACK + 0x35 => 0x1B, // Escape VK_ESCAPE + // ── Navigation ── + 0x73 => 0x24, // Home VK_HOME + 0x77 => 0x23, // End VK_END + 0x74 => 0x21, // PageUp VK_PRIOR + 0x79 => 0x22, // PageDown VK_NEXT + 0x75 => 0x2E, // ForwardDelete VK_DELETE + 0x7B => 0x25, // LeftArrow VK_LEFT + 0x7C => 0x27, // RightArrow VK_RIGHT + 0x7D => 0x28, // DownArrow VK_DOWN + 0x7E => 0x26, // UpArrow VK_UP + // ── Function keys (Windows VK_F1 = 0x70, sequential through VK_F24) ── + 0x7A => 0x70, // F1 + 0x78 => 0x71, // F2 + 0x63 => 0x72, // F3 + 0x76 => 0x73, // F4 + 0x60 => 0x74, // F5 + 0x61 => 0x75, // F6 + 0x62 => 0x76, // F7 + 0x64 => 0x77, // F8 + 0x65 => 0x78, // F9 + 0x6D => 0x79, // F10 + 0x67 => 0x7A, // F11 + 0x6F => 0x7B, // F12 + 0x69 => 0x7C, // F13 + 0x6B => 0x7D, // F14 + 0x71 => 0x7E, // F15 + 0x6A => 0x7F, // F16 + 0x40 => 0x80, // F17 + 0x4F => 0x81, // F18 + 0x50 => 0x82, // F19 + 0x5A => 0x83, // F20 + _ => return None, + }) +} + +#[cfg(target_os = "windows")] +#[allow(unsafe_code, reason = "SendInput is the Win32 API for synthetic input")] +mod windows { + use std::mem::size_of; + + use windows_sys::Win32::UI::Input::KeyboardAndMouse::{ + INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP, + MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN, + MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL, + MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, SendInput, + }; + + use crate::binding::{Action, KeyCombo}; + + const WHEEL_DELTA: i32 = 120; + + pub(super) const VK_A: u16 = 0x41; + pub(super) const VK_C: u16 = 0x43; + pub(super) const VK_D: u16 = 0x44; + pub(super) const VK_F: u16 = 0x46; + pub(super) const VK_L: u16 = 0x4C; + pub(super) const VK_R: u16 = 0x52; + pub(super) const VK_S: u16 = 0x53; + pub(super) const VK_T: u16 = 0x54; + pub(super) const VK_V: u16 = 0x56; + pub(super) const VK_W: u16 = 0x57; + pub(super) const VK_X: u16 = 0x58; + pub(super) const VK_Y: u16 = 0x59; + pub(super) const VK_Z: u16 = 0x5A; + pub(super) const VK_TAB: u16 = 0x09; + pub(super) const VK_LEFT: u16 = 0x25; + pub(super) const VK_RIGHT: u16 = 0x27; + pub(super) const VK_SHIFT: u16 = 0x10; + pub(super) const VK_CONTROL: u16 = 0x11; + pub(super) const VK_MENU: u16 = 0x12; + pub(super) const VK_LWIN: u16 = 0x5B; + pub(super) const VK_BROWSER_BACK: u16 = 0xA6; + pub(super) const VK_BROWSER_FORWARD: u16 = 0xA7; + pub(super) const VK_VOLUME_MUTE: u16 = 0xAD; + pub(super) const VK_VOLUME_DOWN: u16 = 0xAE; + pub(super) const VK_VOLUME_UP: u16 = 0xAF; + pub(super) const VK_MEDIA_NEXT_TRACK: u16 = 0xB0; + pub(super) const VK_MEDIA_PREV_TRACK: u16 = 0xB1; + pub(super) const VK_MEDIA_PLAY_PAUSE: u16 = 0xB3; + + #[derive(Clone, Copy)] + pub(super) enum MouseButton { + Left, + Right, + Middle, + /// Extra button 4 ("back"). + Back, + /// Extra button 5 ("forward"). + Forward, + } + + // XBUTTON1/XBUTTON2 from WinUser.h — windows-sys puts them behind the + // Win32_UI_WindowsAndMessaging feature; not worth enabling for two + // integers (same treatment as the VK_* codes above). + const XBUTTON1: i32 = 1; + const XBUTTON2: i32 = 2; + + pub(super) fn post_click(button: MouseButton) { + let (down, up, data) = match button { + MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, 0), + MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, 0), + MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, 0), + // Extra buttons share the X flag pair; mouseData carries which one. + MouseButton::Back => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON1), + MouseButton::Forward => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON2), + }; + send_inputs(&[mouse_input(down, data), mouse_input(up, data)]); + } + + pub(super) fn post_key(vk: u16, modifiers: &[u16]) { + let mut inputs = Vec::with_capacity(modifiers.len() * 2 + 2); + for modifier in modifiers { + inputs.push(key_input(*modifier, false)); + } + inputs.push(key_input(vk, false)); + inputs.push(key_input(vk, true)); + for modifier in modifiers.iter().rev() { + inputs.push(key_input(*modifier, true)); + } + send_inputs(&inputs); + } + + pub(super) fn post_scroll(action: &Action) { + let (flags, data) = match action { + Action::ScrollUp => (MOUSEEVENTF_WHEEL, WHEEL_DELTA), + Action::ScrollDown => (MOUSEEVENTF_WHEEL, -WHEEL_DELTA), + Action::HorizontalScrollLeft => (MOUSEEVENTF_HWHEEL, -WHEEL_DELTA), + Action::HorizontalScrollRight => (MOUSEEVENTF_HWHEEL, WHEEL_DELTA), + _ => return, + }; + send_inputs(&[mouse_input(flags, data)]); + } + + pub(super) fn post_scroll_delta(v: i32, h: i32) { + let mut inputs = Vec::new(); + if v != 0 { + inputs.push(mouse_input( + MOUSEEVENTF_WHEEL, + v.saturating_mul(WHEEL_DELTA), + )); + } + if h != 0 { + inputs.push(mouse_input( + MOUSEEVENTF_HWHEEL, + h.saturating_mul(WHEEL_DELTA), + )); + } + send_inputs(&inputs); + } + + pub(super) fn post_horizontal_scroll(delta: i32) { + if delta == 0 { + return; + } + send_inputs(&[mouse_input( + MOUSEEVENTF_HWHEEL, + delta.saturating_mul(WHEEL_DELTA), + )]); + } + + pub(super) fn post_custom_shortcut(combo: &KeyCombo) { + if combo.key_code == 0 { + tracing::warn!( + chord = %combo.rendered_label(), + "CustomShortcut with no key code; press ignored" + ); + return; + } + let Some(vk) = super::mac_virtual_key_to_windows(combo.key_code) else { + tracing::warn!( + key_code = combo.key_code, + chord = %combo.rendered_label(), + "CustomShortcut key has no Windows mapping yet; press ignored" + ); + return; + }; + + let mut modifiers = Vec::new(); + if combo.modifiers & KeyCombo::MOD_CMD != 0 { + modifiers.push(VK_CONTROL); + } + if combo.modifiers & KeyCombo::MOD_SHIFT != 0 { + modifiers.push(VK_SHIFT); + } + if combo.modifiers & KeyCombo::MOD_CTRL != 0 && !modifiers.contains(&VK_CONTROL) { + modifiers.push(VK_CONTROL); + } + if combo.modifiers & KeyCombo::MOD_OPTION != 0 { + modifiers.push(VK_MENU); + } + post_key(vk, &modifiers); + } + + fn send_inputs(inputs: &[INPUT]) { + let Ok(input_count) = u32::try_from(inputs.len()) else { + tracing::warn!( + requested = inputs.len(), + "too many SendInput events requested" + ); + return; + }; + let Ok(input_size) = i32::try_from(size_of::()) else { + tracing::warn!("INPUT size does not fit the Win32 SendInput contract"); + return; + }; + let sent = unsafe { SendInput(input_count, inputs.as_ptr(), input_size) }; + if sent != input_count { + tracing::warn!( + requested = inputs.len(), + sent, + "SendInput accepted fewer events than requested" + ); + } + } + + fn key_input(vk: u16, key_up: bool) -> INPUT { + let mut flags = 0; + if key_up { + flags |= KEYEVENTF_KEYUP; + } + INPUT { + r#type: INPUT_KEYBOARD, + Anonymous: INPUT_0 { + ki: KEYBDINPUT { + wVk: vk, + wScan: 0, + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } + } + + fn mouse_input(flags: u32, data: i32) -> INPUT { + INPUT { + r#type: INPUT_MOUSE, + Anonymous: INPUT_0 { + mi: MOUSEINPUT { + dx: 0, + dy: 0, + mouseData: u32::from_ne_bytes(data.to_ne_bytes()), + dwFlags: flags, + time: 0, + dwExtraInfo: 0, + }, + }, + } + } +} + #[cfg(test)] #[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")] mod tests { @@ -947,6 +2541,21 @@ mod tests { } } + #[test] + fn custom_shortcut_keycodes_map_across_categories() { + // One representative per category, checked against independently-known + // (kVK → Win32 VK) facts, so a systematic error (swapped digits, + // off-by-one F-keys, a wrong OEM code) is caught without restating the + // whole table. + assert_eq!(mac_virtual_key_to_windows(0x00), Some(0x41)); // A → VK_A + assert_eq!(mac_virtual_key_to_windows(0x12), Some(0x31)); // 1 → VK_1 + assert_eq!(mac_virtual_key_to_windows(0x7A), Some(0x70)); // F1 → VK_F1 + assert_eq!(mac_virtual_key_to_windows(0x7B), Some(0x25)); // LeftArrow → VK_LEFT + assert_eq!(mac_virtual_key_to_windows(0x31), Some(0x20)); // Space → VK_SPACE + assert_eq!(mac_virtual_key_to_windows(0x29), Some(0xBA)); // ; → VK_OEM_1 + assert_eq!(mac_virtual_key_to_windows(0x37), None); // Command is a modifier, not a key + } + // ── Binding (merged model) serde routing ────────────────────────────────── /// On-disk shape: a `ButtonId` → [`Binding`] map, as `DeviceConfig.bindings` @@ -1335,6 +2944,18 @@ mod tests { assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll); } + #[test] + fn default_gesture_horizontal_swipes_switch_desktops() { + assert_eq!( + default_gesture_binding(GestureDirection::Left), + Action::PreviousDesktop + ); + assert_eq!( + default_gesture_binding(GestureDirection::Right), + Action::NextDesktop + ); + } + #[test] fn category_navigation_variants() { assert_eq!(Action::MissionControl.category(), Category::Navigation); @@ -1379,4 +3000,122 @@ mod tests { Action::CycleDpiPresets ); } + + // ── modifiers_to_keycodes ───────────────────────────────────────────────── + + #[cfg(target_os = "linux")] + mod modifier_mapping { + use evdev::KeyCode; + + use crate::binding::{KeyCombo, linux::modifiers_to_keycodes}; + + #[test] + fn mod_cmd_alone_maps_to_ctrl() { + assert_eq!( + modifiers_to_keycodes(KeyCombo::MOD_CMD), + vec![KeyCode::KEY_LEFTCTRL] + ); + } + + #[test] + fn mod_ctrl_alone_maps_to_ctrl() { + assert_eq!( + modifiers_to_keycodes(KeyCombo::MOD_CTRL), + vec![KeyCode::KEY_LEFTCTRL] + ); + } + + #[test] + fn mod_cmd_and_ctrl_together_produce_single_ctrl() { + // Both bits set must not push KEY_LEFTCTRL twice. + assert_eq!( + modifiers_to_keycodes(KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL), + vec![KeyCode::KEY_LEFTCTRL] + ); + } + + #[test] + fn all_modifiers_produce_canonical_order() { + let mods = modifiers_to_keycodes( + KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT | KeyCombo::MOD_OPTION, + ); + assert_eq!( + mods, + vec![ + KeyCode::KEY_LEFTCTRL, + KeyCode::KEY_LEFTSHIFT, + KeyCode::KEY_LEFTALT + ] + ); + } + + #[test] + fn no_modifiers_produces_empty_vec() { + assert!(modifiers_to_keycodes(0).is_empty()); + } + } + + // ── macos_vk_to_linux ──────────────────────────────────────────────────── + + #[cfg(target_os = "linux")] + mod vk_mapping { + use evdev::KeyCode; + + use crate::binding::linux::macos_vk_to_linux; + + #[test] + fn common_letters_map_correctly() { + assert_eq!(macos_vk_to_linux(0x08), Some(KeyCode::KEY_C)); // kVK_ANSI_C + assert_eq!(macos_vk_to_linux(0x09), Some(KeyCode::KEY_V)); // kVK_ANSI_V + assert_eq!(macos_vk_to_linux(0x07), Some(KeyCode::KEY_X)); // kVK_ANSI_X + assert_eq!(macos_vk_to_linux(0x00), Some(KeyCode::KEY_A)); // kVK_ANSI_A + assert_eq!(macos_vk_to_linux(0x06), Some(KeyCode::KEY_Z)); // kVK_ANSI_Z + assert_eq!(macos_vk_to_linux(0x0D), Some(KeyCode::KEY_W)); // kVK_ANSI_W + } + + #[test] + fn digits_map_correctly() { + assert_eq!(macos_vk_to_linux(0x12), Some(KeyCode::KEY_1)); // kVK_ANSI_1 + assert_eq!(macos_vk_to_linux(0x1D), Some(KeyCode::KEY_0)); // kVK_ANSI_0 + } + + #[test] + fn arrow_keys_map_correctly() { + assert_eq!(macos_vk_to_linux(0x7B), Some(KeyCode::KEY_LEFT)); + assert_eq!(macos_vk_to_linux(0x7C), Some(KeyCode::KEY_RIGHT)); + assert_eq!(macos_vk_to_linux(0x7D), Some(KeyCode::KEY_DOWN)); + assert_eq!(macos_vk_to_linux(0x7E), Some(KeyCode::KEY_UP)); + } + + #[test] + fn function_keys_map_correctly() { + assert_eq!(macos_vk_to_linux(0x7A), Some(KeyCode::KEY_F1)); // kVK_F1 + assert_eq!(macos_vk_to_linux(0x78), Some(KeyCode::KEY_F2)); // kVK_F2 + assert_eq!(macos_vk_to_linux(0x76), Some(KeyCode::KEY_F4)); // kVK_F4 + assert_eq!(macos_vk_to_linux(0x60), Some(KeyCode::KEY_F5)); // kVK_F5 + assert_eq!(macos_vk_to_linux(0x6F), Some(KeyCode::KEY_F12)); // kVK_F12 + } + + #[test] + fn nav_keys_map_correctly() { + assert_eq!(macos_vk_to_linux(0x73), Some(KeyCode::KEY_HOME)); + assert_eq!(macos_vk_to_linux(0x77), Some(KeyCode::KEY_END)); + assert_eq!(macos_vk_to_linux(0x74), Some(KeyCode::KEY_PAGEUP)); + assert_eq!(macos_vk_to_linux(0x79), Some(KeyCode::KEY_PAGEDOWN)); + assert_eq!(macos_vk_to_linux(0x75), Some(KeyCode::KEY_DELETE)); + } + + #[test] + fn brackets_follow_ansi_layout() { + // kVK_ANSI_LeftBracket=0x21 → KEY_LEFTBRACE, RightBracket=0x1E → KEY_RIGHTBRACE + assert_eq!(macos_vk_to_linux(0x21), Some(KeyCode::KEY_LEFTBRACE)); + assert_eq!(macos_vk_to_linux(0x1E), Some(KeyCode::KEY_RIGHTBRACE)); + } + + #[test] + fn unmapped_code_returns_none() { + assert_eq!(macos_vk_to_linux(0xFF), None); + assert_eq!(macos_vk_to_linux(0x34), None); // gap in the kVK table + } + } } diff --git a/crates/openlogi-core/src/config.rs b/crates/openlogi-core/src/config.rs index 72f70d56..9bd06222 100644 --- a/crates/openlogi-core/src/config.rs +++ b/crates/openlogi-core/src/config.rs @@ -88,6 +88,17 @@ pub struct AppSettings { /// user opt in on first launch. #[serde(default)] pub update_prompt_seen: bool, + /// Reverse the direction of captured scroll-wheel gestures. + #[serde(default)] + pub wheel_inverted: bool, + /// Scroll strength multiplier. `1` keeps the physical wheel's native feel; + /// larger values make each wheel tick travel farther. + #[serde(default = "default_scroll_strength")] + pub wheel_strength: u8, + /// Scroll tactility / chunk size. `0` or `1` keeps motion smooth; larger + /// values quantize emitted scroll into chunkier, more tactile steps. + #[serde(default)] + pub wheel_tactility: u8, /// Whether OpenLogi shows a macOS menu-bar (status item) icon. `true` /// (default) → it lives in the menu bar, dropping the Dock icon while no /// window is open; `false` → it stays an ordinary Dock app with no status @@ -142,6 +153,9 @@ impl Default for AppSettings { launch_at_login: false, check_for_updates: false, update_prompt_seen: false, + wheel_inverted: false, + wheel_strength: default_scroll_strength(), + wheel_tactility: 0, show_in_menu_bar: true, auto_download_assets: true, language: None, @@ -156,6 +170,11 @@ fn default_true() -> bool { true } +/// serde default for [`AppSettings::wheel_strength`]: keep native feel. +const fn default_scroll_strength() -> u8 { + 1 +} + /// serde default for [`AppSettings::thumbwheel_sensitivity`]: keeps configs /// predating the field at the 1× default. const fn default_thumbwheel_sensitivity() -> i32 { diff --git a/crates/openlogi-gui/src/state.rs b/crates/openlogi-gui/src/state.rs index 5fd7b055..5586251e 100644 --- a/crates/openlogi-gui/src/state.rs +++ b/crates/openlogi-gui/src/state.rs @@ -1096,6 +1096,32 @@ impl AppState { /// Set the thumb-wheel sensitivity (clamped to the valid range), publish it /// to the gesture watcher via the shared atomic, and persist it. No-op when /// unchanged. Disk failures are logged, not propagated. + pub fn set_wheel_inverted(&mut self, inverted: bool) { + if self.config.app_settings.wheel_inverted == inverted { + return; + } + self.config.app_settings.wheel_inverted = inverted; + self.persist_and_reload("wheel inversion"); + } + + pub fn set_wheel_strength(&mut self, strength: u8) { + let strength = strength.clamp(1, 10); + if self.config.app_settings.wheel_strength == strength { + return; + } + self.config.app_settings.wheel_strength = strength; + self.persist_and_reload("wheel strength"); + } + + pub fn set_wheel_tactility(&mut self, tactility: u8) { + let tactility = tactility.min(10); + if self.config.app_settings.wheel_tactility == tactility { + return; + } + self.config.app_settings.wheel_tactility = tactility; + self.persist_and_reload("wheel tactility"); + } + pub fn set_thumbwheel_sensitivity(&mut self, sensitivity: i32) { let sensitivity = sensitivity.clamp( openlogi_core::config::MIN_THUMBWHEEL_SENSITIVITY, diff --git a/crates/openlogi-gui/src/windows/settings.rs b/crates/openlogi-gui/src/windows/settings.rs index 08222016..f9a0a572 100644 --- a/crates/openlogi-gui/src/windows/settings.rs +++ b/crates/openlogi-gui/src/windows/settings.rs @@ -26,6 +26,12 @@ use openlogi_core::config::{ DEFAULT_THUMBWHEEL_SENSITIVITY, MAX_THUMBWHEEL_SENSITIVITY, MIN_THUMBWHEEL_SENSITIVITY, }; +const DEFAULT_WHEEL_STRENGTH: u8 = 1; +const MIN_WHEEL_STRENGTH: u8 = 1; +const MAX_WHEEL_STRENGTH: u8 = 10; +const DEFAULT_WHEEL_TACTILITY: u8 = 0; +const MAX_WHEEL_TACTILITY: u8 = 10; + use crate::app_menu::{CloseWindow, Minimize, Zoom}; #[cfg(target_os = "macos")] use crate::platform::permissions::Permission; @@ -42,6 +48,8 @@ pub struct SettingsView { appearance_obs: Option, language_select: Entity>>, sensitivity_slider: Entity, + wheel_strength_slider: Entity, + wheel_tactility_slider: Entity, /// Asset-cache size blurb, computed once when the window opens rather than /// re-walking the cache on every render. A snapshot — reopen to refresh /// after a Clear. @@ -77,10 +85,48 @@ impl SettingsView { cx.subscribe_in(&sensitivity_slider, window, Self::on_sensitivity_slider) .detach(); + let wheel_strength = cx + .try_global::() + .map_or(DEFAULT_WHEEL_STRENGTH, |s| { + s.app_settings().wheel_strength.max(1) + }); + let wheel_strength_slider = cx.new(|_| { + SliderState::new() + .min(f32::from(MIN_WHEEL_STRENGTH)) + .max(f32::from(MAX_WHEEL_STRENGTH)) + .default_value(f32::from(wheel_strength)) + }); + cx.subscribe_in( + &wheel_strength_slider, + window, + Self::on_wheel_strength_slider, + ) + .detach(); + + let wheel_tactility = cx + .try_global::() + .map_or(DEFAULT_WHEEL_TACTILITY, |s| { + s.app_settings().wheel_tactility + }); + let wheel_tactility_slider = cx.new(|_| { + SliderState::new() + .min(0.0) + .max(f32::from(MAX_WHEEL_TACTILITY)) + .default_value(f32::from(wheel_tactility)) + }); + cx.subscribe_in( + &wheel_tactility_slider, + window, + Self::on_wheel_tactility_slider, + ) + .detach(); + Self { appearance_obs: None, language_select, sensitivity_slider, + wheel_strength_slider, + wheel_tactility_slider, asset_cache_desc: cache_size_description(), } } @@ -111,6 +157,52 @@ impl SettingsView { cx.notify(); } + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "slider values are small stepped integer ranges" + )] + #[allow( + clippy::unused_self, + reason = "gpui subscription handlers must take &mut self" + )] + fn on_wheel_strength_slider( + &mut self, + _: &Entity, + event: &SliderEvent, + _: &mut Window, + cx: &mut Context, + ) { + if let SliderEvent::Release(value) = event { + let strength = value.start().round() as u8; + cx.update_global::(|s, _| s.set_wheel_strength(strength)); + } + cx.notify(); + } + + #[allow( + clippy::cast_possible_truncation, + clippy::cast_sign_loss, + reason = "slider values are small stepped integer ranges" + )] + #[allow( + clippy::unused_self, + reason = "gpui subscription handlers must take &mut self" + )] + fn on_wheel_tactility_slider( + &mut self, + _: &Entity, + event: &SliderEvent, + _: &mut Window, + cx: &mut Context, + ) { + if let SliderEvent::Release(value) = event { + let tactility = value.start().round() as u8; + cx.update_global::(|s, _| s.set_wheel_tactility(tactility)); + } + cx.notify(); + } + fn on_language_select( &mut self, _: &Entity>>, @@ -162,7 +254,11 @@ impl Render for SettingsView { .child( Settings::new("settings") .sidebar_width(px(210.)) - .page(general_page(self.sensitivity_slider.clone())) + .page(general_page( + self.sensitivity_slider.clone(), + self.wheel_strength_slider.clone(), + self.wheel_tactility_slider.clone(), + )) .page(permissions_page(pal)) .page(assets_page(pal, self.asset_cache_desc.clone())) .page(language_page(self.language_select.clone())), @@ -170,7 +266,11 @@ impl Render for SettingsView { } } -fn general_page(sensitivity_slider: Entity) -> SettingPage { +fn general_page( + sensitivity_slider: Entity, + wheel_strength_slider: Entity, + wheel_tactility_slider: Entity, +) -> SettingPage { let group = SettingGroup::new() .item( SettingItem::new( @@ -183,6 +283,44 @@ fn general_page(sensitivity_slider: Entity) -> SettingPage { "Scales the thumb wheel's horizontal scroll speed and how readily custom wheel actions trigger." )), ) + .item( + SettingItem::new( + tr!("Invert wheel direction"), + SettingField::switch( + |cx| { + cx.try_global::() + .is_some_and(|s| s.app_settings().wheel_inverted) + }, + |enabled, cx| { + cx.update_global::(move |s, _| { + s.set_wheel_inverted(enabled); + }); + cx.refresh_windows(); + }, + ), + ) + .description(tr!( + "Reverse captured vertical and horizontal wheel scrolling." + )), + ) + .item( + SettingItem::new( + tr!("Wheel strength"), + SettingField::render(move |_, _, cx| { + wheel_slider_field(&wheel_strength_slider, DEFAULT_WHEEL_STRENGTH, cx) + }), + ) + .description(tr!("Multiplies captured wheel deltas before re-emitting them.")), + ) + .item( + SettingItem::new( + tr!("Wheel tactility"), + SettingField::render(move |_, _, cx| { + wheel_slider_field(&wheel_tactility_slider, DEFAULT_WHEEL_TACTILITY, cx) + }), + ) + .description(tr!("Quantizes captured wheel deltas into chunkier steps. 0 keeps smooth scrolling.")), + ) .item( SettingItem::new( tr!("Launch at login"), @@ -612,9 +750,23 @@ fn language_select_field( clippy::cast_sign_loss, reason = "slider value is a stepped 1..=100 figure" )] +fn wheel_slider_field(slider: &Entity, default_value: u8, cx: &mut App) -> AnyElement { + let value = slider.read(cx).value().start().round() as u8; + slider_value_field(slider, value.to_string(), value == default_value, cx) +} + fn sensitivity_field(slider: &Entity, cx: &mut App) -> AnyElement { let value = slider.read(cx).value().start().round() as i32; let is_default = value == DEFAULT_THUMBWHEEL_SENSITIVITY; + slider_value_field(slider, value.to_string(), is_default, cx) +} + +fn slider_value_field( + slider: &Entity, + label: String, + is_default: bool, + cx: &mut App, +) -> AnyElement { let pal = theme::palette(cx); v_flex() .flex_shrink_0() @@ -629,7 +781,7 @@ fn sensitivity_field(slider: &Entity, cx: &mut App) -> AnyElement { .w(px(72.)) .text_sm() .text_color(pal.text_muted) - .child(value.to_string()), + .child(label), ), ) .when(is_default, |this| { diff --git a/crates/openlogi-hook/src/lib.rs b/crates/openlogi-hook/src/lib.rs index 75d31740..3e8e8393 100644 --- a/crates/openlogi-hook/src/lib.rs +++ b/crates/openlogi-hook/src/lib.rs @@ -43,6 +43,11 @@ pub enum MouseEvent { delta_x: f32, /// Positive = down, negative = up. delta_y: f32, + /// `true` for continuous/pixel scroll devices such as trackpads. + /// + /// macOS uses this to keep mouse-wheel transforms from touching the + /// built-in trackpad's natural scrolling stream. + is_continuous: bool, }, /// Pointer movement, in device units. Emitted so a held gesture button can /// accumulate a swipe; the callback passes these through (the cursor keeps @@ -68,6 +73,24 @@ pub enum EventDisposition { PassThrough, /// Drop the event; the target application never sees it. Suppress, + /// Mutate a captured scroll event in place and let it continue. + /// + /// Only macOS can rewrite the original `CGEvent` in the tap callback. Other + /// platform hooks treat this as pass-through, and callers should only return + /// it when handling a `MouseEvent::Scroll` they know can be transformed. + TransformScroll(ScrollTransform), +} + +/// In-place scroll-wheel transform requested by the hook runtime. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ScrollTransform { + /// Negate scroll axes. + pub inverted: bool, + /// Multiplier applied to line, pixel, and fixed-point scroll fields. + pub strength: u8, + /// Optional line-delta chunking strength. Pixel/fixed fields keep their + /// native precision and are only scaled/inverted. + pub tactility: u8, } /// Errors that [`Hook::start`] and related functions can produce. diff --git a/crates/openlogi-hook/src/linux.rs b/crates/openlogi-hook/src/linux.rs index a36913bc..dc7ec6cf 100644 --- a/crates/openlogi-hook/src/linux.rs +++ b/crates/openlogi-hook/src/linux.rs @@ -211,7 +211,11 @@ fn wait_readable(device_fd: i32, stop_fd: i32) -> bool { } fn scroll(delta_x: f32, delta_y: f32) -> MouseEvent { - MouseEvent::Scroll { delta_x, delta_y } + MouseEvent::Scroll { + delta_x, + delta_y, + is_continuous: false, + } } fn translate(event: &evdev::InputEvent, hires_scroll: bool) -> Option { @@ -366,7 +370,7 @@ fn device_thread( } None => EventDisposition::PassThrough, }; - if matches!(disposition, EventDisposition::PassThrough) { + if !matches!(disposition, EventDisposition::Suppress) { pending.push(event); } } @@ -575,7 +579,7 @@ mod tests { let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_WHEEL.0, 3); let result = translate(&event, false); assert!( - matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y }) + matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y, .. }) if delta_x.abs() < f32::EPSILON && (delta_y - 3.0).abs() < f32::EPSILON), "expected Scroll {{ delta_x: 0.0, delta_y: 3.0 }}, got {result:?}" ); @@ -586,7 +590,7 @@ mod tests { let event = InputEvent::new(EventType::RELATIVE.0, RelativeAxisCode::REL_HWHEEL.0, -2); let result = translate(&event, false); assert!( - matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y }) + matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y, .. }) if (delta_x - -2.0).abs() < f32::EPSILON && delta_y.abs() < f32::EPSILON), "expected Scroll {{ delta_x: -2.0, delta_y: 0.0 }}, got {result:?}" ); @@ -604,7 +608,7 @@ mod tests { ); let result = translate(&event, true); assert!( - matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y }) + matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y, .. }) if delta_x.abs() < f32::EPSILON && (delta_y - 0.5).abs() < f32::EPSILON), "expected Scroll {{ delta_x: 0.0, delta_y: 0.5 }}, got {result:?}" ); @@ -619,7 +623,7 @@ mod tests { ); let result = translate(&event, true); assert!( - matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y }) + matches!(result, Some(MouseEvent::Scroll { delta_x, delta_y, .. }) if (delta_x - -1.0).abs() < f32::EPSILON && delta_y.abs() < f32::EPSILON), "expected Scroll {{ delta_x: -1.0, delta_y: 0.0 }}, got {result:?}" ); diff --git a/crates/openlogi-hook/src/macos.rs b/crates/openlogi-hook/src/macos.rs index de79ca14..8f2ea94c 100644 --- a/crates/openlogi-hook/src/macos.rs +++ b/crates/openlogi-hook/src/macos.rs @@ -12,7 +12,7 @@ use core_graphics::event::{ }; use tracing::{debug, error, warn}; -use crate::{ButtonId, EventDisposition, HookError, MouseEvent}; +use crate::{ButtonId, EventDisposition, HookError, MouseEvent, ScrollTransform}; /// Everything `Hook` needs to control the background thread. pub(crate) struct HookInner { @@ -100,6 +100,92 @@ fn button_number_to_id(n: i64) -> Option { } } +fn transform_scroll_event(event: &CGEvent, transform: ScrollTransform) { + let factor = if transform.inverted { -1.0 } else { 1.0 } * f64::from(transform.strength.max(1)); + let tactility = i64::from(transform.tactility.min(10)); + + transform_double_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1, + factor, + tactility, + ); + transform_double_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2, + factor, + tactility, + ); + transform_integer_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_1, + factor, + 0, + ); + transform_integer_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_POINT_DELTA_AXIS_2, + factor, + 0, + ); + transform_integer_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_FIXED_POINT_DELTA_AXIS_1, + factor, + 0, + ); + transform_integer_scroll_field( + event, + EventField::SCROLL_WHEEL_EVENT_FIXED_POINT_DELTA_AXIS_2, + factor, + 0, + ); +} + +fn transform_double_scroll_field( + event: &CGEvent, + field: core_graphics::event::CGEventField, + factor: f64, + tactility: i64, +) { + let value = event.get_double_value_field(field) * factor; + let value = if tactility <= 1 { + value + } else { + quantize_scroll(value, tactility) as f64 + }; + event.set_double_value_field(field, value); +} + +fn transform_integer_scroll_field( + event: &CGEvent, + field: core_graphics::event::CGEventField, + factor: f64, + tactility: i64, +) { + let value = (event.get_integer_value_field(field) as f64 * factor).round() as i64; + let value = if tactility <= 1 { + value + } else { + quantize_scroll(value as f64, tactility) + }; + event.set_integer_value_field(field, value); +} + +fn quantize_scroll(value: f64, tactility: i64) -> i64 { + let v = value.round() as i64; + if tactility <= 1 { + return v; + } + + let abs = v.abs(); + let snapped = ((abs + tactility / 2) / tactility) * tactility; + if snapped == 0 && v != 0 { + return v.signum() * tactility; + } + if v < 0 { -snapped } else { snapped } +} + /// Convert a `CGEvent` to our [`MouseEvent`] vocabulary. Returns `None` /// for event types we don't translate (e.g. move events, unknown buttons). fn translate(etype: CGEventType, event: &CGEvent) -> Option { @@ -153,6 +239,8 @@ fn translate(etype: CGEventType, event: &CGEvent) -> Option { // axis 1 = vertical scroll; axis 2 = horizontal scroll. let dy = event.get_double_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_1); let dx = event.get_double_value_field(EventField::SCROLL_WHEEL_EVENT_DELTA_AXIS_2); + let is_continuous = + event.get_integer_value_field(EventField::SCROLL_WHEEL_EVENT_IS_CONTINUOUS) != 0; #[allow( clippy::cast_possible_truncation, reason = "scroll deltas are small fractional values that fit comfortably in f32" @@ -160,6 +248,7 @@ fn translate(etype: CGEventType, event: &CGEvent) -> Option { Some(MouseEvent::Scroll { delta_x: dx as f32, delta_y: dy as f32, + is_continuous, }) } // Pointer movement feeds gesture-button swipe detection. While a button @@ -265,6 +354,10 @@ fn thread_main( match cb(mouse_event) { EventDisposition::PassThrough => CallbackResult::Keep, EventDisposition::Suppress => CallbackResult::Drop, + EventDisposition::TransformScroll(transform) => { + transform_scroll_event(event, transform); + CallbackResult::Keep + } } }, ); diff --git a/crates/openlogi-hook/src/tests.rs b/crates/openlogi-hook/src/tests.rs index f21947a0..d980348d 100644 --- a/crates/openlogi-hook/src/tests.rs +++ b/crates/openlogi-hook/src/tests.rs @@ -30,6 +30,7 @@ fn mouse_event_clone_and_debug() { MouseEvent::Scroll { delta_x: 1.0, delta_y: -1.5, + is_continuous: false, }, MouseEvent::Moved { delta_x: 3, diff --git a/crates/openlogi-hook/src/windows.rs b/crates/openlogi-hook/src/windows.rs index c8a77a66..b2fae3c1 100644 --- a/crates/openlogi-hook/src/windows.rs +++ b/crates/openlogi-hook/src/windows.rs @@ -257,10 +257,12 @@ fn translate_event(wparam: WPARAM, data: MSLLHOOKSTRUCT) -> Option { WM_MOUSEWHEEL => Some(MouseEvent::Scroll { delta_x: 0.0, delta_y: f32::from(signed_high_word(data.mouseData)) / WHEEL_DELTA, + is_continuous: false, }), WM_MOUSEHWHEEL => Some(MouseEvent::Scroll { delta_x: f32::from(signed_high_word(data.mouseData)) / WHEEL_DELTA, delta_y: 0.0, + is_continuous: false, }), _ => None, } @@ -349,8 +351,9 @@ mod tests { mouseData: 120u32 << 16, ..MSLLHOOKSTRUCT::default() }; - let Some(MouseEvent::Scroll { delta_x, delta_y }) = - translate_event(WM_MOUSEWHEEL as WPARAM, forward) + let Some(MouseEvent::Scroll { + delta_x, delta_y, .. + }) = translate_event(WM_MOUSEWHEEL as WPARAM, forward) else { panic!("expected a scroll event"); };