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 found on MX-line devices. Bind a click
27    /// action; rotation isn't bindable yet (P1.2 / P1.5 follow-up).
28    Thumbwheel,
29    /// The thumb-pad gesture button on MX-line devices. The press itself
30    /// fires the bound action; swipe directions are P1.5 territory.
31    GestureButton,
32}
33
34impl ButtonId {
35    pub const ALL: [ButtonId; 8] = [
36        ButtonId::LeftClick,
37        ButtonId::RightClick,
38        ButtonId::MiddleClick,
39        ButtonId::Back,
40        ButtonId::Forward,
41        ButtonId::DpiToggle,
42        ButtonId::Thumbwheel,
43        ButtonId::GestureButton,
44    ];
45
46    /// Human-readable label for popovers and tooltips.
47    #[must_use]
48    pub fn label(self) -> &'static str {
49        match self {
50            ButtonId::LeftClick => "Left Click",
51            ButtonId::RightClick => "Right Click",
52            ButtonId::MiddleClick => "Middle Click",
53            ButtonId::Back => "Back",
54            ButtonId::Forward => "Forward",
55            ButtonId::DpiToggle => "DPI Toggle",
56            ButtonId::Thumbwheel => "Thumb Wheel",
57            ButtonId::GestureButton => "Gesture Button",
58        }
59    }
60}
61
62impl fmt::Display for ButtonId {
63    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
64        f.write_str(self.label())
65    }
66}
67
68/// One of the five sub-bindings on the gesture button: hold + swipe up/down/
69/// left/right or a plain click without movement. Logi ships these as
70/// independent assignments (`SLOT_NAME_GESTURE_*_BUTTON` in the
71/// `device_gesture_buttons_image` metadata block) — OpenLogi mirrors the
72/// same shape.
73///
74/// Variant identifiers are TOML-stable: renames are migration events.
75#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
76pub enum GestureDirection {
77    Up,
78    Down,
79    Left,
80    Right,
81    Click,
82}
83
84impl GestureDirection {
85    pub const ALL: [GestureDirection; 5] = [
86        GestureDirection::Up,
87        GestureDirection::Down,
88        GestureDirection::Left,
89        GestureDirection::Right,
90        GestureDirection::Click,
91    ];
92
93    #[must_use]
94    pub fn label(self) -> &'static str {
95        match self {
96            GestureDirection::Up => "Up",
97            GestureDirection::Down => "Down",
98            GestureDirection::Left => "Left",
99            GestureDirection::Right => "Right",
100            GestureDirection::Click => "Click",
101        }
102    }
103
104    /// Arrow glyph for compact list rendering.
105    #[must_use]
106    pub fn glyph(self) -> &'static str {
107        match self {
108            GestureDirection::Up => "↑",
109            GestureDirection::Down => "↓",
110            GestureDirection::Left => "←",
111            GestureDirection::Right => "→",
112            GestureDirection::Click => "·",
113        }
114    }
115}
116
117impl fmt::Display for GestureDirection {
118    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
119        f.write_str(self.label())
120    }
121}
122
123/// Minimum dominant-axis travel (raw-XY units) before a held gesture commits to
124/// a direction. Tuned to match Logitech Options+'s responsiveness.
125pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
126/// Maximum cross-axis travel allowed at the threshold, so only a reasonably
127/// straight swipe commits. Grows with the dominant axis (`max(deadzone, 35%)`).
128pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
129
130/// Classify the *running* raw-XY travel of a held gesture button into a
131/// directional swipe, the instant it commits — or `None` while it's still too
132/// short or too diagonal.
133///
134/// The dominant axis must pass [`GESTURE_SWIPE_THRESHOLD`] while the cross axis
135/// stays within `max(`[`GESTURE_SWIPE_DEADZONE`]`, 35% of dominant)`. Callers
136/// fire the bound action the moment this returns `Some` — mid-swipe, like
137/// Options+ — rather than waiting for the button release; a press that never
138/// commits a direction is treated as [`GestureDirection::Click`] on release.
139///
140/// Coordinates follow the device's raw-XY convention (`+x` = right, `+y` =
141/// down), so an upward swipe (negative `dy`) maps to [`GestureDirection::Up`].
142#[must_use]
143pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
144    let (abs_x, abs_y) = (dx.abs(), dy.abs());
145    let dominant = abs_x.max(abs_y);
146    if dominant < GESTURE_SWIPE_THRESHOLD {
147        return None;
148    }
149    let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant * 35 / 100);
150    if abs_x > abs_y {
151        if abs_y > cross_limit {
152            return None;
153        }
154        Some(if dx > 0 {
155            GestureDirection::Right
156        } else {
157            GestureDirection::Left
158        })
159    } else {
160        if abs_x > cross_limit {
161            return None;
162        }
163        Some(if dy > 0 {
164            GestureDirection::Down
165        } else {
166            GestureDirection::Up
167        })
168    }
169}
170
171/// Grouping for popover section headers.
172///
173/// Used by [`Action::category`] and rendered as a small muted label above
174/// each group in the action picker.
175#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
176pub enum Category {
177    /// Cut, copy, paste, undo, redo, select-all, find, save.
178    Editing,
179    /// Browser navigation: tabs, page reload, back/forward.
180    Browser,
181    /// Playback and volume controls.
182    Media,
183    /// Physical mouse clicks.
184    Mouse,
185    /// DPI cycle and SmartShift.
186    Dpi,
187    /// Scroll direction shortcuts.
188    Scroll,
189    /// Window/app navigation: Mission Control, Launchpad, etc.
190    Navigation,
191    /// Lock screen, show desktop, system-level actions.
192    System,
193}
194
195impl Category {
196    /// Short label for popover section headers (already uppercase so callers
197    /// don't have to transform it).
198    #[must_use]
199    pub fn label(self) -> &'static str {
200        match self {
201            Category::Editing => "EDITING",
202            Category::Browser => "BROWSER",
203            Category::Media => "MEDIA",
204            Category::Mouse => "MOUSE",
205            Category::Dpi => "DPI",
206            Category::Scroll => "SCROLL",
207            Category::Navigation => "NAVIGATION",
208            Category::System => "SYSTEM",
209        }
210    }
211}
212
213/// What pressing a [`ButtonId`] should do.
214///
215/// Serialization uses serde's default external tagging: unit variants
216/// serialize as a bare string (`"BrowserBack"`) and the tuple variant
217/// serializes as a single-key table (`{ CustomShortcut = "my chord" }`).
218///
219/// **Stability contract:** existing variant *names* are frozen — they form the
220/// on-disk `config.toml` schema. New variants may be appended freely; removing
221/// or renaming a variant requires a `schema_version` bump and a migration.
222///
223/// `Action::execute` synthesizes the OS-level event for each variant.
224/// On macOS it posts the event via `CGEventPost(kCGHIDEventTap, …)`.
225/// On other platforms it logs a warning and returns immediately — the binary
226/// compiles on all targets.
227///
228/// # Manual verification
229///
230/// `execute` is intentionally excluded from the automated test suite because
231/// it would need to intercept the OS event queue. Smoke-test it manually:
232/// bind a button to any action in the GUI and confirm the expected system event
233/// fires when the button is pressed.
234#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
235pub enum Action {
236    // ── Mouse ────────────────────────────────────────────────────────────────
237    /// Primary mouse button.
238    LeftClick,
239    /// Secondary mouse button.
240    RightClick,
241    /// Middle mouse button (wheel click).
242    MiddleClick,
243
244    // ── Editing ──────────────────────────────────────────────────────────────
245    /// Copy the current selection (⌘C / Ctrl+C).
246    Copy,
247    /// Paste from the clipboard (⌘V / Ctrl+V).
248    Paste,
249    /// Cut the current selection (⌘X / Ctrl+X).
250    Cut,
251    /// Undo the last action (⌘Z / Ctrl+Z).
252    Undo,
253    /// Redo the last undone action (⌘⇧Z / Ctrl+Y).
254    Redo,
255    /// Select all content (⌘A / Ctrl+A).
256    SelectAll,
257    /// Open the find / search bar (⌘F / Ctrl+F).
258    Find,
259    /// Save the current document (⌘S / Ctrl+S).
260    Save,
261
262    // ── Browser / Navigation ──────────────────────────────────────────────────
263    /// Navigate backward in browser history.
264    BrowserBack,
265    /// Navigate forward in browser history.
266    BrowserForward,
267    /// Open a new tab (⌘T / Ctrl+T).
268    NewTab,
269    /// Close the current tab (⌘W / Ctrl+W).
270    CloseTab,
271    /// Reopen the last closed tab (⌘⇧T / Ctrl+Shift+T).
272    ReopenTab,
273    /// Switch to the next tab (⌃⇥ / Ctrl+Tab).
274    NextTab,
275    /// Switch to the previous tab (⌃⇧⇥ / Ctrl+Shift+Tab).
276    PrevTab,
277    /// Reload the current page (⌘R / Ctrl+R).
278    ReloadPage,
279
280    // ── Navigation / Window ───────────────────────────────────────────────────
281    /// macOS Mission Control (⌃↑).
282    MissionControl,
283    /// macOS App Exposé — all windows for the current app (⌃↓).
284    AppExpose,
285    /// Switch to the previous desktop / Space.
286    PreviousDesktop,
287    /// Switch to the next desktop / Space.
288    NextDesktop,
289    /// Show the desktop (hide all windows).
290    ShowDesktop,
291    /// Open Launchpad.
292    LaunchpadShow,
293
294    // ── System ────────────────────────────────────────────────────────────────
295    /// Lock the screen.
296    LockScreen,
297    /// Capture a screenshot.
298    Screenshot,
299
300    // ── Media ────────────────────────────────────────────────────────────────
301    /// Toggle media play/pause.
302    PlayPause,
303    /// Skip to the next track.
304    NextTrack,
305    /// Go back to the previous track.
306    PrevTrack,
307    /// Increase system volume.
308    VolumeUp,
309    /// Decrease system volume.
310    VolumeDown,
311    /// Toggle system mute.
312    MuteVolume,
313
314    // ── DPI ──────────────────────────────────────────────────────────────────
315    /// Step through the configured DPI preset list (P1.7).
316    CycleDpiPresets,
317    /// Jump to a specific zero-based preset in the device's DPI preset list.
318    /// Out-of-range indices clamp to the list length at fire time (P1.7).
319    SetDpiPreset(u8),
320    /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
321    ToggleSmartShift,
322
323    // ── Scroll ───────────────────────────────────────────────────────────────
324    /// Synthesise a vertical scroll-up tick.
325    ScrollUp,
326    /// Synthesise a vertical scroll-down tick.
327    ScrollDown,
328    /// Synthesise a horizontal scroll-left tick.
329    HorizontalScrollLeft,
330    /// Synthesise a horizontal scroll-right tick.
331    HorizontalScrollRight,
332
333    // ── Custom ───────────────────────────────────────────────────────────────
334    /// Replay an arbitrary recorded key chord (P1.3).
335    ///
336    /// Holds the structured chord data so `execute` can post the real
337    /// keystroke (macOS: CGEventPost with the encoded modifier flags).
338    /// The `display` field is used by [`Action::label`] so the popover
339    /// shows the user-friendly chord name.
340    CustomShortcut(KeyCombo),
341}
342
343/// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or
344/// hand-authored in `config.toml`.
345///
346/// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format
347/// is a compact integer, not a string. `key_code` is the macOS virtual key
348/// (kVK_*); other platforms map at `execute` time when they grow real
349/// support.
350///
351/// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it
352/// from the captured chord; we keep it in the struct so older configs
353/// continue to render the same label without re-deriving on every load.
354#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
355pub struct KeyCombo {
356    /// Bitmask of [`Self::MOD_CMD`] etc.
357    pub modifiers: u8,
358    /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for
359    /// modifier-only placeholders that the recorder UI rejects.
360    pub key_code: u16,
361    /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a
362    /// generated label at runtime.
363    #[serde(default)]
364    pub display: String,
365}
366
367impl KeyCombo {
368    pub const MOD_CMD: u8 = 1 << 0;
369    pub const MOD_SHIFT: u8 = 1 << 1;
370    pub const MOD_CTRL: u8 = 1 << 2;
371    pub const MOD_OPTION: u8 = 1 << 3;
372
373    /// Build the human-readable label from the modifier bitmask + key code.
374    /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the
375    /// commonly-recognised letters; the recorder UI usually overrides this
376    /// with its own derivation.
377    #[must_use]
378    pub fn rendered_label(&self) -> String {
379        if !self.display.is_empty() {
380            return self.display.clone();
381        }
382        let mut out = String::new();
383        if self.modifiers & Self::MOD_CTRL != 0 {
384            out.push('⌃');
385        }
386        if self.modifiers & Self::MOD_OPTION != 0 {
387            out.push('⌥');
388        }
389        if self.modifiers & Self::MOD_SHIFT != 0 {
390            out.push('⇧');
391        }
392        if self.modifiers & Self::MOD_CMD != 0 {
393            out.push('⌘');
394        }
395        match self.key_code {
396            0x00 => out.push('A'),
397            0x01 => out.push('S'),
398            0x02 => out.push('D'),
399            0x03 => out.push('F'),
400            0x06 => out.push('Z'),
401            0x07 => out.push('X'),
402            0x08 => out.push('C'),
403            0x09 => out.push('V'),
404            0x0B => out.push('B'),
405            0x0C => out.push('Q'),
406            0x0D => out.push('W'),
407            0x0E => out.push('E'),
408            0x0F => out.push('R'),
409            0x10 => out.push('Y'),
410            0x11 => out.push('T'),
411            0x20 => out.push('U'),
412            0x22 => out.push('I'),
413            0x1F => out.push('O'),
414            0x23 => out.push('P'),
415            _ => {
416                use std::fmt::Write as _;
417                let _ = write!(out, "key 0x{:02X}", self.key_code);
418            }
419        }
420        out
421    }
422}
423
424impl Action {
425    /// Display label for the popover row.
426    ///
427    /// Returns `String` rather than `&str` so parameterized variants (e.g.
428    /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
429    /// includes their payload.
430    #[must_use]
431    pub fn label(&self) -> String {
432        match self {
433            Action::LeftClick => "Left Click".into(),
434            Action::RightClick => "Right Click".into(),
435            Action::MiddleClick => "Middle Click".into(),
436            Action::Copy => "Copy".into(),
437            Action::Paste => "Paste".into(),
438            Action::Cut => "Cut".into(),
439            Action::Undo => "Undo".into(),
440            Action::Redo => "Redo".into(),
441            Action::SelectAll => "Select All".into(),
442            Action::Find => "Find".into(),
443            Action::Save => "Save".into(),
444            Action::BrowserBack => "Browser Back".into(),
445            Action::BrowserForward => "Browser Forward".into(),
446            Action::NewTab => "New Tab".into(),
447            Action::CloseTab => "Close Tab".into(),
448            Action::ReopenTab => "Reopen Tab".into(),
449            Action::NextTab => "Next Tab".into(),
450            Action::PrevTab => "Previous Tab".into(),
451            Action::ReloadPage => "Reload Page".into(),
452            Action::MissionControl => "Mission Control".into(),
453            Action::AppExpose => "App Exposé".into(),
454            Action::PreviousDesktop => "Previous Desktop".into(),
455            Action::NextDesktop => "Next Desktop".into(),
456            Action::ShowDesktop => "Show Desktop".into(),
457            Action::LaunchpadShow => "Launchpad".into(),
458            Action::LockScreen => "Lock Screen".into(),
459            Action::Screenshot => "Screenshot".into(),
460            Action::PlayPause => "Play / Pause".into(),
461            Action::NextTrack => "Next Track".into(),
462            Action::PrevTrack => "Previous Track".into(),
463            Action::VolumeUp => "Volume Up".into(),
464            Action::VolumeDown => "Volume Down".into(),
465            Action::MuteVolume => "Mute".into(),
466            Action::CycleDpiPresets => "Cycle DPI Presets".into(),
467            Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
468            Action::ToggleSmartShift => "Toggle SmartShift".into(),
469            Action::ScrollUp => "Scroll Up".into(),
470            Action::ScrollDown => "Scroll Down".into(),
471            Action::HorizontalScrollLeft => "Scroll Left".into(),
472            Action::HorizontalScrollRight => "Scroll Right".into(),
473            Action::CustomShortcut(combo) => combo.rendered_label(),
474        }
475    }
476
477    /// Which [`Category`] this action belongs to, used for popover grouping.
478    #[must_use]
479    pub fn category(&self) -> Category {
480        match self {
481            Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
482            // CustomShortcut is assigned to Editing so it doesn't need a
483            // separate arm (it's not in the picker catalog).
484            Action::Copy
485            | Action::Paste
486            | Action::Cut
487            | Action::Undo
488            | Action::Redo
489            | Action::SelectAll
490            | Action::Find
491            | Action::Save
492            | Action::CustomShortcut(_) => Category::Editing,
493            Action::BrowserBack
494            | Action::BrowserForward
495            | Action::NewTab
496            | Action::CloseTab
497            | Action::ReopenTab
498            | Action::NextTab
499            | Action::PrevTab
500            | Action::ReloadPage => Category::Browser,
501            Action::MissionControl
502            | Action::AppExpose
503            | Action::PreviousDesktop
504            | Action::NextDesktop
505            | Action::ShowDesktop
506            | Action::LaunchpadShow => Category::Navigation,
507            Action::LockScreen | Action::Screenshot => Category::System,
508            Action::PlayPause
509            | Action::NextTrack
510            | Action::PrevTrack
511            | Action::VolumeUp
512            | Action::VolumeDown
513            | Action::MuteVolume => Category::Media,
514            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
515                Category::Dpi
516            }
517            Action::ScrollUp
518            | Action::ScrollDown
519            | Action::HorizontalScrollLeft
520            | Action::HorizontalScrollRight => Category::Scroll,
521        }
522    }
523
524    /// All pickable actions in a deterministic order.
525    ///
526    /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
527    /// "Record shortcut…" (P1.3), not selected from the catalog.
528    #[must_use]
529    pub fn catalog() -> Vec<Action> {
530        vec![
531            // Mouse
532            Action::LeftClick,
533            Action::RightClick,
534            Action::MiddleClick,
535            // Editing
536            Action::Copy,
537            Action::Paste,
538            Action::Cut,
539            Action::Undo,
540            Action::Redo,
541            Action::SelectAll,
542            Action::Find,
543            Action::Save,
544            // Browser
545            Action::BrowserBack,
546            Action::BrowserForward,
547            Action::NewTab,
548            Action::CloseTab,
549            Action::ReopenTab,
550            Action::NextTab,
551            Action::PrevTab,
552            Action::ReloadPage,
553            // Navigation
554            Action::MissionControl,
555            Action::AppExpose,
556            Action::PreviousDesktop,
557            Action::NextDesktop,
558            Action::ShowDesktop,
559            Action::LaunchpadShow,
560            // System
561            Action::LockScreen,
562            Action::Screenshot,
563            // Media
564            Action::PlayPause,
565            Action::NextTrack,
566            Action::PrevTrack,
567            Action::VolumeUp,
568            Action::VolumeDown,
569            Action::MuteVolume,
570            // DPI
571            Action::CycleDpiPresets,
572            Action::ToggleSmartShift,
573            // Scroll
574            Action::ScrollUp,
575            Action::ScrollDown,
576            Action::HorizontalScrollLeft,
577            Action::HorizontalScrollRight,
578        ]
579    }
580
581    /// Synthesise the OS-level event for this action.
582    ///
583    /// On macOS, key events are posted via `CGEventPost(kCGHIDEventTap, …)`
584    /// using virtual key codes from the standard US keyboard layout, and the
585    /// `LeftClick`/`RightClick`/`MiddleClick` variants synthesise a mouse click
586    /// at the current cursor location. The WindowServer actions (`MissionControl`,
587    /// `AppExpose`, `ShowDesktop`, `LaunchpadShow`) are posted straight to the
588    /// Dock via `CoreDockSendNotification`. Device-side actions (`CycleDpiPresets`,
589    /// `SetDpiPreset`, `ToggleSmartShift`) have no CGEvent equivalent and are
590    /// handled at the hook/HID layer, logging a trace here.
591    ///
592    /// On other platforms a warning is logged and the function returns
593    /// immediately — the binary compiles clean on all targets.
594    pub fn execute(&self) {
595        #[cfg(target_os = "macos")]
596        self.execute_macos();
597
598        #[cfg(not(target_os = "macos"))]
599        {
600            tracing::warn!(
601                action = self.label(),
602                "Action::execute unsupported on this platform"
603            );
604        }
605    }
606
607    /// macOS implementation: dispatch to the appropriate event helper.
608    #[cfg(target_os = "macos")]
609    fn execute_macos(&self) {
610        use core_graphics::event::{CGEventFlags, CGMouseButton};
611
612        // Modifier bit shorthands.
613        let cmd = CGEventFlags::CGEventFlagCommand;
614        let shift = CGEventFlags::CGEventFlagShift;
615        let ctrl = CGEventFlags::CGEventFlagControl;
616        let none = CGEventFlags::CGEventFlagNull;
617
618        match self {
619            // ── Mouse clicks: synthesise a click at the cursor ────────────────
620            // Remapping a *different* button to a click lands here (e.g. Back →
621            // MiddleClick). A button left on its own native click never reaches
622            // this — the hook passes it straight through to the OS.
623            Action::LeftClick => macos::post_click(CGMouseButton::Left),
624            Action::RightClick => macos::post_click(CGMouseButton::Right),
625            Action::MiddleClick => macos::post_click(CGMouseButton::Center),
626            // ── Editing ───────────────────────────────────────────────────────
627            Action::Copy => macos::post_key(VK_C, cmd),
628            Action::Paste => macos::post_key(VK_V, cmd),
629            Action::Cut => macos::post_key(VK_X, cmd),
630            Action::Undo => macos::post_key(VK_Z, cmd),
631            Action::Redo => macos::post_key(VK_Z, cmd | shift),
632            Action::SelectAll => macos::post_key(VK_A, cmd),
633            Action::Find => macos::post_key(VK_F, cmd),
634            Action::Save => macos::post_key(VK_S, cmd),
635            // ── Browser / Navigation ──────────────────────────────────────────
636            // BrowserBack/Forward: Cmd+[ / Cmd+] as keyboard fallback; hook
637            // layer handles the physical mouse buttons directly.
638            // kVK_ANSI_LeftBracket = 0x21, kVK_ANSI_RightBracket = 0x1E
639            Action::BrowserBack => macos::post_key(0x21, cmd),
640            Action::BrowserForward => macos::post_key(0x1E, cmd),
641            Action::NewTab => macos::post_key(VK_T, cmd),
642            Action::CloseTab => macos::post_key(VK_W, cmd),
643            Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
644            Action::NextTab => macos::post_key(VK_TAB, ctrl),
645            Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
646            Action::ReloadPage => macos::post_key(VK_R, cmd),
647            // ── Navigation / Window: posted straight to the Dock ──────────────
648            // Synthesising these shortcuts is unreliable — the WindowServer
649            // matcher needs the exact configured key (incl. the Fn flag) and
650            // Show Desktop ignores synthetic events entirely — so they go to the
651            // Dock via `CoreDockSendNotification`, which fires regardless of the
652            // user's keyboard settings.
653            Action::MissionControl => macos::mission_control(),
654            Action::AppExpose => macos::app_expose(),
655            Action::PreviousDesktop => macos::previous_desktop(),
656            Action::NextDesktop => macos::next_desktop(),
657            Action::ShowDesktop => macos::show_desktop(),
658            Action::LaunchpadShow => macos::launchpad(),
659            // ── System ────────────────────────────────────────────────────────
660            // Lock screen = Cmd+Ctrl+Q (kVK_ANSI_Q = 0x0C)
661            Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
662            // Screenshot = Cmd+Shift+3 (kVK_ANSI_3 = 0x14)
663            Action::Screenshot => macos::post_key(0x14, cmd | shift),
664            // ── Media ─────────────────────────────────────────────────────────
665            // NX_KEYTYPE_PLAY=16, NEXT=17, PREVIOUS=18 via NSSystemDefined stub.
666            Action::PlayPause => macos::post_media_key(0),
667            Action::NextTrack => macos::post_media_key(1),
668            Action::PrevTrack => macos::post_media_key(2),
669            // kVK_VolumeUp/Down/Mute = 0x48/0x49/0x4A (ADB codes)
670            Action::VolumeUp => macos::post_key(0x48, none),
671            Action::VolumeDown => macos::post_key(0x49, none),
672            Action::MuteVolume => macos::post_key(0x4A, none),
673            // ── DPI / SmartShift: handled at hook/HID layer ───────────────────
674            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
675                tracing::debug!(
676                    action = self.label(),
677                    "device action handled by hook/HID layer"
678                );
679            }
680            // ── Scroll ────────────────────────────────────────────────────────
681            Action::ScrollUp
682            | Action::ScrollDown
683            | Action::HorizontalScrollLeft
684            | Action::HorizontalScrollRight => macos::post_scroll(self),
685            // ── Custom ────────────────────────────────────────────────────────
686            Action::CustomShortcut(combo) => {
687                // P1.3: post the recorded chord. `key_code == 0` is the
688                // "modifier-only placeholder" the recorder UI rejects;
689                // skip it here too so a malformed config doesn't fire
690                // bare modifier presses.
691                if combo.key_code == 0 {
692                    tracing::warn!(
693                        chord = %combo.rendered_label(),
694                        "CustomShortcut with no key code — press ignored"
695                    );
696                    return;
697                }
698                let mut flags = CGEventFlags::CGEventFlagNull;
699                if combo.modifiers & KeyCombo::MOD_CMD != 0 {
700                    flags |= CGEventFlags::CGEventFlagCommand;
701                }
702                if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
703                    flags |= CGEventFlags::CGEventFlagShift;
704                }
705                if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
706                    flags |= CGEventFlags::CGEventFlagControl;
707                }
708                if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
709                    flags |= CGEventFlags::CGEventFlagAlternate;
710                }
711                macos::post_key(combo.key_code, flags);
712            }
713        }
714    }
715}
716
717/// Synthesise a horizontal scroll of `delta` wheel lines at the current focus.
718///
719/// Used by the gesture/thumbwheel capture watcher to re-inject the MX thumb
720/// wheel's scrolling after the wheel has been diverted over HID++ to capture its
721/// click. `delta` is the device's raw rotation; its sign follows the wheel's
722/// rotation convention and its magnitude (one line per rotation increment) may
723/// need tuning per device, since the diverted resolution differs from native.
724///
725/// No-op (logs nothing) on platforms without CGEvent.
726pub fn post_horizontal_scroll(delta: i32) {
727    #[cfg(target_os = "macos")]
728    macos::post_horizontal_scroll(delta);
729
730    #[cfg(not(target_os = "macos"))]
731    let _ = delta;
732}
733
734// ── macOS virtual key codes ────────────────────────────────────────────────
735// Source: <HIToolbox/Events.h> kVK_* constants. Values are layout-independent
736// for the US ANSI keyboard.
737#[cfg(target_os = "macos")]
738const VK_A: u16 = 0x00;
739#[cfg(target_os = "macos")]
740const VK_C: u16 = 0x08;
741#[cfg(target_os = "macos")]
742const VK_F: u16 = 0x03;
743#[cfg(target_os = "macos")]
744const VK_R: u16 = 0x0F;
745#[cfg(target_os = "macos")]
746const VK_S: u16 = 0x01;
747#[cfg(target_os = "macos")]
748const VK_T: u16 = 0x11;
749#[cfg(target_os = "macos")]
750const VK_V: u16 = 0x09;
751#[cfg(target_os = "macos")]
752const VK_W: u16 = 0x0D;
753#[cfg(target_os = "macos")]
754const VK_X: u16 = 0x07;
755#[cfg(target_os = "macos")]
756const VK_Z: u16 = 0x06;
757#[cfg(target_os = "macos")]
758const VK_TAB: u16 = 0x30;
759
760/// Platform helpers for synthesising OS-level input events on macOS.
761#[cfg(target_os = "macos")]
762mod macos {
763    use core_graphics::event::{
764        CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, ScrollEventUnit,
765    };
766    use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
767    use core_graphics::geometry::CGPoint;
768
769    use crate::binding::Action;
770
771    /// Post a mouse-down + mouse-up pair for `button` at the cursor's current
772    /// location.
773    ///
774    /// Posted at the HID tap location, so OpenLogi's own event tap sees the
775    /// synthetic click too: a `LeftClick`/`RightClick` flows straight through
776    /// (the tap never owns the primary buttons), and a `MiddleClick` is left
777    /// alone unless the user has *also* remapped the middle button.
778    pub(super) fn post_click(button: CGMouseButton) {
779        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
780            tracing::warn!("CGEventSource::new failed for click");
781            return;
782        };
783        // A fresh event reports the current pointer location; mouse events need
784        // an explicit position or they land at (0, 0).
785        let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
786        let (down, up) = match button {
787            CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
788            CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
789            CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
790        };
791        for (kind, phase) in [(down, "down"), (up, "up")] {
792            if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
793                ev.post(CGEventTapLocation::HID);
794            } else {
795                tracing::warn!(phase, "CGEvent::new_mouse_event failed");
796            }
797        }
798    }
799
800    /// Post a key-down + key-up pair for `vk` with `flags` set.
801    pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
802        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
803            tracing::warn!("CGEventSource::new failed");
804            return;
805        };
806        let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
807            tracing::warn!("CGEvent::new_keyboard_event(down) failed");
808            return;
809        };
810        down.set_flags(flags);
811        down.post(CGEventTapLocation::HID);
812        let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
813            tracing::warn!("CGEvent::new_keyboard_event(up) failed");
814            return;
815        };
816        up.set_flags(flags);
817        up.post(CGEventTapLocation::HID);
818    }
819
820    /// Post a media key event (Play/Pause, Next, Previous).
821    ///
822    /// `kind`: 0 = play/pause, 1 = next track, 2 = previous track.
823    ///
824    /// The proper implementation uses an `NSSystemDefined` event (type 14,
825    /// subtype 8) which requires AppKit bindings. Until those land this
826    /// function logs a debug trace so manual smoke tests can confirm the
827    /// correct execution path.
828    pub(super) fn post_media_key(kind: i32) {
829        // NX_KEYTYPE_PLAY=16, NX_KEYTYPE_NEXT=17, NX_KEYTYPE_PREVIOUS=18.
830        let nx_key: i64 = match kind {
831            0 => 16,
832            1 => 17,
833            _ => 18,
834        };
835        tracing::debug!(
836            nx_key,
837            "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
838        );
839    }
840
841    /// Post a synthetic scroll event for `action` (one of the `Scroll*` variants).
842    pub(super) fn post_scroll(action: &Action) {
843        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
844            tracing::warn!("CGEventSource::new failed for scroll");
845            return;
846        };
847        let (v, h): (i32, i32) = match action {
848            Action::ScrollUp => (3, 0),
849            Action::ScrollDown => (-3, 0),
850            Action::HorizontalScrollLeft => (0, -3),
851            Action::HorizontalScrollRight => (0, 3),
852            _ => return,
853        };
854        let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
855            tracing::warn!("CGEvent::new_scroll_event failed");
856            return;
857        };
858        ev.post(CGEventTapLocation::HID);
859    }
860
861    /// Post a horizontal scroll of `delta` lines (wheel2 axis). Line units suit
862    /// the thumb wheel's ratchet-like increments better than pixels.
863    pub(super) fn post_horizontal_scroll(delta: i32) {
864        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
865            tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
866            return;
867        };
868        let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
869            tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
870            return;
871        };
872        ev.post(CGEventTapLocation::HID);
873    }
874
875    pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
876    pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
877
878    use app_services::symbol as app_services_symbol;
879
880    /// Shared resolver for private ApplicationServices SPI used by the Dock and
881    /// symbolic-hotkey helpers.
882    #[allow(
883        unsafe_code,
884        reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
885    )]
886    mod app_services {
887        use std::ffi::{CStr, c_char, c_int, c_void};
888        use std::sync::OnceLock;
889
890        /// Resolve a symbol from ApplicationServices, caching the `dlopen`
891        /// handle for the process lifetime. Returns `None` if the framework or
892        /// symbol is unavailable on this macOS version.
893        pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
894            const RTLD_LAZY: c_int = 0x1;
895            const APP_SERVICES: &CStr =
896                c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
897            static HANDLE: OnceLock<usize> = OnceLock::new();
898
899            // SAFETY: `dlopen`/`dlsym` come from libSystem; APP_SERVICES and
900            // `symbol` are valid C strings. The handle is cached and
901            // intentionally never closed.
902            let sym = unsafe {
903                let handle =
904                    *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
905                if handle == 0 {
906                    return None;
907                }
908                dlsym(handle as *mut c_void, symbol.as_ptr())
909            };
910            (!sym.is_null()).then_some(sym)
911        }
912
913        unsafe extern "C" {
914            fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
915            fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
916        }
917    }
918
919    /// WindowServer window/space actions (Mission Control, App Exposé, Show
920    /// Desktop, Launchpad).
921    ///
922    /// These are driven by the Dock, and synthesising their keyboard shortcut is
923    /// unreliable — the WindowServer matcher needs the exact configured key
924    /// (incl. the Fn flag) and Show Desktop's in particular doesn't respond. So
925    /// we post the action straight to the Dock via the private
926    /// `CoreDockSendNotification` SPI, which fires it regardless of the user's
927    /// Keyboard settings.
928    ///
929    /// Isolated in its own submodule so the `unsafe` the `dlopen`/`dlsym` FFI
930    /// needs is scoped here rather than spread across the platform helpers.
931    #[allow(
932        unsafe_code,
933        reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
934    )]
935    mod dock {
936        use std::ffi::{c_int, c_void};
937
938        use core_foundation::base::TCFType;
939        use core_foundation::string::CFString;
940
941        use super::app_services_symbol;
942
943        /// Show all windows across spaces (Mission Control).
944        pub(crate) fn mission_control() {
945            send("com.apple.expose.awake");
946        }
947
948        /// Show the front app's windows (App Exposé).
949        pub(crate) fn app_expose() {
950            send("com.apple.expose.front.awake");
951        }
952
953        /// Move all windows aside to reveal the desktop.
954        pub(crate) fn show_desktop() {
955            send("com.apple.showdesktop.awake");
956        }
957
958        /// Toggle Launchpad. A no-op on macOS 26, which removed Launchpad.
959        pub(crate) fn launchpad() {
960            send("com.apple.launchpad.toggle");
961        }
962
963        /// Post `notification` to the Dock. Logs and returns on any failure.
964        fn send(notification: &str) {
965            let Some(core_dock_send) = core_dock_send_notification() else {
966                tracing::warn!(notification, "CoreDockSendNotification unavailable");
967                return;
968            };
969            let name = CFString::new(notification);
970            // SAFETY: resolved AppServices symbol called with its documented
971            // signature; `name` is a live CFString for the call's duration.
972            let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
973            if err != 0 {
974                tracing::warn!(notification, err, "CoreDockSendNotification failed");
975            }
976        }
977
978        type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
979
980        /// Resolve `CoreDockSendNotification` from `ApplicationServices`, caching
981        /// the `dlopen` handle for the process lifetime. `None` if unavailable.
982        fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
983            let sym = app_services_symbol(c"CoreDockSendNotification")?;
984            // SAFETY: the symbol, when present, has the documented signature.
985            Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
986        }
987    }
988
989    /// macOS Space switching actions.
990    ///
991    /// Use the system symbolic hotkey records for "Move left a space" (79) and
992    /// "Move right a space" (81). That respects the user's configured shortcut
993    /// instead of assuming Ctrl+Left/Right, and temporarily enables the symbolic
994    /// hotkey when the user has disabled it.
995    #[allow(
996        unsafe_code,
997        reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
998    )]
999    mod symbolic_hotkey {
1000        use std::ffi::{c_int, c_uint, c_ushort, c_void};
1001
1002        use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
1003        use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1004
1005        use super::app_services_symbol;
1006
1007        const SPACE_LEFT: u32 = 79;
1008        const SPACE_RIGHT: u32 = 81;
1009
1010        /// Switch to the previous desktop / Space.
1011        pub(crate) fn previous_desktop() {
1012            post_symbolic_hotkey(SPACE_LEFT);
1013        }
1014
1015        /// Switch to the next desktop / Space.
1016        pub(crate) fn next_desktop() {
1017            post_symbolic_hotkey(SPACE_RIGHT);
1018        }
1019
1020        fn post_symbolic_hotkey(hotkey: u32) {
1021            let Some(cgs) = cgs_hotkey_api() else {
1022                tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
1023                return;
1024            };
1025
1026            let mut key_equivalent = 0_u16;
1027            let mut virtual_key = 0_u16;
1028            let mut modifiers = 0_u32;
1029
1030            // SAFETY: resolved AppServices symbols are called with their
1031            // expected signatures and valid out-parameters.
1032            let err = unsafe {
1033                (cgs.get_value)(
1034                    hotkey,
1035                    &raw mut key_equivalent,
1036                    &raw mut virtual_key,
1037                    &raw mut modifiers,
1038                )
1039            };
1040            if err != 0 {
1041                tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
1042                return;
1043            }
1044
1045            // SAFETY: resolved AppServices symbol called with its expected
1046            // signature.
1047            let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
1048            if !was_enabled {
1049                // SAFETY: resolved AppServices symbol called with its expected
1050                // signature.
1051                let err = unsafe { (cgs.set_enabled)(hotkey, true) };
1052                if err != 0 {
1053                    tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
1054                }
1055            }
1056
1057            post_key(virtual_key, modifiers);
1058
1059            if !was_enabled {
1060                // SAFETY: resolved AppServices symbol called with its expected
1061                // signature.
1062                let err = unsafe { (cgs.set_enabled)(hotkey, false) };
1063                if err != 0 {
1064                    tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
1065                }
1066            }
1067        }
1068
1069        fn post_key(vk: u16, modifiers: u32) {
1070            let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1071                tracing::warn!("CGEventSource::new failed for symbolic hotkey");
1072                return;
1073            };
1074            let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1075                tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
1076                return;
1077            };
1078            let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
1079            down.set_flags(flags);
1080            down.post(CGEventTapLocation::Session);
1081
1082            let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1083                tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
1084                return;
1085            };
1086            up.set_flags(flags);
1087            up.post(CGEventTapLocation::Session);
1088        }
1089
1090        #[derive(Clone, Copy)]
1091        struct CgsHotkeyApi {
1092            get_value: CgsGetSymbolicHotKeyValueFn,
1093            is_enabled: CgsIsSymbolicHotKeyEnabledFn,
1094            set_enabled: CgsSetSymbolicHotKeyEnabledFn,
1095        }
1096
1097        type CgsGetSymbolicHotKeyValueFn =
1098            unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
1099        type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
1100        type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
1101
1102        fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
1103            let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
1104            let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
1105            let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
1106
1107            // SAFETY: the symbols, when present, have the private SPI
1108            // signatures declared above.
1109            Some(unsafe {
1110                CgsHotkeyApi {
1111                    get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
1112                        get_value,
1113                    ),
1114                    is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
1115                        is_enabled,
1116                    ),
1117                    set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
1118                        set_enabled,
1119                    ),
1120                }
1121            })
1122        }
1123    }
1124}
1125
1126/// Sensible defaults for a fresh device so the panel isn't empty on first run.
1127///
1128/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
1129/// MX-line devices: thumb wheel click → App Exposé, gesture button →
1130/// Mission Control. The thumb wheel isn't captured yet; the gesture button is
1131/// (per-direction, see [`default_gesture_binding`]). The bindings persist
1132/// regardless so the user only configures once.
1133///
1134/// `GestureButton`'s entry here is the legacy single-binding placeholder;
1135/// the per-direction sub-bindings live in [`default_gesture_binding`] and
1136/// are what the UI now edits.
1137#[must_use]
1138pub fn default_binding(button: ButtonId) -> Action {
1139    match button {
1140        ButtonId::LeftClick => Action::LeftClick,
1141        ButtonId::RightClick => Action::RightClick,
1142        ButtonId::MiddleClick => Action::MiddleClick,
1143        ButtonId::Back => Action::BrowserBack,
1144        ButtonId::Forward => Action::BrowserForward,
1145        ButtonId::DpiToggle => Action::CycleDpiPresets,
1146        ButtonId::Thumbwheel => Action::AppExpose,
1147        ButtonId::GestureButton => Action::MissionControl,
1148    }
1149}
1150
1151/// Per-direction defaults for the gesture button. These are captured live over
1152/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
1153/// defaults give the picker something sensible to show on first run.
1154#[must_use]
1155pub fn default_gesture_binding(direction: GestureDirection) -> Action {
1156    match direction {
1157        GestureDirection::Up => Action::MissionControl,
1158        GestureDirection::Down => Action::ShowDesktop,
1159        GestureDirection::Left => Action::PrevTab,
1160        GestureDirection::Right => Action::NextTab,
1161        GestureDirection::Click => Action::AppExpose,
1162    }
1163}
1164
1165#[cfg(test)]
1166#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
1167mod tests {
1168    use std::collections::BTreeMap;
1169
1170    use serde::{Deserialize, Serialize};
1171
1172    use super::*;
1173
1174    // ── Roundtrip wrapper: defined here so it precedes any `let` statements ──
1175
1176    /// Minimal TOML-serializable wrapper used by `roundtrip`.
1177    /// Defined at module scope to satisfy `clippy::items_after_statements`.
1178    #[derive(Serialize, Deserialize)]
1179    struct RoundtripWrapper {
1180        binding: BTreeMap<ButtonId, Action>,
1181    }
1182
1183    // ── Catalog tests ─────────────────────────────────────────────────────────
1184
1185    #[test]
1186    fn catalog_has_at_least_29_entries() {
1187        let catalog = Action::catalog();
1188        assert!(
1189            catalog.len() >= 29,
1190            "catalog has {} entries, need ≥ 29",
1191            catalog.len()
1192        );
1193    }
1194
1195    #[test]
1196    fn catalog_excludes_custom_shortcut() {
1197        let catalog = Action::catalog();
1198        for action in &catalog {
1199            assert!(
1200                !matches!(action, Action::CustomShortcut(_)),
1201                "catalog must not contain CustomShortcut"
1202            );
1203        }
1204    }
1205
1206    // ── Gesture classification ────────────────────────────────────────────────
1207
1208    #[test]
1209    fn detect_swipe_below_threshold_keeps_accumulating() {
1210        // Too little travel to commit — caller keeps summing raw-XY.
1211        assert_eq!(detect_swipe(40, 5), None);
1212        assert_eq!(detect_swipe(0, 0), None);
1213    }
1214
1215    #[test]
1216    fn detect_swipe_commits_clean_direction() {
1217        assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
1218        assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
1219        assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
1220        assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
1221    }
1222
1223    #[test]
1224    fn detect_swipe_rejects_diagonal() {
1225        // Past the threshold but too diagonal (cross axis beyond the band).
1226        assert_eq!(detect_swipe(60, 60), None);
1227        assert_eq!(detect_swipe(-60, -60), None);
1228    }
1229
1230    // ── TOML roundtrip ────────────────────────────────────────────────────────
1231
1232    /// Serialize then deserialize `action` through TOML, using a wrapper
1233    /// struct because TOML requires a top-level table.
1234    fn roundtrip(action: &Action) -> Action {
1235        let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
1236        map.insert(ButtonId::Back, action.clone());
1237        let w = RoundtripWrapper { binding: map };
1238        let s = toml::to_string(&w).expect("serialize");
1239        let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
1240        back.binding
1241            .into_values()
1242            .next()
1243            .expect("binding present after roundtrip")
1244    }
1245
1246    #[test]
1247    fn all_catalog_variants_roundtrip_toml() {
1248        for action in Action::catalog() {
1249            let back = roundtrip(&action);
1250            assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
1251        }
1252    }
1253
1254    #[test]
1255    fn custom_shortcut_roundtrips_toml() {
1256        let action = Action::CustomShortcut(KeyCombo {
1257            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1258            key_code: 0x23, // kVK_ANSI_P
1259            display: "⌘⇧P".into(),
1260        });
1261        assert_eq!(roundtrip(&action), action);
1262    }
1263
1264    #[test]
1265    fn key_combo_rendered_label_uses_display_when_set() {
1266        let combo = KeyCombo {
1267            modifiers: 0,
1268            key_code: 0,
1269            display: "preset".into(),
1270        };
1271        assert_eq!(combo.rendered_label(), "preset");
1272    }
1273
1274    #[test]
1275    fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
1276        let combo = KeyCombo {
1277            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1278            key_code: 0x23, // P
1279            display: String::new(),
1280        };
1281        assert_eq!(combo.rendered_label(), "⇧⌘P");
1282    }
1283
1284    // ── Category tests ────────────────────────────────────────────────────────
1285
1286    #[test]
1287    fn category_editing_variants() {
1288        assert_eq!(Action::Copy.category(), Category::Editing);
1289        assert_eq!(Action::Undo.category(), Category::Editing);
1290        assert_eq!(Action::SelectAll.category(), Category::Editing);
1291        assert_eq!(Action::Find.category(), Category::Editing);
1292        assert_eq!(Action::Save.category(), Category::Editing);
1293        assert_eq!(Action::Cut.category(), Category::Editing);
1294        assert_eq!(Action::Redo.category(), Category::Editing);
1295        assert_eq!(Action::Paste.category(), Category::Editing);
1296    }
1297
1298    #[test]
1299    fn category_browser_variants() {
1300        assert_eq!(Action::BrowserBack.category(), Category::Browser);
1301        assert_eq!(Action::BrowserForward.category(), Category::Browser);
1302        assert_eq!(Action::NewTab.category(), Category::Browser);
1303        assert_eq!(Action::CloseTab.category(), Category::Browser);
1304        assert_eq!(Action::ReopenTab.category(), Category::Browser);
1305        assert_eq!(Action::NextTab.category(), Category::Browser);
1306        assert_eq!(Action::PrevTab.category(), Category::Browser);
1307        assert_eq!(Action::ReloadPage.category(), Category::Browser);
1308    }
1309
1310    #[test]
1311    fn category_media_variants() {
1312        assert_eq!(Action::PlayPause.category(), Category::Media);
1313        assert_eq!(Action::NextTrack.category(), Category::Media);
1314        assert_eq!(Action::PrevTrack.category(), Category::Media);
1315        assert_eq!(Action::VolumeUp.category(), Category::Media);
1316        assert_eq!(Action::VolumeDown.category(), Category::Media);
1317        assert_eq!(Action::MuteVolume.category(), Category::Media);
1318    }
1319
1320    #[test]
1321    fn category_mouse_variants() {
1322        assert_eq!(Action::LeftClick.category(), Category::Mouse);
1323        assert_eq!(Action::RightClick.category(), Category::Mouse);
1324        assert_eq!(Action::MiddleClick.category(), Category::Mouse);
1325    }
1326
1327    #[test]
1328    fn category_dpi_variants() {
1329        assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
1330        assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
1331    }
1332
1333    #[test]
1334    fn category_scroll_variants() {
1335        assert_eq!(Action::ScrollUp.category(), Category::Scroll);
1336        assert_eq!(Action::ScrollDown.category(), Category::Scroll);
1337        assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
1338        assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
1339    }
1340
1341    #[test]
1342    fn category_navigation_variants() {
1343        assert_eq!(Action::MissionControl.category(), Category::Navigation);
1344        assert_eq!(Action::AppExpose.category(), Category::Navigation);
1345        assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
1346        assert_eq!(Action::NextDesktop.category(), Category::Navigation);
1347        assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
1348        assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1349    }
1350
1351    #[test]
1352    fn category_system_variants() {
1353        assert_eq!(Action::LockScreen.category(), Category::System);
1354        assert_eq!(Action::Screenshot.category(), Category::System);
1355    }
1356
1357    // ── Category label smoke test ─────────────────────────────────────────────
1358
1359    #[test]
1360    fn category_labels_are_nonempty() {
1361        let categories = [
1362            Category::Editing,
1363            Category::Browser,
1364            Category::Media,
1365            Category::Mouse,
1366            Category::Dpi,
1367            Category::Scroll,
1368            Category::Navigation,
1369            Category::System,
1370        ];
1371        for cat in categories {
1372            assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1373        }
1374    }
1375
1376    // ── Default binding ───────────────────────────────────────────────────────
1377
1378    #[test]
1379    fn dpi_toggle_default_is_cycle_dpi_presets() {
1380        assert_eq!(
1381            default_binding(ButtonId::DpiToggle),
1382            Action::CycleDpiPresets
1383        );
1384    }
1385}