Skip to main content

openlogi_core/
binding.rs

1//! Logical mouse button identifiers and the action vocabulary each one can
2//! bind to. Lives in `openlogi-core` because the [`config`](crate::config)
3//! schema serializes these directly — the GUI re-exports them.
4//!
5//! When [`Action`] gains new variants, keep the existing variant names stable:
6//! the TOML config keys/values use the enum variant identifiers verbatim, so
7//! renames are migration events.
8
9use std::fmt;
10
11use serde::{Deserialize, Serialize};
12
13/// One of the user-rebindable hotspots on a Logi mouse. The order matches the
14/// physical layout from front to side; [`ButtonId::ALL`] is consumed by the
15/// default-binding generator and the popover trigger list.
16#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
17pub enum ButtonId {
18    LeftClick,
19    RightClick,
20    MiddleClick,
21    Back,
22    Forward,
23    /// The "ModeShift" button under the wheel — typically used for SmartShift /
24    /// DPI cycle. Named `DpiToggle` for historical reasons.
25    DpiToggle,
26    /// The horizontal thumb wheel's click. Kept in [`ButtonId::ALL`] so its
27    /// default still seeds and dispatches when the wheel is diverted, even
28    /// though the mouse model surfaces the two rotation directions instead of
29    /// the click (see `mouse_model::geometry`).
30    Thumbwheel,
31    /// Rotating the thumb wheel "up" (positive rotation). Bound, by default, to
32    /// continuous horizontal scroll; see [`crate::watchers`]-side dispatch.
33    ThumbwheelScrollUp,
34    /// Rotating the thumb wheel "down" (negative rotation).
35    ThumbwheelScrollDown,
36    /// The thumb-pad gesture button on MX-line devices. The press itself
37    /// fires the bound action; swipe directions are P1.5 territory.
38    GestureButton,
39}
40
41impl ButtonId {
42    pub const ALL: [ButtonId; 10] = [
43        ButtonId::LeftClick,
44        ButtonId::RightClick,
45        ButtonId::MiddleClick,
46        ButtonId::Back,
47        ButtonId::Forward,
48        ButtonId::DpiToggle,
49        ButtonId::Thumbwheel,
50        ButtonId::ThumbwheelScrollUp,
51        ButtonId::ThumbwheelScrollDown,
52        ButtonId::GestureButton,
53    ];
54
55    /// Human-readable label for popovers and tooltips.
56    #[must_use]
57    pub fn label(self) -> &'static str {
58        match self {
59            ButtonId::LeftClick => "Left Click",
60            ButtonId::RightClick => "Right Click",
61            ButtonId::MiddleClick => "Middle Click",
62            ButtonId::Back => "Back",
63            ButtonId::Forward => "Forward",
64            ButtonId::DpiToggle => "DPI Toggle",
65            ButtonId::Thumbwheel => "Thumb Wheel",
66            ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
67            ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
68            ButtonId::GestureButton => "Gesture Button",
69        }
70    }
71}
72
73impl fmt::Display for ButtonId {
74    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
75        f.write_str(self.label())
76    }
77}
78
79/// One of the five sub-bindings on the gesture button: hold + swipe up/down/
80/// left/right or a plain click without movement. Logi ships these as
81/// independent assignments (`SLOT_NAME_GESTURE_*_BUTTON` in the
82/// `device_gesture_buttons_image` metadata block) — OpenLogi mirrors the
83/// same shape.
84///
85/// Variant identifiers are TOML-stable: renames are migration events.
86#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
87pub enum GestureDirection {
88    Up,
89    Down,
90    Left,
91    Right,
92    Click,
93}
94
95impl GestureDirection {
96    pub const ALL: [GestureDirection; 5] = [
97        GestureDirection::Up,
98        GestureDirection::Down,
99        GestureDirection::Left,
100        GestureDirection::Right,
101        GestureDirection::Click,
102    ];
103
104    #[must_use]
105    pub fn label(self) -> &'static str {
106        match self {
107            GestureDirection::Up => "Up",
108            GestureDirection::Down => "Down",
109            GestureDirection::Left => "Left",
110            GestureDirection::Right => "Right",
111            GestureDirection::Click => "Click",
112        }
113    }
114
115    /// Arrow glyph for compact list rendering.
116    #[must_use]
117    pub fn glyph(self) -> &'static str {
118        match self {
119            GestureDirection::Up => "↑",
120            GestureDirection::Down => "↓",
121            GestureDirection::Left => "←",
122            GestureDirection::Right => "→",
123            GestureDirection::Click => "·",
124        }
125    }
126}
127
128impl fmt::Display for GestureDirection {
129    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
130        f.write_str(self.label())
131    }
132}
133
134/// Minimum dominant-axis travel (raw-XY units) before a held gesture commits to
135/// a direction. Tuned to match Logitech Options+'s responsiveness.
136pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
137/// Maximum cross-axis travel allowed at the threshold, so only a reasonably
138/// straight swipe commits. Grows with the dominant axis (`max(deadzone, 35%)`).
139pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
140
141/// Classify the *running* raw-XY travel of a held gesture button into a
142/// directional swipe, the instant it commits — or `None` while it's still too
143/// short or too diagonal.
144///
145/// The dominant axis must pass [`GESTURE_SWIPE_THRESHOLD`] while the cross axis
146/// stays within `max(`[`GESTURE_SWIPE_DEADZONE`]`, 35% of dominant)`. Callers
147/// fire the bound action the moment this returns `Some` — mid-swipe, like
148/// Options+ — rather than waiting for the button release; a press that never
149/// commits a direction is treated as [`GestureDirection::Click`] on release.
150///
151/// Coordinates follow the device's raw-XY convention (`+x` = right, `+y` =
152/// down), so an upward swipe (negative `dy`) maps to [`GestureDirection::Up`].
153#[must_use]
154pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
155    let (abs_x, abs_y) = (dx.abs(), dy.abs());
156    let dominant = abs_x.max(abs_y);
157    if dominant < GESTURE_SWIPE_THRESHOLD {
158        return None;
159    }
160    let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant * 35 / 100);
161    if abs_x > abs_y {
162        if abs_y > cross_limit {
163            return None;
164        }
165        Some(if dx > 0 {
166            GestureDirection::Right
167        } else {
168            GestureDirection::Left
169        })
170    } else {
171        if abs_x > cross_limit {
172            return None;
173        }
174        Some(if dy > 0 {
175            GestureDirection::Down
176        } else {
177            GestureDirection::Up
178        })
179    }
180}
181
182/// Grouping for popover section headers.
183///
184/// Used by [`Action::category`] and rendered as a small muted label above
185/// each group in the action picker.
186#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
187pub enum Category {
188    /// Cut, copy, paste, undo, redo, select-all, find, save.
189    Editing,
190    /// Browser navigation: tabs, page reload, back/forward.
191    Browser,
192    /// Playback and volume controls.
193    Media,
194    /// Physical mouse clicks.
195    Mouse,
196    /// DPI cycle and SmartShift.
197    Dpi,
198    /// Scroll direction shortcuts.
199    Scroll,
200    /// Window/app navigation: Mission Control, Launchpad, etc.
201    Navigation,
202    /// Lock screen, show desktop, system-level actions.
203    System,
204}
205
206impl Category {
207    /// Short label for popover section headers (already uppercase so callers
208    /// don't have to transform it).
209    #[must_use]
210    pub fn label(self) -> &'static str {
211        match self {
212            Category::Editing => "EDITING",
213            Category::Browser => "BROWSER",
214            Category::Media => "MEDIA",
215            Category::Mouse => "MOUSE",
216            Category::Dpi => "DPI",
217            Category::Scroll => "SCROLL",
218            Category::Navigation => "NAVIGATION",
219            Category::System => "SYSTEM",
220        }
221    }
222}
223
224/// What pressing a [`ButtonId`] should do.
225///
226/// Serialization uses serde's default external tagging: unit variants
227/// serialize as a bare string (`"BrowserBack"`) and the tuple variant
228/// serializes as a single-key table (`{ CustomShortcut = "my chord" }`).
229///
230/// **Stability contract:** existing variant *names* are frozen — they form the
231/// on-disk `config.toml` schema. New variants may be appended freely; removing
232/// or renaming a variant requires a `schema_version` bump and a migration.
233///
234/// `Action::execute` synthesizes the OS-level event for each variant.
235/// On macOS it posts the event via `CGEventPost(kCGHIDEventTap, …)`.
236/// On other platforms it logs a warning and returns immediately — the binary
237/// compiles on all targets.
238///
239/// # Manual verification
240///
241/// `execute` is intentionally excluded from the automated test suite because
242/// it would need to intercept the OS event queue. Smoke-test it manually:
243/// bind a button to any action in the GUI and confirm the expected system event
244/// fires when the button is pressed.
245#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
246pub enum Action {
247    // ── System ───────────────────────────────────────────────────────────────
248    /// Suppress the input entirely — the button or wheel direction is captured
249    /// but no OS event is synthesised, so the physical input does nothing.
250    None,
251
252    // ── Mouse ────────────────────────────────────────────────────────────────
253    /// Primary mouse button.
254    LeftClick,
255    /// Secondary mouse button.
256    RightClick,
257    /// Middle mouse button (wheel click).
258    MiddleClick,
259
260    // ── Editing ──────────────────────────────────────────────────────────────
261    /// Copy the current selection (⌘C / Ctrl+C).
262    Copy,
263    /// Paste from the clipboard (⌘V / Ctrl+V).
264    Paste,
265    /// Cut the current selection (⌘X / Ctrl+X).
266    Cut,
267    /// Undo the last action (⌘Z / Ctrl+Z).
268    Undo,
269    /// Redo the last undone action (⌘⇧Z on macOS / Ctrl+Shift+Z on Linux).
270    ///
271    /// Note: Ctrl+Y is the dominant redo shortcut in LibreOffice and many GTK
272    /// apps. Ctrl+Shift+Z is used here because it mirrors the macOS convention
273    /// and works in GNOME text fields, browsers, and Electron apps. If Ctrl+Y
274    /// coverage is needed, a `CustomShortcut` binding is the escape hatch.
275    Redo,
276    /// Select all content (⌘A / Ctrl+A).
277    SelectAll,
278    /// Open the find / search bar (⌘F / Ctrl+F).
279    Find,
280    /// Save the current document (⌘S / Ctrl+S).
281    Save,
282
283    // ── Browser / Navigation ──────────────────────────────────────────────────
284    /// Navigate backward in browser history.
285    BrowserBack,
286    /// Navigate forward in browser history.
287    BrowserForward,
288    /// Open a new tab (⌘T / Ctrl+T).
289    NewTab,
290    /// Close the current tab (⌘W / Ctrl+W).
291    CloseTab,
292    /// Reopen the last closed tab (⌘⇧T / Ctrl+Shift+T).
293    ReopenTab,
294    /// Switch to the next tab (⌃⇥ / Ctrl+Tab).
295    NextTab,
296    /// Switch to the previous tab (⌃⇧⇥ / Ctrl+Shift+Tab).
297    PrevTab,
298    /// Reload the current page (⌘R / Ctrl+R).
299    ReloadPage,
300
301    // ── Navigation / Window ───────────────────────────────────────────────────
302    /// macOS Mission Control (⌃↑).
303    MissionControl,
304    /// macOS App Exposé — all windows for the current app (⌃↓).
305    AppExpose,
306    /// Switch to the previous desktop / Space.
307    PreviousDesktop,
308    /// Switch to the next desktop / Space.
309    NextDesktop,
310    /// Show the desktop (hide all windows).
311    ShowDesktop,
312    /// Open Launchpad.
313    LaunchpadShow,
314
315    // ── System ────────────────────────────────────────────────────────────────
316    /// Lock the screen (⌘⌃Q on macOS).
317    ///
318    /// On Linux, calls `org.freedesktop.login1.Manager.LockSession($XDG_SESSION_ID)`
319    /// on the system bus (current session only). Falls back to Super+L when
320    /// `$XDG_SESSION_ID` is unset or on non-systemd systems.
321    LockScreen,
322    /// Capture a screenshot.
323    Screenshot,
324
325    // ── Media ────────────────────────────────────────────────────────────────
326    /// Toggle media play/pause.
327    PlayPause,
328    /// Skip to the next track.
329    NextTrack,
330    /// Go back to the previous track.
331    PrevTrack,
332    /// Increase system volume.
333    VolumeUp,
334    /// Decrease system volume.
335    VolumeDown,
336    /// Toggle system mute.
337    MuteVolume,
338
339    // ── DPI ──────────────────────────────────────────────────────────────────
340    /// Step through the configured DPI preset list (P1.7).
341    CycleDpiPresets,
342    /// Jump to a specific zero-based preset in the device's DPI preset list.
343    /// Out-of-range indices clamp to the list length at fire time (P1.7).
344    SetDpiPreset(u8),
345    /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
346    ToggleSmartShift,
347
348    // ── Scroll ───────────────────────────────────────────────────────────────
349    /// Synthesise a vertical scroll-up tick.
350    ScrollUp,
351    /// Synthesise a vertical scroll-down tick.
352    ScrollDown,
353    /// Synthesise a horizontal scroll-left tick.
354    HorizontalScrollLeft,
355    /// Synthesise a horizontal scroll-right tick.
356    HorizontalScrollRight,
357
358    // ── Custom ───────────────────────────────────────────────────────────────
359    /// Replay an arbitrary recorded key chord (P1.3).
360    ///
361    /// Holds the structured chord data so `execute` can post the real
362    /// keystroke (macOS: CGEventPost with the encoded modifier flags).
363    /// The `display` field is used by [`Action::label`] so the popover
364    /// shows the user-friendly chord name.
365    CustomShortcut(KeyCombo),
366}
367
368/// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or
369/// hand-authored in `config.toml`.
370///
371/// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format
372/// is a compact integer, not a string. `key_code` is the macOS virtual key
373/// (`kVK_*`); on Linux, `Action::execute` maps it to an evdev `KeyCode` via
374/// `linux::macos_vk_to_linux`.
375///
376/// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it
377/// from the captured chord; we keep it in the struct so older configs
378/// continue to render the same label without re-deriving on every load.
379#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
380pub struct KeyCombo {
381    /// Bitmask of [`Self::MOD_CMD`] etc.
382    pub modifiers: u8,
383    /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for
384    /// modifier-only placeholders that the recorder UI rejects. On Linux,
385    /// `Action::execute` translates this to an evdev `KeyCode`.
386    pub key_code: u16,
387    /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a
388    /// generated label at runtime.
389    #[serde(default)]
390    pub display: String,
391}
392
393impl KeyCombo {
394    pub const MOD_CMD: u8 = 1 << 0;
395    pub const MOD_SHIFT: u8 = 1 << 1;
396    pub const MOD_CTRL: u8 = 1 << 2;
397    pub const MOD_OPTION: u8 = 1 << 3;
398
399    /// Build the human-readable label from the modifier bitmask + key code.
400    /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the
401    /// commonly-recognised letters; the recorder UI usually overrides this
402    /// with its own derivation.
403    #[must_use]
404    pub fn rendered_label(&self) -> String {
405        if !self.display.is_empty() {
406            return self.display.clone();
407        }
408        let mut out = String::new();
409        if self.modifiers & Self::MOD_CTRL != 0 {
410            out.push('⌃');
411        }
412        if self.modifiers & Self::MOD_OPTION != 0 {
413            out.push('⌥');
414        }
415        if self.modifiers & Self::MOD_SHIFT != 0 {
416            out.push('⇧');
417        }
418        if self.modifiers & Self::MOD_CMD != 0 {
419            out.push('⌘');
420        }
421        match self.key_code {
422            0x00 => out.push('A'),
423            0x01 => out.push('S'),
424            0x02 => out.push('D'),
425            0x03 => out.push('F'),
426            0x06 => out.push('Z'),
427            0x07 => out.push('X'),
428            0x08 => out.push('C'),
429            0x09 => out.push('V'),
430            0x0B => out.push('B'),
431            0x0C => out.push('Q'),
432            0x0D => out.push('W'),
433            0x0E => out.push('E'),
434            0x0F => out.push('R'),
435            0x10 => out.push('Y'),
436            0x11 => out.push('T'),
437            0x20 => out.push('U'),
438            0x22 => out.push('I'),
439            0x1F => out.push('O'),
440            0x23 => out.push('P'),
441            _ => {
442                use std::fmt::Write as _;
443                let _ = write!(out, "key 0x{:02X}", self.key_code);
444            }
445        }
446        out
447    }
448}
449
450impl Action {
451    /// Display label for the popover row.
452    ///
453    /// Returns `String` rather than `&str` so parameterized variants (e.g.
454    /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
455    /// includes their payload.
456    #[must_use]
457    pub fn label(&self) -> String {
458        match self {
459            Action::None => "Do Nothing".into(),
460            Action::LeftClick => "Left Click".into(),
461            Action::RightClick => "Right Click".into(),
462            Action::MiddleClick => "Middle Click".into(),
463            Action::Copy => "Copy".into(),
464            Action::Paste => "Paste".into(),
465            Action::Cut => "Cut".into(),
466            Action::Undo => "Undo".into(),
467            Action::Redo => "Redo".into(),
468            Action::SelectAll => "Select All".into(),
469            Action::Find => "Find".into(),
470            Action::Save => "Save".into(),
471            Action::BrowserBack => "Browser Back".into(),
472            Action::BrowserForward => "Browser Forward".into(),
473            Action::NewTab => "New Tab".into(),
474            Action::CloseTab => "Close Tab".into(),
475            Action::ReopenTab => "Reopen Tab".into(),
476            Action::NextTab => "Next Tab".into(),
477            Action::PrevTab => "Previous Tab".into(),
478            Action::ReloadPage => "Reload Page".into(),
479            Action::MissionControl => "Mission Control".into(),
480            Action::AppExpose => "App Exposé".into(),
481            Action::PreviousDesktop => "Previous Desktop".into(),
482            Action::NextDesktop => "Next Desktop".into(),
483            Action::ShowDesktop => "Show Desktop".into(),
484            Action::LaunchpadShow => "Launchpad".into(),
485            Action::LockScreen => "Lock Screen".into(),
486            Action::Screenshot => "Screenshot".into(),
487            Action::PlayPause => "Play / Pause".into(),
488            Action::NextTrack => "Next Track".into(),
489            Action::PrevTrack => "Previous Track".into(),
490            Action::VolumeUp => "Volume Up".into(),
491            Action::VolumeDown => "Volume Down".into(),
492            Action::MuteVolume => "Mute".into(),
493            Action::CycleDpiPresets => "Cycle DPI Presets".into(),
494            Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
495            Action::ToggleSmartShift => "Toggle SmartShift".into(),
496            Action::ScrollUp => "Scroll Up".into(),
497            Action::ScrollDown => "Scroll Down".into(),
498            Action::HorizontalScrollLeft => "Scroll Left".into(),
499            Action::HorizontalScrollRight => "Scroll Right".into(),
500            Action::CustomShortcut(combo) => combo.rendered_label(),
501        }
502    }
503
504    /// Which [`Category`] this action belongs to, used for popover grouping.
505    #[must_use]
506    pub fn category(&self) -> Category {
507        match self {
508            Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
509            // CustomShortcut is assigned to Editing so it doesn't need a
510            // separate arm (it's not in the picker catalog).
511            Action::Copy
512            | Action::Paste
513            | Action::Cut
514            | Action::Undo
515            | Action::Redo
516            | Action::SelectAll
517            | Action::Find
518            | Action::Save
519            | Action::CustomShortcut(_) => Category::Editing,
520            Action::BrowserBack
521            | Action::BrowserForward
522            | Action::NewTab
523            | Action::CloseTab
524            | Action::ReopenTab
525            | Action::NextTab
526            | Action::PrevTab
527            | Action::ReloadPage => Category::Browser,
528            Action::MissionControl
529            | Action::AppExpose
530            | Action::PreviousDesktop
531            | Action::NextDesktop
532            | Action::ShowDesktop
533            | Action::LaunchpadShow => Category::Navigation,
534            Action::None | Action::LockScreen | Action::Screenshot => Category::System,
535            Action::PlayPause
536            | Action::NextTrack
537            | Action::PrevTrack
538            | Action::VolumeUp
539            | Action::VolumeDown
540            | Action::MuteVolume => Category::Media,
541            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
542                Category::Dpi
543            }
544            Action::ScrollUp
545            | Action::ScrollDown
546            | Action::HorizontalScrollLeft
547            | Action::HorizontalScrollRight => Category::Scroll,
548        }
549    }
550
551    /// All pickable actions in a deterministic order.
552    ///
553    /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
554    /// "Record shortcut…" (P1.3), not selected from the catalog.
555    #[must_use]
556    pub fn catalog() -> Vec<Action> {
557        vec![
558            // Mouse
559            Action::LeftClick,
560            Action::RightClick,
561            Action::MiddleClick,
562            // Editing
563            Action::Copy,
564            Action::Paste,
565            Action::Cut,
566            Action::Undo,
567            Action::Redo,
568            Action::SelectAll,
569            Action::Find,
570            Action::Save,
571            // Browser
572            Action::BrowserBack,
573            Action::BrowserForward,
574            Action::NewTab,
575            Action::CloseTab,
576            Action::ReopenTab,
577            Action::NextTab,
578            Action::PrevTab,
579            Action::ReloadPage,
580            // Navigation
581            Action::MissionControl,
582            Action::AppExpose,
583            Action::PreviousDesktop,
584            Action::NextDesktop,
585            Action::ShowDesktop,
586            Action::LaunchpadShow,
587            // System
588            Action::None,
589            Action::LockScreen,
590            Action::Screenshot,
591            // Media
592            Action::PlayPause,
593            Action::NextTrack,
594            Action::PrevTrack,
595            Action::VolumeUp,
596            Action::VolumeDown,
597            Action::MuteVolume,
598            // DPI
599            Action::CycleDpiPresets,
600            Action::ToggleSmartShift,
601            // Scroll
602            Action::ScrollUp,
603            Action::ScrollDown,
604            Action::HorizontalScrollLeft,
605            Action::HorizontalScrollRight,
606        ]
607    }
608
609    /// Synthesise the OS-level event for this action.
610    ///
611    /// On macOS, key events are posted via `CGEventPost(kCGHIDEventTap, …)`
612    /// using virtual key codes from the standard US keyboard layout, and the
613    /// `LeftClick`/`RightClick`/`MiddleClick` variants synthesise a mouse click
614    /// at the current cursor location. The WindowServer actions (`MissionControl`,
615    /// `AppExpose`, `ShowDesktop`, `LaunchpadShow`) are posted straight to the
616    /// Dock via `CoreDockSendNotification`. Device-side actions (`CycleDpiPresets`,
617    /// `SetDpiPreset`, `ToggleSmartShift`) have no CGEvent equivalent and are
618    /// handled at the hook/HID layer, logging a trace here.
619    ///
620    /// On Linux, key and scroll events are injected via a lazily-created `uinput`
621    /// virtual device. Mouse clicks inject `BTN_*` events. macOS-only window
622    /// manager actions (`MissionControl`, `AppExpose`, `ShowDesktop`,
623    /// `LaunchpadShow`) have no universal Linux equivalent and are silently
624    /// skipped (debug-logged). `CustomShortcut` maps macOS `kVK_*` codes to
625    /// Linux key codes; macOS Cmd maps to Ctrl.
626    ///
627    /// On other platforms a warning is logged and the function returns
628    /// immediately — the binary compiles clean on all targets.
629    pub fn execute(&self) {
630        #[cfg(target_os = "macos")]
631        self.execute_macos();
632
633        #[cfg(target_os = "linux")]
634        self.execute_linux();
635
636        #[cfg(not(any(target_os = "macos", target_os = "linux")))]
637        {
638            tracing::warn!(
639                action = self.label(),
640                "Action::execute unsupported on this platform"
641            );
642        }
643    }
644
645    /// Linux implementation: inject events via a shared `uinput` virtual device.
646    #[cfg(target_os = "linux")]
647    fn execute_linux(&self) {
648        use evdev::{KeyCode, RelativeAxisCode};
649        let ctrl = KeyCode::KEY_LEFTCTRL;
650        let shift = KeyCode::KEY_LEFTSHIFT;
651        let alt = KeyCode::KEY_LEFTALT;
652        match self {
653            // ── Mouse clicks ──────────────────────────────────────────────────
654            Action::LeftClick => linux::click(KeyCode::BTN_LEFT),
655            Action::RightClick => linux::click(KeyCode::BTN_RIGHT),
656            Action::MiddleClick => linux::click(KeyCode::BTN_MIDDLE),
657            // ── Editing ───────────────────────────────────────────────────────
658            Action::Copy => linux::press_key(&[ctrl], KeyCode::KEY_C),
659            Action::Paste => linux::press_key(&[ctrl], KeyCode::KEY_V),
660            Action::Cut => linux::press_key(&[ctrl], KeyCode::KEY_X),
661            Action::Undo => linux::press_key(&[ctrl], KeyCode::KEY_Z),
662            // Redo is Ctrl+Shift+Z on Linux (matches macOS ⌘⇧Z convention).
663            Action::Redo => linux::press_key(&[ctrl, shift], KeyCode::KEY_Z),
664            Action::SelectAll => linux::press_key(&[ctrl], KeyCode::KEY_A),
665            Action::Find => linux::press_key(&[ctrl], KeyCode::KEY_F),
666            Action::Save => linux::press_key(&[ctrl], KeyCode::KEY_S),
667            // ── Browser / Navigation ──────────────────────────────────────────
668            Action::BrowserBack => linux::press_key(&[alt], KeyCode::KEY_LEFT),
669            Action::BrowserForward => linux::press_key(&[alt], KeyCode::KEY_RIGHT),
670            Action::NewTab => linux::press_key(&[ctrl], KeyCode::KEY_T),
671            Action::CloseTab => linux::press_key(&[ctrl], KeyCode::KEY_W),
672            Action::ReopenTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_T),
673            Action::NextTab => linux::press_key(&[ctrl], KeyCode::KEY_TAB),
674            Action::PrevTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_TAB),
675            Action::ReloadPage => linux::press_key(&[ctrl], KeyCode::KEY_R),
676            // ── Navigation — macOS-specific ───────────────────────────────────
677            // No universal Linux equivalent; the compositor shortcut varies.
678            Action::MissionControl
679            | Action::AppExpose
680            | Action::ShowDesktop
681            | Action::LaunchpadShow => {
682                tracing::debug!(
683                    action = self.label(),
684                    "no Linux equivalent — action skipped"
685                );
686            }
687            // Ctrl+Alt+←/→ is the default in GNOME and KDE.
688            Action::PreviousDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_LEFT),
689            Action::NextDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_RIGHT),
690            // ── System ────────────────────────────────────────────────────────
691            // logind LockSessions() via the system bus; falls back to Super+L.
692            Action::LockScreen => linux::lock_screen(),
693            Action::Screenshot => linux::press_key(&[], KeyCode::KEY_SYSRQ),
694            // ── Media ─────────────────────────────────────────────────────────
695            // MPRIS targets the running media player; XF86 volume keys go to the
696            // system mixer (PulseAudio/PipeWire) which is what users expect.
697            Action::PlayPause => linux::mpris_command("PlayPause"),
698            Action::NextTrack => linux::mpris_command("Next"),
699            Action::PrevTrack => linux::mpris_command("Previous"),
700            Action::VolumeUp => linux::press_key(&[], KeyCode::KEY_VOLUMEUP),
701            Action::VolumeDown => linux::press_key(&[], KeyCode::KEY_VOLUMEDOWN),
702            Action::MuteVolume => linux::press_key(&[], KeyCode::KEY_MUTE),
703            // ── DPI / SmartShift: handled at hook/HID layer ───────────────────
704            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
705                tracing::debug!(
706                    action = self.label(),
707                    "device action handled by hook/HID layer"
708                );
709            }
710            // ── Scroll ────────────────────────────────────────────────────────
711            Action::ScrollUp => linux::scroll(RelativeAxisCode::REL_WHEEL, 3),
712            Action::ScrollDown => linux::scroll(RelativeAxisCode::REL_WHEEL, -3),
713            Action::HorizontalScrollLeft => linux::scroll(RelativeAxisCode::REL_HWHEEL, -3),
714            Action::HorizontalScrollRight => linux::scroll(RelativeAxisCode::REL_HWHEEL, 3),
715            // ── No-op ─────────────────────────────────────────────────────────
716            Action::None => {}
717            // ── Custom shortcut ───────────────────────────────────────────────
718            Action::CustomShortcut(combo) => {
719                if combo.key_code == 0 {
720                    tracing::warn!(
721                        chord = %combo.rendered_label(),
722                        "CustomShortcut with no key code — press ignored"
723                    );
724                    return;
725                }
726                let Some(key) = linux::macos_vk_to_linux(combo.key_code) else {
727                    tracing::warn!(
728                        key_code = combo.key_code,
729                        "CustomShortcut key code has no Linux mapping — press ignored"
730                    );
731                    return;
732                };
733                linux::press_key(&linux::modifiers_to_keycodes(combo.modifiers), key);
734            }
735        }
736    }
737
738    /// macOS implementation: dispatch to the appropriate event helper.
739    #[cfg(target_os = "macos")]
740    fn execute_macos(&self) {
741        use core_graphics::event::{CGEventFlags, CGMouseButton};
742
743        // Modifier bit shorthands.
744        let cmd = CGEventFlags::CGEventFlagCommand;
745        let shift = CGEventFlags::CGEventFlagShift;
746        let ctrl = CGEventFlags::CGEventFlagControl;
747        let none = CGEventFlags::CGEventFlagNull;
748
749        match self {
750            // Suppressed input: captured but deliberately produces no event.
751            Action::None => {}
752            // ── Mouse clicks: synthesise a click at the cursor ────────────────
753            // Remapping a *different* button to a click lands here (e.g. Back →
754            // MiddleClick). A button left on its own native click never reaches
755            // this — the hook passes it straight through to the OS.
756            Action::LeftClick => macos::post_click(CGMouseButton::Left),
757            Action::RightClick => macos::post_click(CGMouseButton::Right),
758            Action::MiddleClick => macos::post_click(CGMouseButton::Center),
759            // ── Editing ───────────────────────────────────────────────────────
760            Action::Copy => macos::post_key(VK_C, cmd),
761            Action::Paste => macos::post_key(VK_V, cmd),
762            Action::Cut => macos::post_key(VK_X, cmd),
763            Action::Undo => macos::post_key(VK_Z, cmd),
764            Action::Redo => macos::post_key(VK_Z, cmd | shift),
765            Action::SelectAll => macos::post_key(VK_A, cmd),
766            Action::Find => macos::post_key(VK_F, cmd),
767            Action::Save => macos::post_key(VK_S, cmd),
768            // ── Browser / Navigation ──────────────────────────────────────────
769            // BrowserBack/Forward: Cmd+[ / Cmd+] as keyboard fallback; hook
770            // layer handles the physical mouse buttons directly.
771            // kVK_ANSI_LeftBracket = 0x21, kVK_ANSI_RightBracket = 0x1E
772            Action::BrowserBack => macos::post_key(0x21, cmd),
773            Action::BrowserForward => macos::post_key(0x1E, cmd),
774            Action::NewTab => macos::post_key(VK_T, cmd),
775            Action::CloseTab => macos::post_key(VK_W, cmd),
776            Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
777            Action::NextTab => macos::post_key(VK_TAB, ctrl),
778            Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
779            Action::ReloadPage => macos::post_key(VK_R, cmd),
780            // ── Navigation / Window: posted straight to the Dock ──────────────
781            // Synthesising these shortcuts is unreliable — the WindowServer
782            // matcher needs the exact configured key (incl. the Fn flag) and
783            // Show Desktop ignores synthetic events entirely — so they go to the
784            // Dock via `CoreDockSendNotification`, which fires regardless of the
785            // user's keyboard settings.
786            Action::MissionControl => macos::mission_control(),
787            Action::AppExpose => macos::app_expose(),
788            Action::PreviousDesktop => macos::previous_desktop(),
789            Action::NextDesktop => macos::next_desktop(),
790            Action::ShowDesktop => macos::show_desktop(),
791            Action::LaunchpadShow => macos::launchpad(),
792            // ── System ────────────────────────────────────────────────────────
793            // Lock screen = Cmd+Ctrl+Q (kVK_ANSI_Q = 0x0C)
794            Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
795            // Screenshot = Cmd+Shift+3 (kVK_ANSI_3 = 0x14)
796            Action::Screenshot => macos::post_key(0x14, cmd | shift),
797            // ── Media ─────────────────────────────────────────────────────────
798            // NX_KEYTYPE_PLAY=16, NEXT=17, PREVIOUS=18 via NSSystemDefined stub.
799            Action::PlayPause => macos::post_media_key(0),
800            Action::NextTrack => macos::post_media_key(1),
801            Action::PrevTrack => macos::post_media_key(2),
802            // kVK_VolumeUp/Down/Mute = 0x48/0x49/0x4A (ADB codes)
803            Action::VolumeUp => macos::post_key(0x48, none),
804            Action::VolumeDown => macos::post_key(0x49, none),
805            Action::MuteVolume => macos::post_key(0x4A, none),
806            // ── DPI / SmartShift: handled at hook/HID layer ───────────────────
807            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
808                tracing::debug!(
809                    action = self.label(),
810                    "device action handled by hook/HID layer"
811                );
812            }
813            // ── Scroll ────────────────────────────────────────────────────────
814            Action::ScrollUp
815            | Action::ScrollDown
816            | Action::HorizontalScrollLeft
817            | Action::HorizontalScrollRight => macos::post_scroll(self),
818            // ── Custom ────────────────────────────────────────────────────────
819            Action::CustomShortcut(combo) => {
820                // P1.3: post the recorded chord. `key_code == 0` is the
821                // "modifier-only placeholder" the recorder UI rejects;
822                // skip it here too so a malformed config doesn't fire
823                // bare modifier presses.
824                if combo.key_code == 0 {
825                    tracing::warn!(
826                        chord = %combo.rendered_label(),
827                        "CustomShortcut with no key code — press ignored"
828                    );
829                    return;
830                }
831                let mut flags = CGEventFlags::CGEventFlagNull;
832                if combo.modifiers & KeyCombo::MOD_CMD != 0 {
833                    flags |= CGEventFlags::CGEventFlagCommand;
834                }
835                if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
836                    flags |= CGEventFlags::CGEventFlagShift;
837                }
838                if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
839                    flags |= CGEventFlags::CGEventFlagControl;
840                }
841                if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
842                    flags |= CGEventFlags::CGEventFlagAlternate;
843                }
844                macos::post_key(combo.key_code, flags);
845            }
846        }
847    }
848}
849
850/// Synthesise a horizontal scroll of `delta` wheel lines at the current focus.
851///
852/// Used by the gesture/thumbwheel capture watcher to re-inject the MX thumb
853/// wheel's scrolling after the wheel has been diverted over HID++ to capture its
854/// click. `delta` is the device's raw rotation; its sign follows the wheel's
855/// rotation convention and its magnitude (one line per rotation increment) may
856/// need tuning per device, since the diverted resolution differs from native.
857///
858/// No-op (logs nothing) on platforms without a supported injection mechanism.
859pub fn post_horizontal_scroll(delta: i32) {
860    #[cfg(target_os = "macos")]
861    macos::post_horizontal_scroll(delta);
862
863    // `delta` is already in "one line per rotation increment" units (see doc
864    // above), which matches REL_HWHEEL's convention of one unit per detent.
865    // This is intentionally different from Action::HorizontalScrollLeft/Right,
866    // which hardcode ±3 as a fixed "scroll tick" with no device delta involved.
867    #[cfg(target_os = "linux")]
868    linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, delta);
869
870    #[cfg(not(any(target_os = "macos", target_os = "linux")))]
871    let _ = delta;
872}
873
874/// Return the `/dev/input/eventN` node for the action-injector uinput device,
875/// initialising it if needed.
876///
877/// Intended for debugging and manual smoke-testing (e.g. attaching `evtest`
878/// before firing `Action::execute`). Returns `None` on non-Linux platforms or
879/// when the device could not be created (e.g. `/dev/uinput` not writable).
880#[cfg(target_os = "linux")]
881#[must_use]
882pub fn action_device_path() -> Option<std::path::PathBuf> {
883    linux::device_node()
884}
885
886// ── macOS virtual key codes ────────────────────────────────────────────────
887// Source: <HIToolbox/Events.h> kVK_* constants. Values are layout-independent
888// for the US ANSI keyboard.
889#[cfg(target_os = "macos")]
890const VK_A: u16 = 0x00;
891#[cfg(target_os = "macos")]
892const VK_C: u16 = 0x08;
893#[cfg(target_os = "macos")]
894const VK_F: u16 = 0x03;
895#[cfg(target_os = "macos")]
896const VK_R: u16 = 0x0F;
897#[cfg(target_os = "macos")]
898const VK_S: u16 = 0x01;
899#[cfg(target_os = "macos")]
900const VK_T: u16 = 0x11;
901#[cfg(target_os = "macos")]
902const VK_V: u16 = 0x09;
903#[cfg(target_os = "macos")]
904const VK_W: u16 = 0x0D;
905#[cfg(target_os = "macos")]
906const VK_X: u16 = 0x07;
907#[cfg(target_os = "macos")]
908const VK_Z: u16 = 0x06;
909#[cfg(target_os = "macos")]
910const VK_TAB: u16 = 0x30;
911
912/// Platform helpers for synthesising OS-level input events on macOS.
913#[cfg(target_os = "macos")]
914mod macos {
915    use core_graphics::event::{
916        CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, ScrollEventUnit,
917    };
918    use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
919    use core_graphics::geometry::CGPoint;
920
921    use crate::binding::Action;
922
923    /// Post a mouse-down + mouse-up pair for `button` at the cursor's current
924    /// location.
925    ///
926    /// Posted at the HID tap location, so OpenLogi's own event tap sees the
927    /// synthetic click too: a `LeftClick`/`RightClick` flows straight through
928    /// (the tap never owns the primary buttons), and a `MiddleClick` is left
929    /// alone unless the user has *also* remapped the middle button.
930    pub(super) fn post_click(button: CGMouseButton) {
931        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
932            tracing::warn!("CGEventSource::new failed for click");
933            return;
934        };
935        // A fresh event reports the current pointer location; mouse events need
936        // an explicit position or they land at (0, 0).
937        let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
938        let (down, up) = match button {
939            CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
940            CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
941            CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
942        };
943        for (kind, phase) in [(down, "down"), (up, "up")] {
944            if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
945                ev.post(CGEventTapLocation::HID);
946            } else {
947                tracing::warn!(phase, "CGEvent::new_mouse_event failed");
948            }
949        }
950    }
951
952    /// Post a key-down + key-up pair for `vk` with `flags` set.
953    pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
954        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
955            tracing::warn!("CGEventSource::new failed");
956            return;
957        };
958        let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
959            tracing::warn!("CGEvent::new_keyboard_event(down) failed");
960            return;
961        };
962        down.set_flags(flags);
963        down.post(CGEventTapLocation::HID);
964        let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
965            tracing::warn!("CGEvent::new_keyboard_event(up) failed");
966            return;
967        };
968        up.set_flags(flags);
969        up.post(CGEventTapLocation::HID);
970    }
971
972    /// Post a media key event (Play/Pause, Next, Previous).
973    ///
974    /// `kind`: 0 = play/pause, 1 = next track, 2 = previous track.
975    ///
976    /// The proper implementation uses an `NSSystemDefined` event (type 14,
977    /// subtype 8) which requires AppKit bindings. Until those land this
978    /// function logs a debug trace so manual smoke tests can confirm the
979    /// correct execution path.
980    pub(super) fn post_media_key(kind: i32) {
981        // NX_KEYTYPE_PLAY=16, NX_KEYTYPE_NEXT=17, NX_KEYTYPE_PREVIOUS=18.
982        let nx_key: i64 = match kind {
983            0 => 16,
984            1 => 17,
985            _ => 18,
986        };
987        tracing::debug!(
988            nx_key,
989            "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
990        );
991    }
992
993    /// Post a synthetic scroll event for `action` (one of the `Scroll*` variants).
994    pub(super) fn post_scroll(action: &Action) {
995        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
996            tracing::warn!("CGEventSource::new failed for scroll");
997            return;
998        };
999        let (v, h): (i32, i32) = match action {
1000            Action::ScrollUp => (3, 0),
1001            Action::ScrollDown => (-3, 0),
1002            Action::HorizontalScrollLeft => (0, -3),
1003            Action::HorizontalScrollRight => (0, 3),
1004            _ => return,
1005        };
1006        let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
1007            tracing::warn!("CGEvent::new_scroll_event failed");
1008            return;
1009        };
1010        ev.post(CGEventTapLocation::HID);
1011    }
1012
1013    /// Post a horizontal scroll of `delta` lines (wheel2 axis). Line units suit
1014    /// the thumb wheel's ratchet-like increments better than pixels.
1015    pub(super) fn post_horizontal_scroll(delta: i32) {
1016        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1017            tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
1018            return;
1019        };
1020        let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
1021            tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
1022            return;
1023        };
1024        ev.post(CGEventTapLocation::HID);
1025    }
1026
1027    pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
1028    pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
1029
1030    use app_services::symbol as app_services_symbol;
1031
1032    /// Shared resolver for private ApplicationServices SPI used by the Dock and
1033    /// symbolic-hotkey helpers.
1034    #[allow(
1035        unsafe_code,
1036        reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
1037    )]
1038    mod app_services {
1039        use std::ffi::{CStr, c_char, c_int, c_void};
1040        use std::sync::OnceLock;
1041
1042        /// Resolve a symbol from ApplicationServices, caching the `dlopen`
1043        /// handle for the process lifetime. Returns `None` if the framework or
1044        /// symbol is unavailable on this macOS version.
1045        pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
1046            const RTLD_LAZY: c_int = 0x1;
1047            const APP_SERVICES: &CStr =
1048                c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
1049            static HANDLE: OnceLock<usize> = OnceLock::new();
1050
1051            // SAFETY: `dlopen`/`dlsym` come from libSystem; APP_SERVICES and
1052            // `symbol` are valid C strings. The handle is cached and
1053            // intentionally never closed.
1054            let sym = unsafe {
1055                let handle =
1056                    *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
1057                if handle == 0 {
1058                    return None;
1059                }
1060                dlsym(handle as *mut c_void, symbol.as_ptr())
1061            };
1062            (!sym.is_null()).then_some(sym)
1063        }
1064
1065        unsafe extern "C" {
1066            fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
1067            fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
1068        }
1069    }
1070
1071    /// WindowServer window/space actions (Mission Control, App Exposé, Show
1072    /// Desktop, Launchpad).
1073    ///
1074    /// These are driven by the Dock, and synthesising their keyboard shortcut is
1075    /// unreliable — the WindowServer matcher needs the exact configured key
1076    /// (incl. the Fn flag) and Show Desktop's in particular doesn't respond. So
1077    /// we post the action straight to the Dock via the private
1078    /// `CoreDockSendNotification` SPI, which fires it regardless of the user's
1079    /// Keyboard settings.
1080    ///
1081    /// Isolated in its own submodule so the `unsafe` the `dlopen`/`dlsym` FFI
1082    /// needs is scoped here rather than spread across the platform helpers.
1083    #[allow(
1084        unsafe_code,
1085        reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
1086    )]
1087    mod dock {
1088        use std::ffi::{c_int, c_void};
1089
1090        use core_foundation::base::TCFType;
1091        use core_foundation::string::CFString;
1092
1093        use super::app_services_symbol;
1094
1095        /// Show all windows across spaces (Mission Control).
1096        pub(crate) fn mission_control() {
1097            send("com.apple.expose.awake");
1098        }
1099
1100        /// Show the front app's windows (App Exposé).
1101        pub(crate) fn app_expose() {
1102            send("com.apple.expose.front.awake");
1103        }
1104
1105        /// Move all windows aside to reveal the desktop.
1106        pub(crate) fn show_desktop() {
1107            send("com.apple.showdesktop.awake");
1108        }
1109
1110        /// Toggle Launchpad. A no-op on macOS 26, which removed Launchpad.
1111        pub(crate) fn launchpad() {
1112            send("com.apple.launchpad.toggle");
1113        }
1114
1115        /// Post `notification` to the Dock. Logs and returns on any failure.
1116        fn send(notification: &str) {
1117            let Some(core_dock_send) = core_dock_send_notification() else {
1118                tracing::warn!(notification, "CoreDockSendNotification unavailable");
1119                return;
1120            };
1121            let name = CFString::new(notification);
1122            // SAFETY: resolved AppServices symbol called with its documented
1123            // signature; `name` is a live CFString for the call's duration.
1124            let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
1125            if err != 0 {
1126                tracing::warn!(notification, err, "CoreDockSendNotification failed");
1127            }
1128        }
1129
1130        type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
1131
1132        /// Resolve `CoreDockSendNotification` from `ApplicationServices`, caching
1133        /// the `dlopen` handle for the process lifetime. `None` if unavailable.
1134        fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
1135            let sym = app_services_symbol(c"CoreDockSendNotification")?;
1136            // SAFETY: the symbol, when present, has the documented signature.
1137            Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
1138        }
1139    }
1140
1141    /// macOS Space switching actions.
1142    ///
1143    /// Use the system symbolic hotkey records for "Move left a space" (79) and
1144    /// "Move right a space" (81). That respects the user's configured shortcut
1145    /// instead of assuming Ctrl+Left/Right, and temporarily enables the symbolic
1146    /// hotkey when the user has disabled it.
1147    #[allow(
1148        unsafe_code,
1149        reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
1150    )]
1151    mod symbolic_hotkey {
1152        use std::ffi::{c_int, c_uint, c_ushort, c_void};
1153
1154        use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
1155        use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1156
1157        use super::app_services_symbol;
1158
1159        const SPACE_LEFT: u32 = 79;
1160        const SPACE_RIGHT: u32 = 81;
1161
1162        /// Switch to the previous desktop / Space.
1163        pub(crate) fn previous_desktop() {
1164            post_symbolic_hotkey(SPACE_LEFT);
1165        }
1166
1167        /// Switch to the next desktop / Space.
1168        pub(crate) fn next_desktop() {
1169            post_symbolic_hotkey(SPACE_RIGHT);
1170        }
1171
1172        fn post_symbolic_hotkey(hotkey: u32) {
1173            let Some(cgs) = cgs_hotkey_api() else {
1174                tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
1175                return;
1176            };
1177
1178            let mut key_equivalent = 0_u16;
1179            let mut virtual_key = 0_u16;
1180            let mut modifiers = 0_u32;
1181
1182            // SAFETY: resolved AppServices symbols are called with their
1183            // expected signatures and valid out-parameters.
1184            let err = unsafe {
1185                (cgs.get_value)(
1186                    hotkey,
1187                    &raw mut key_equivalent,
1188                    &raw mut virtual_key,
1189                    &raw mut modifiers,
1190                )
1191            };
1192            if err != 0 {
1193                tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
1194                return;
1195            }
1196
1197            // SAFETY: resolved AppServices symbol called with its expected
1198            // signature.
1199            let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
1200            if !was_enabled {
1201                // SAFETY: resolved AppServices symbol called with its expected
1202                // signature.
1203                let err = unsafe { (cgs.set_enabled)(hotkey, true) };
1204                if err != 0 {
1205                    tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
1206                }
1207            }
1208
1209            post_key(virtual_key, modifiers);
1210
1211            if !was_enabled {
1212                // SAFETY: resolved AppServices symbol called with its expected
1213                // signature.
1214                let err = unsafe { (cgs.set_enabled)(hotkey, false) };
1215                if err != 0 {
1216                    tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
1217                }
1218            }
1219        }
1220
1221        fn post_key(vk: u16, modifiers: u32) {
1222            let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1223                tracing::warn!("CGEventSource::new failed for symbolic hotkey");
1224                return;
1225            };
1226            let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1227                tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
1228                return;
1229            };
1230            let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
1231            down.set_flags(flags);
1232            down.post(CGEventTapLocation::Session);
1233
1234            let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1235                tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
1236                return;
1237            };
1238            up.set_flags(flags);
1239            up.post(CGEventTapLocation::Session);
1240        }
1241
1242        #[derive(Clone, Copy)]
1243        struct CgsHotkeyApi {
1244            get_value: CgsGetSymbolicHotKeyValueFn,
1245            is_enabled: CgsIsSymbolicHotKeyEnabledFn,
1246            set_enabled: CgsSetSymbolicHotKeyEnabledFn,
1247        }
1248
1249        type CgsGetSymbolicHotKeyValueFn =
1250            unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
1251        type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
1252        type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
1253
1254        fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
1255            let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
1256            let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
1257            let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
1258
1259            // SAFETY: the symbols, when present, have the private SPI
1260            // signatures declared above.
1261            Some(unsafe {
1262                CgsHotkeyApi {
1263                    get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
1264                        get_value,
1265                    ),
1266                    is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
1267                        is_enabled,
1268                    ),
1269                    set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
1270                        set_enabled,
1271                    ),
1272                }
1273            })
1274        }
1275    }
1276}
1277
1278/// Sensible defaults for a fresh device so the panel isn't empty on first run.
1279///
1280/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
1281/// MX-line devices: thumb wheel click → App Exposé, gesture button →
1282/// Mission Control. The thumb wheel isn't captured yet; the gesture button is
1283/// (per-direction, see [`default_gesture_binding`]). The bindings persist
1284/// regardless so the user only configures once.
1285///
1286/// `GestureButton`'s entry here is the legacy single-binding placeholder;
1287/// the per-direction sub-bindings live in [`default_gesture_binding`] and
1288/// are what the UI now edits.
1289#[must_use]
1290pub fn default_binding(button: ButtonId) -> Action {
1291    match button {
1292        ButtonId::LeftClick => Action::LeftClick,
1293        ButtonId::RightClick => Action::RightClick,
1294        ButtonId::MiddleClick => Action::MiddleClick,
1295        ButtonId::Back => Action::BrowserBack,
1296        ButtonId::Forward => Action::BrowserForward,
1297        ButtonId::DpiToggle => Action::CycleDpiPresets,
1298        ButtonId::Thumbwheel => Action::AppExpose,
1299        // The thumb wheel scrolls horizontally by default: rotating it produces
1300        // continuous horizontal scroll, with "up" → right and "down" → left.
1301        // The wheel watcher renders these two actions as smooth, sensitivity-
1302        // scaled scrolling rather than the discrete per-press burst a button
1303        // would get (see `watchers::gesture`).
1304        ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
1305        ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
1306        ButtonId::GestureButton => Action::MissionControl,
1307    }
1308}
1309
1310/// Per-direction defaults for the gesture button. These are captured live over
1311/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
1312/// defaults give the picker something sensible to show on first run.
1313#[must_use]
1314pub fn default_gesture_binding(direction: GestureDirection) -> Action {
1315    match direction {
1316        GestureDirection::Up => Action::MissionControl,
1317        GestureDirection::Down => Action::ShowDesktop,
1318        GestureDirection::Left => Action::PrevTab,
1319        GestureDirection::Right => Action::NextTab,
1320        GestureDirection::Click => Action::AppExpose,
1321    }
1322}
1323
1324/// Linux helpers for synthesising OS-level input events via a shared `uinput`
1325/// virtual device.
1326///
1327/// The device is created lazily on first use. If `/dev/uinput` is inaccessible
1328/// (missing group membership or udev rule) every call logs a `warn` and returns
1329/// without panicking.
1330#[cfg(target_os = "linux")]
1331mod linux {
1332    use std::io;
1333    use std::sync::{LazyLock, Mutex};
1334
1335    use evdev::uinput::VirtualDevice;
1336    use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode};
1337    use zbus::blocking::Connection as DbusConn;
1338
1339    const DEVICE_NAME: &str = "OpenLogi action injector";
1340
1341    static VIRTUAL_INPUT: LazyLock<Option<Mutex<VirtualDevice>>> = LazyLock::new(|| {
1342        build()
1343            .map(Mutex::new)
1344            .map_err(|e| tracing::warn!("failed to create uinput action device: {e}"))
1345            .ok()
1346    });
1347
1348    #[rustfmt::skip]
1349    const KEY_CAPABILITIES: &[KeyCode] = &[
1350        // Letters
1351        KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D,
1352        KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H,
1353        KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L,
1354        KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P,
1355        KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T,
1356        KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X,
1357        KeyCode::KEY_Y, KeyCode::KEY_Z,
1358        // Digits
1359        KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3,
1360        KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7,
1361        KeyCode::KEY_8, KeyCode::KEY_9,
1362        // Punctuation / symbols
1363        KeyCode::KEY_MINUS,      KeyCode::KEY_EQUAL,   KeyCode::KEY_LEFTBRACE,
1364        KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON,
1365        KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE,   KeyCode::KEY_COMMA,
1366        KeyCode::KEY_DOT,        KeyCode::KEY_SLASH,
1367        // Navigation / editing
1368        KeyCode::KEY_LEFT,  KeyCode::KEY_RIGHT, KeyCode::KEY_UP,       KeyCode::KEY_DOWN,
1369        KeyCode::KEY_HOME,  KeyCode::KEY_END,   KeyCode::KEY_PAGEUP,   KeyCode::KEY_PAGEDOWN,
1370        KeyCode::KEY_TAB,   KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE,
1371        KeyCode::KEY_ESC,   KeyCode::KEY_SPACE,
1372        // Modifiers (KEY_LEFTMETA used by the LockScreen Super+L fallback)
1373        KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA,
1374        // Function keys
1375        KeyCode::KEY_F1,  KeyCode::KEY_F2,  KeyCode::KEY_F3,  KeyCode::KEY_F4,
1376        KeyCode::KEY_F5,  KeyCode::KEY_F6,  KeyCode::KEY_F7,  KeyCode::KEY_F8,
1377        KeyCode::KEY_F9,  KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12,
1378        // System
1379        KeyCode::KEY_SYSRQ,
1380        // Multimedia
1381        KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG,
1382        KeyCode::KEY_VOLUMEUP,  KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE,
1383        // Mouse buttons (injected as EV_KEY with BTN_* codes)
1384        KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE,
1385    ];
1386
1387    fn build() -> io::Result<VirtualDevice> {
1388        let mut keys = AttributeSet::<KeyCode>::default();
1389        for &k in KEY_CAPABILITIES {
1390            keys.insert(k);
1391        }
1392
1393        // Only scroll axes: the device never emits cursor movement, so leaving
1394        // out REL_X/REL_Y keeps libinput from classifying it as a pointer —
1395        // which can otherwise cause injected key/wheel events to be grabbed by
1396        // pointer-grabbing X11 clients or routed oddly by some Wayland compositors.
1397        let mut axes = AttributeSet::<RelativeAxisCode>::default();
1398        for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] {
1399            axes.insert(a);
1400        }
1401
1402        VirtualDevice::builder()?
1403            .name(DEVICE_NAME)
1404            .with_keys(&keys)?
1405            .with_relative_axes(&axes)?
1406            .build()
1407    }
1408
1409    fn emit(events: &[InputEvent]) {
1410        if let Some(m) = &*VIRTUAL_INPUT {
1411            if let Ok(mut guard) = m.lock() {
1412                if let Err(e) = guard.emit(events) {
1413                    tracing::warn!("uinput action emit failed: {e}");
1414                }
1415            } else {
1416                tracing::warn!("uinput action device mutex poisoned");
1417            }
1418        } else {
1419            // Device creation failed at init; already logged once in LazyLock.
1420            tracing::debug!("uinput action device unavailable — action skipped");
1421        }
1422    }
1423
1424    fn syn() -> InputEvent {
1425        InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0)
1426    }
1427
1428    fn key_ev(code: KeyCode, value: i32) -> InputEvent {
1429        InputEvent::new(EventType::KEY.0, code.0, value)
1430    }
1431
1432    fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent {
1433        InputEvent::new(EventType::RELATIVE.0, axis.0, value)
1434    }
1435
1436    /// Inject modifier-down + key-down in one SYN frame, then key-up +
1437    /// modifier-up in a second SYN frame.
1438    ///
1439    /// Two separate frames give the kernel distinct timestamps for press and
1440    /// release, which matches what the kernel `uinput` docs show and avoids
1441    /// toolkits treating a zero-duration event as invalid.
1442    pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) {
1443        // Down phase.
1444        let mut down: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1445        for &m in mods {
1446            down.push(key_ev(m, 1));
1447        }
1448        down.push(key_ev(key, 1));
1449        down.push(syn());
1450        emit(&down);
1451
1452        // Up phase.
1453        let mut up: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1454        up.push(key_ev(key, 0));
1455        for &m in mods.iter().rev() {
1456            up.push(key_ev(m, 0));
1457        }
1458        up.push(syn());
1459        emit(&up);
1460    }
1461
1462    /// Inject a button-down in one SYN frame and button-up in a second.
1463    pub(super) fn click(button: KeyCode) {
1464        emit(&[key_ev(button, 1), syn()]);
1465        emit(&[key_ev(button, 0), syn()]);
1466    }
1467
1468    /// Inject a single relative-axis delta followed by `SYN_REPORT`.
1469    pub(super) fn scroll(axis: RelativeAxisCode, value: i32) {
1470        emit(&[rel_ev(axis, value), syn()]);
1471    }
1472
1473    /// Force the virtual device to initialise (if it hasn't already) and return
1474    /// its `/dev/input/eventN` node path.
1475    ///
1476    /// Uses `VirtualDevice::enumerate_dev_nodes()` which returns the correct
1477    /// `/dev/input/eventN` path directly. Returns `None` if the device couldn't
1478    /// be created or if the node hasn't appeared yet (udev typically creates it
1479    /// within a few milliseconds of the `ioctl`).
1480    pub(super) fn device_node() -> Option<std::path::PathBuf> {
1481        // Touch the LazyLock to force initialisation.
1482        let _ = &*VIRTUAL_INPUT;
1483        // Give udev a moment to create the /dev node.
1484        std::thread::sleep(std::time::Duration::from_millis(150));
1485        if let Some(m) = &*VIRTUAL_INPUT {
1486            if let Ok(mut guard) = m.lock() {
1487                return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next();
1488            }
1489        }
1490        None
1491    }
1492
1493    /// Convert a [`KeyCombo`] modifier bitmask to the evdev keys to hold.
1494    ///
1495    /// macOS Cmd (`MOD_CMD`) and Ctrl (`MOD_CTRL`) both map to `KEY_LEFTCTRL`;
1496    /// the bitwise-OR check deduplicates them so at most one Ctrl is pushed.
1497    /// Order is canonical: Ctrl → Shift → Alt.
1498    pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec<KeyCode> {
1499        use crate::binding::KeyCombo;
1500        let mut mods = Vec::new();
1501        if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 {
1502            mods.push(KeyCode::KEY_LEFTCTRL);
1503        }
1504        if modifiers & KeyCombo::MOD_SHIFT != 0 {
1505            mods.push(KeyCode::KEY_LEFTSHIFT);
1506        }
1507        if modifiers & KeyCombo::MOD_OPTION != 0 {
1508            mods.push(KeyCode::KEY_LEFTALT);
1509        }
1510        mods
1511    }
1512
1513    /// Map a macOS `kVK_*` virtual key code to the corresponding Linux `KeyCode`.
1514    ///
1515    /// Source: `HIToolbox/Events.h` (macOS side) and
1516    /// `linux/input-event-codes.h` (Linux side). Only the codes the recorder UI
1517    /// is likely to produce are mapped; unknown codes return `None`.
1518    pub(super) fn macos_vk_to_linux(vk: u16) -> Option<KeyCode> {
1519        Some(match vk {
1520            0x00 => KeyCode::KEY_A,          // kVK_ANSI_A
1521            0x01 => KeyCode::KEY_S,          // kVK_ANSI_S
1522            0x02 => KeyCode::KEY_D,          // kVK_ANSI_D
1523            0x03 => KeyCode::KEY_F,          // kVK_ANSI_F
1524            0x04 => KeyCode::KEY_H,          // kVK_ANSI_H
1525            0x05 => KeyCode::KEY_G,          // kVK_ANSI_G
1526            0x06 => KeyCode::KEY_Z,          // kVK_ANSI_Z
1527            0x07 => KeyCode::KEY_X,          // kVK_ANSI_X
1528            0x08 => KeyCode::KEY_C,          // kVK_ANSI_C
1529            0x09 => KeyCode::KEY_V,          // kVK_ANSI_V
1530            0x0B => KeyCode::KEY_B,          // kVK_ANSI_B
1531            0x0C => KeyCode::KEY_Q,          // kVK_ANSI_Q
1532            0x0D => KeyCode::KEY_W,          // kVK_ANSI_W
1533            0x0E => KeyCode::KEY_E,          // kVK_ANSI_E
1534            0x0F => KeyCode::KEY_R,          // kVK_ANSI_R
1535            0x10 => KeyCode::KEY_Y,          // kVK_ANSI_Y
1536            0x11 => KeyCode::KEY_T,          // kVK_ANSI_T
1537            0x12 => KeyCode::KEY_1,          // kVK_ANSI_1
1538            0x13 => KeyCode::KEY_2,          // kVK_ANSI_2
1539            0x14 => KeyCode::KEY_3,          // kVK_ANSI_3
1540            0x15 => KeyCode::KEY_4,          // kVK_ANSI_4
1541            0x16 => KeyCode::KEY_6,          // kVK_ANSI_6
1542            0x17 => KeyCode::KEY_5,          // kVK_ANSI_5
1543            0x18 => KeyCode::KEY_EQUAL,      // kVK_ANSI_Equal
1544            0x19 => KeyCode::KEY_9,          // kVK_ANSI_9
1545            0x1A => KeyCode::KEY_7,          // kVK_ANSI_7
1546            0x1B => KeyCode::KEY_MINUS,      // kVK_ANSI_Minus
1547            0x1C => KeyCode::KEY_8,          // kVK_ANSI_8
1548            0x1D => KeyCode::KEY_0,          // kVK_ANSI_0
1549            0x1E => KeyCode::KEY_RIGHTBRACE, // kVK_ANSI_RightBracket
1550            0x1F => KeyCode::KEY_O,          // kVK_ANSI_O
1551            0x20 => KeyCode::KEY_U,          // kVK_ANSI_U
1552            0x21 => KeyCode::KEY_LEFTBRACE,  // kVK_ANSI_LeftBracket
1553            0x22 => KeyCode::KEY_I,          // kVK_ANSI_I
1554            0x23 => KeyCode::KEY_P,          // kVK_ANSI_P
1555            0x24 => KeyCode::KEY_ENTER,      // kVK_Return
1556            0x25 => KeyCode::KEY_L,          // kVK_ANSI_L
1557            0x26 => KeyCode::KEY_J,          // kVK_ANSI_J
1558            0x27 => KeyCode::KEY_APOSTROPHE, // kVK_ANSI_Quote
1559            0x28 => KeyCode::KEY_K,          // kVK_ANSI_K
1560            0x29 => KeyCode::KEY_SEMICOLON,  // kVK_ANSI_Semicolon
1561            0x2A => KeyCode::KEY_BACKSLASH,  // kVK_ANSI_Backslash
1562            0x2B => KeyCode::KEY_COMMA,      // kVK_ANSI_Comma
1563            0x2C => KeyCode::KEY_SLASH,      // kVK_ANSI_Slash
1564            0x2D => KeyCode::KEY_N,          // kVK_ANSI_N
1565            0x2E => KeyCode::KEY_M,          // kVK_ANSI_M
1566            0x2F => KeyCode::KEY_DOT,        // kVK_ANSI_Period
1567            0x30 => KeyCode::KEY_TAB,        // kVK_Tab
1568            0x31 => KeyCode::KEY_SPACE,      // kVK_Space
1569            0x32 => KeyCode::KEY_GRAVE,      // kVK_ANSI_Grave
1570            0x33 => KeyCode::KEY_BACKSPACE,  // kVK_Delete (= Backspace on macOS)
1571            0x35 => KeyCode::KEY_ESC,        // kVK_Escape
1572            0x60 => KeyCode::KEY_F5,         // kVK_F5
1573            0x61 => KeyCode::KEY_F6,         // kVK_F6
1574            0x62 => KeyCode::KEY_F7,         // kVK_F7
1575            0x63 => KeyCode::KEY_F3,         // kVK_F3
1576            0x64 => KeyCode::KEY_F8,         // kVK_F8
1577            0x65 => KeyCode::KEY_F9,         // kVK_F9
1578            0x67 => KeyCode::KEY_F11,        // kVK_F11
1579            0x6D => KeyCode::KEY_F10,        // kVK_F10
1580            0x6F => KeyCode::KEY_F12,        // kVK_F12
1581            0x76 => KeyCode::KEY_F4,         // kVK_F4
1582            0x78 => KeyCode::KEY_F2,         // kVK_F2
1583            0x7A => KeyCode::KEY_F1,         // kVK_F1
1584            0x73 => KeyCode::KEY_HOME,       // kVK_Home
1585            0x77 => KeyCode::KEY_END,        // kVK_End
1586            0x74 => KeyCode::KEY_PAGEUP,     // kVK_PageUp
1587            0x79 => KeyCode::KEY_PAGEDOWN,   // kVK_PageDown
1588            0x75 => KeyCode::KEY_DELETE,     // kVK_ForwardDelete
1589            0x7B => KeyCode::KEY_LEFT,       // kVK_LeftArrow
1590            0x7C => KeyCode::KEY_RIGHT,      // kVK_RightArrow
1591            0x7D => KeyCode::KEY_DOWN,       // kVK_DownArrow
1592            0x7E => KeyCode::KEY_UP,         // kVK_UpArrow
1593            _ => return None,
1594        })
1595    }
1596
1597    // ── D-Bus helpers ────────────────────────────────────────────────────────
1598
1599    static SESSION_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1600        DbusConn::session()
1601            .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}"))
1602            .ok()
1603    });
1604
1605    static SYSTEM_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
1606        DbusConn::system()
1607            .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}"))
1608            .ok()
1609    });
1610
1611    /// Lock the screen via logind `LockSession($XDG_SESSION_ID)` on the system
1612    /// bus, falling back to Super+L.
1613    ///
1614    /// Only the session identified by `$XDG_SESSION_ID` is locked; if the
1615    /// variable is unset the D-Bus path is skipped entirely to avoid locking
1616    /// all sessions on the machine. Super+L covers non-systemd systems and the
1617    /// no-session-id case.
1618    pub(super) fn lock_screen() {
1619        if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) {
1620            match conn.call_method(
1621                Some("org.freedesktop.login1"),
1622                "/org/freedesktop/login1",
1623                Some("org.freedesktop.login1.Manager"),
1624                "LockSession",
1625                &(id.as_str(),),
1626            ) {
1627                Ok(_) => {
1628                    tracing::debug!("LockScreen via logind");
1629                    return;
1630                }
1631                Err(e) => tracing::warn!("logind LockSession failed: {e}"),
1632            }
1633        }
1634        // Super+L is the standard lock shortcut on GNOME and KDE.
1635        tracing::debug!("LockScreen via Super+L key combo");
1636        press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L);
1637    }
1638
1639    /// Send `command` to the first MPRIS-capable media player on the session bus,
1640    /// falling back to the corresponding XF86 multimedia key only if no MPRIS
1641    /// player is found. When a player is found but the call fails, the fallback
1642    /// is suppressed to avoid double-toggling (the player likely handles the
1643    /// XF86 key too).
1644    pub(super) fn mpris_command(command: &str) {
1645        if try_mpris_command(command).is_none() {
1646            let fallback = match command {
1647                "PlayPause" => KeyCode::KEY_PLAYPAUSE,
1648                "Next" => KeyCode::KEY_NEXTSONG,
1649                "Previous" => KeyCode::KEY_PREVIOUSSONG,
1650                _ => return,
1651            };
1652            press_key(&[], fallback);
1653        }
1654    }
1655
1656    fn try_mpris_command(command: &str) -> Option<()> {
1657        let conn = SESSION_BUS.as_ref()?;
1658        let reply = conn
1659            .call_method(
1660                Some("org.freedesktop.DBus"),
1661                "/org/freedesktop/DBus",
1662                Some("org.freedesktop.DBus"),
1663                "ListNames",
1664                &(),
1665            )
1666            .ok()?;
1667        let names = reply.body().deserialize::<Vec<String>>().ok()?;
1668        let Some(player) = names
1669            .iter()
1670            .find(|n| n.starts_with("org.mpris.MediaPlayer2."))
1671        else {
1672            tracing::debug!("no MPRIS player found — {command} via XF86 key fallback");
1673            return None;
1674        };
1675        match conn.call_method(
1676            Some(player.as_str()),
1677            "/org/mpris/MediaPlayer2",
1678            Some("org.mpris.MediaPlayer2.Player"),
1679            command,
1680            &(),
1681        ) {
1682            Ok(_) => {
1683                tracing::debug!("MPRIS {command} via {player}");
1684                Some(())
1685            }
1686            Err(e) => {
1687                // Player was identified — suppress XF86 fallback to avoid
1688                // double-toggling if the player also handles multimedia keys.
1689                tracing::warn!("MPRIS {command} on {player} failed: {e}");
1690                Some(())
1691            }
1692        }
1693    }
1694}
1695
1696#[cfg(test)]
1697#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
1698mod tests {
1699    use std::collections::BTreeMap;
1700
1701    use serde::{Deserialize, Serialize};
1702
1703    use super::*;
1704
1705    // ── Roundtrip wrapper: defined here so it precedes any `let` statements ──
1706
1707    /// Minimal TOML-serializable wrapper used by `roundtrip`.
1708    /// Defined at module scope to satisfy `clippy::items_after_statements`.
1709    #[derive(Serialize, Deserialize)]
1710    struct RoundtripWrapper {
1711        binding: BTreeMap<ButtonId, Action>,
1712    }
1713
1714    // ── Catalog tests ─────────────────────────────────────────────────────────
1715
1716    #[test]
1717    fn catalog_has_at_least_29_entries() {
1718        let catalog = Action::catalog();
1719        assert!(
1720            catalog.len() >= 29,
1721            "catalog has {} entries, need ≥ 29",
1722            catalog.len()
1723        );
1724    }
1725
1726    #[test]
1727    fn catalog_excludes_custom_shortcut() {
1728        let catalog = Action::catalog();
1729        for action in &catalog {
1730            assert!(
1731                !matches!(action, Action::CustomShortcut(_)),
1732                "catalog must not contain CustomShortcut"
1733            );
1734        }
1735    }
1736
1737    // ── Gesture classification ────────────────────────────────────────────────
1738
1739    #[test]
1740    fn detect_swipe_below_threshold_keeps_accumulating() {
1741        // Too little travel to commit — caller keeps summing raw-XY.
1742        assert_eq!(detect_swipe(40, 5), None);
1743        assert_eq!(detect_swipe(0, 0), None);
1744    }
1745
1746    #[test]
1747    fn detect_swipe_commits_clean_direction() {
1748        assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
1749        assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
1750        assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
1751        assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
1752    }
1753
1754    #[test]
1755    fn detect_swipe_rejects_diagonal() {
1756        // Past the threshold but too diagonal (cross axis beyond the band).
1757        assert_eq!(detect_swipe(60, 60), None);
1758        assert_eq!(detect_swipe(-60, -60), None);
1759    }
1760
1761    // ── TOML roundtrip ────────────────────────────────────────────────────────
1762
1763    /// Serialize then deserialize `action` through TOML, using a wrapper
1764    /// struct because TOML requires a top-level table.
1765    fn roundtrip(action: &Action) -> Action {
1766        let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
1767        map.insert(ButtonId::Back, action.clone());
1768        let w = RoundtripWrapper { binding: map };
1769        let s = toml::to_string(&w).expect("serialize");
1770        let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
1771        back.binding
1772            .into_values()
1773            .next()
1774            .expect("binding present after roundtrip")
1775    }
1776
1777    #[test]
1778    fn all_catalog_variants_roundtrip_toml() {
1779        for action in Action::catalog() {
1780            let back = roundtrip(&action);
1781            assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
1782        }
1783    }
1784
1785    #[test]
1786    fn custom_shortcut_roundtrips_toml() {
1787        let action = Action::CustomShortcut(KeyCombo {
1788            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1789            key_code: 0x23, // kVK_ANSI_P
1790            display: "⌘⇧P".into(),
1791        });
1792        assert_eq!(roundtrip(&action), action);
1793    }
1794
1795    #[test]
1796    fn key_combo_rendered_label_uses_display_when_set() {
1797        let combo = KeyCombo {
1798            modifiers: 0,
1799            key_code: 0,
1800            display: "preset".into(),
1801        };
1802        assert_eq!(combo.rendered_label(), "preset");
1803    }
1804
1805    #[test]
1806    fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
1807        let combo = KeyCombo {
1808            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1809            key_code: 0x23, // P
1810            display: String::new(),
1811        };
1812        assert_eq!(combo.rendered_label(), "⇧⌘P");
1813    }
1814
1815    // ── Category tests ────────────────────────────────────────────────────────
1816
1817    #[test]
1818    fn category_editing_variants() {
1819        assert_eq!(Action::Copy.category(), Category::Editing);
1820        assert_eq!(Action::Undo.category(), Category::Editing);
1821        assert_eq!(Action::SelectAll.category(), Category::Editing);
1822        assert_eq!(Action::Find.category(), Category::Editing);
1823        assert_eq!(Action::Save.category(), Category::Editing);
1824        assert_eq!(Action::Cut.category(), Category::Editing);
1825        assert_eq!(Action::Redo.category(), Category::Editing);
1826        assert_eq!(Action::Paste.category(), Category::Editing);
1827    }
1828
1829    #[test]
1830    fn category_browser_variants() {
1831        assert_eq!(Action::BrowserBack.category(), Category::Browser);
1832        assert_eq!(Action::BrowserForward.category(), Category::Browser);
1833        assert_eq!(Action::NewTab.category(), Category::Browser);
1834        assert_eq!(Action::CloseTab.category(), Category::Browser);
1835        assert_eq!(Action::ReopenTab.category(), Category::Browser);
1836        assert_eq!(Action::NextTab.category(), Category::Browser);
1837        assert_eq!(Action::PrevTab.category(), Category::Browser);
1838        assert_eq!(Action::ReloadPage.category(), Category::Browser);
1839    }
1840
1841    #[test]
1842    fn category_media_variants() {
1843        assert_eq!(Action::PlayPause.category(), Category::Media);
1844        assert_eq!(Action::NextTrack.category(), Category::Media);
1845        assert_eq!(Action::PrevTrack.category(), Category::Media);
1846        assert_eq!(Action::VolumeUp.category(), Category::Media);
1847        assert_eq!(Action::VolumeDown.category(), Category::Media);
1848        assert_eq!(Action::MuteVolume.category(), Category::Media);
1849    }
1850
1851    #[test]
1852    fn category_mouse_variants() {
1853        assert_eq!(Action::LeftClick.category(), Category::Mouse);
1854        assert_eq!(Action::RightClick.category(), Category::Mouse);
1855        assert_eq!(Action::MiddleClick.category(), Category::Mouse);
1856    }
1857
1858    #[test]
1859    fn category_dpi_variants() {
1860        assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
1861        assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
1862    }
1863
1864    #[test]
1865    fn category_scroll_variants() {
1866        assert_eq!(Action::ScrollUp.category(), Category::Scroll);
1867        assert_eq!(Action::ScrollDown.category(), Category::Scroll);
1868        assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
1869        assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
1870    }
1871
1872    #[test]
1873    fn category_navigation_variants() {
1874        assert_eq!(Action::MissionControl.category(), Category::Navigation);
1875        assert_eq!(Action::AppExpose.category(), Category::Navigation);
1876        assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
1877        assert_eq!(Action::NextDesktop.category(), Category::Navigation);
1878        assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
1879        assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1880    }
1881
1882    #[test]
1883    fn category_system_variants() {
1884        assert_eq!(Action::LockScreen.category(), Category::System);
1885        assert_eq!(Action::Screenshot.category(), Category::System);
1886    }
1887
1888    // ── Category label smoke test ─────────────────────────────────────────────
1889
1890    #[test]
1891    fn category_labels_are_nonempty() {
1892        let categories = [
1893            Category::Editing,
1894            Category::Browser,
1895            Category::Media,
1896            Category::Mouse,
1897            Category::Dpi,
1898            Category::Scroll,
1899            Category::Navigation,
1900            Category::System,
1901        ];
1902        for cat in categories {
1903            assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1904        }
1905    }
1906
1907    // ── Default binding ───────────────────────────────────────────────────────
1908
1909    #[test]
1910    fn dpi_toggle_default_is_cycle_dpi_presets() {
1911        assert_eq!(
1912            default_binding(ButtonId::DpiToggle),
1913            Action::CycleDpiPresets
1914        );
1915    }
1916
1917    // ── modifiers_to_keycodes ─────────────────────────────────────────────────
1918
1919    #[cfg(target_os = "linux")]
1920    mod modifier_mapping {
1921        use evdev::KeyCode;
1922
1923        use crate::binding::{KeyCombo, linux::modifiers_to_keycodes};
1924
1925        #[test]
1926        fn mod_cmd_alone_maps_to_ctrl() {
1927            assert_eq!(
1928                modifiers_to_keycodes(KeyCombo::MOD_CMD),
1929                vec![KeyCode::KEY_LEFTCTRL]
1930            );
1931        }
1932
1933        #[test]
1934        fn mod_ctrl_alone_maps_to_ctrl() {
1935            assert_eq!(
1936                modifiers_to_keycodes(KeyCombo::MOD_CTRL),
1937                vec![KeyCode::KEY_LEFTCTRL]
1938            );
1939        }
1940
1941        #[test]
1942        fn mod_cmd_and_ctrl_together_produce_single_ctrl() {
1943            // Both bits set must not push KEY_LEFTCTRL twice.
1944            assert_eq!(
1945                modifiers_to_keycodes(KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL),
1946                vec![KeyCode::KEY_LEFTCTRL]
1947            );
1948        }
1949
1950        #[test]
1951        fn all_modifiers_produce_canonical_order() {
1952            let mods = modifiers_to_keycodes(
1953                KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT | KeyCombo::MOD_OPTION,
1954            );
1955            assert_eq!(
1956                mods,
1957                vec![
1958                    KeyCode::KEY_LEFTCTRL,
1959                    KeyCode::KEY_LEFTSHIFT,
1960                    KeyCode::KEY_LEFTALT
1961                ]
1962            );
1963        }
1964
1965        #[test]
1966        fn no_modifiers_produces_empty_vec() {
1967            assert!(modifiers_to_keycodes(0).is_empty());
1968        }
1969    }
1970
1971    // ── macos_vk_to_linux ────────────────────────────────────────────────────
1972
1973    #[cfg(target_os = "linux")]
1974    mod vk_mapping {
1975        use evdev::KeyCode;
1976
1977        use crate::binding::linux::macos_vk_to_linux;
1978
1979        #[test]
1980        fn common_letters_map_correctly() {
1981            assert_eq!(macos_vk_to_linux(0x08), Some(KeyCode::KEY_C)); // kVK_ANSI_C
1982            assert_eq!(macos_vk_to_linux(0x09), Some(KeyCode::KEY_V)); // kVK_ANSI_V
1983            assert_eq!(macos_vk_to_linux(0x07), Some(KeyCode::KEY_X)); // kVK_ANSI_X
1984            assert_eq!(macos_vk_to_linux(0x00), Some(KeyCode::KEY_A)); // kVK_ANSI_A
1985            assert_eq!(macos_vk_to_linux(0x06), Some(KeyCode::KEY_Z)); // kVK_ANSI_Z
1986            assert_eq!(macos_vk_to_linux(0x0D), Some(KeyCode::KEY_W)); // kVK_ANSI_W
1987        }
1988
1989        #[test]
1990        fn digits_map_correctly() {
1991            assert_eq!(macos_vk_to_linux(0x12), Some(KeyCode::KEY_1)); // kVK_ANSI_1
1992            assert_eq!(macos_vk_to_linux(0x1D), Some(KeyCode::KEY_0)); // kVK_ANSI_0
1993        }
1994
1995        #[test]
1996        fn arrow_keys_map_correctly() {
1997            assert_eq!(macos_vk_to_linux(0x7B), Some(KeyCode::KEY_LEFT));
1998            assert_eq!(macos_vk_to_linux(0x7C), Some(KeyCode::KEY_RIGHT));
1999            assert_eq!(macos_vk_to_linux(0x7D), Some(KeyCode::KEY_DOWN));
2000            assert_eq!(macos_vk_to_linux(0x7E), Some(KeyCode::KEY_UP));
2001        }
2002
2003        #[test]
2004        fn function_keys_map_correctly() {
2005            assert_eq!(macos_vk_to_linux(0x7A), Some(KeyCode::KEY_F1)); // kVK_F1
2006            assert_eq!(macos_vk_to_linux(0x78), Some(KeyCode::KEY_F2)); // kVK_F2
2007            assert_eq!(macos_vk_to_linux(0x76), Some(KeyCode::KEY_F4)); // kVK_F4
2008            assert_eq!(macos_vk_to_linux(0x60), Some(KeyCode::KEY_F5)); // kVK_F5
2009            assert_eq!(macos_vk_to_linux(0x6F), Some(KeyCode::KEY_F12)); // kVK_F12
2010        }
2011
2012        #[test]
2013        fn nav_keys_map_correctly() {
2014            assert_eq!(macos_vk_to_linux(0x73), Some(KeyCode::KEY_HOME));
2015            assert_eq!(macos_vk_to_linux(0x77), Some(KeyCode::KEY_END));
2016            assert_eq!(macos_vk_to_linux(0x74), Some(KeyCode::KEY_PAGEUP));
2017            assert_eq!(macos_vk_to_linux(0x79), Some(KeyCode::KEY_PAGEDOWN));
2018            assert_eq!(macos_vk_to_linux(0x75), Some(KeyCode::KEY_DELETE));
2019        }
2020
2021        #[test]
2022        fn brackets_follow_ansi_layout() {
2023            // kVK_ANSI_LeftBracket=0x21 → KEY_LEFTBRACE, RightBracket=0x1E → KEY_RIGHTBRACE
2024            assert_eq!(macos_vk_to_linux(0x21), Some(KeyCode::KEY_LEFTBRACE));
2025            assert_eq!(macos_vk_to_linux(0x1E), Some(KeyCode::KEY_RIGHTBRACE));
2026        }
2027
2028        #[test]
2029        fn unmapped_code_returns_none() {
2030            assert_eq!(macos_vk_to_linux(0xFF), None);
2031            assert_eq!(macos_vk_to_linux(0x34), None); // gap in the kVK table
2032        }
2033    }
2034}