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/// Classify an accumulated raw-XY swipe — the summed `dx`/`dy` the device
124/// reports while the gesture button is held — into one of the five
125/// [`GestureDirection`] slots.
126///
127/// Returns [`GestureDirection::Click`] when the total travel is shorter than
128/// `min_travel` (a press with no meaningful movement). Otherwise the dominant
129/// axis decides: larger `|dx|` ⇒ Left/Right, larger `|dy|` ⇒ Up/Down, with ties
130/// favouring the horizontal axis. Coordinates follow the device's raw-XY
131/// convention (`+x` = right, `+y` = down), so an upward swipe (negative `dy`)
132/// maps to [`GestureDirection::Up`]; flip the sign at the call site for a device
133/// that reports an inverted axis.
134#[must_use]
135pub fn classify_gesture(dx: i32, dy: i32, min_travel: u32) -> GestureDirection {
136    let dxl = i64::from(dx);
137    let dyl = i64::from(dy);
138    let min = i64::from(min_travel);
139    if dxl * dxl + dyl * dyl < min * min {
140        return GestureDirection::Click;
141    }
142    if dx.unsigned_abs() >= dy.unsigned_abs() {
143        if dx >= 0 {
144            GestureDirection::Right
145        } else {
146            GestureDirection::Left
147        }
148    } else if dy >= 0 {
149        GestureDirection::Down
150    } else {
151        GestureDirection::Up
152    }
153}
154
155/// Grouping for popover section headers.
156///
157/// Used by [`Action::category`] and rendered as a small muted label above
158/// each group in the action picker.
159#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
160pub enum Category {
161    /// Cut, copy, paste, undo, redo, select-all, find, save.
162    Editing,
163    /// Browser navigation: tabs, page reload, back/forward.
164    Browser,
165    /// Playback and volume controls.
166    Media,
167    /// Physical mouse clicks.
168    Mouse,
169    /// DPI cycle and SmartShift.
170    Dpi,
171    /// Scroll direction shortcuts.
172    Scroll,
173    /// Window/app navigation: Mission Control, Launchpad, etc.
174    Navigation,
175    /// Lock screen, show desktop, system-level actions.
176    System,
177}
178
179impl Category {
180    /// Short label for popover section headers (already uppercase so callers
181    /// don't have to transform it).
182    #[must_use]
183    pub fn label(self) -> &'static str {
184        match self {
185            Category::Editing => "EDITING",
186            Category::Browser => "BROWSER",
187            Category::Media => "MEDIA",
188            Category::Mouse => "MOUSE",
189            Category::Dpi => "DPI",
190            Category::Scroll => "SCROLL",
191            Category::Navigation => "NAVIGATION",
192            Category::System => "SYSTEM",
193        }
194    }
195}
196
197/// What pressing a [`ButtonId`] should do.
198///
199/// Serialization uses serde's default external tagging: unit variants
200/// serialize as a bare string (`"BrowserBack"`) and the tuple variant
201/// serializes as a single-key table (`{ CustomShortcut = "my chord" }`).
202///
203/// **Stability contract:** existing variant *names* are frozen — they form the
204/// on-disk `config.toml` schema. New variants may be appended freely; removing
205/// or renaming a variant requires a `schema_version` bump and a migration.
206///
207/// `Action::execute` synthesizes the OS-level event for each variant.
208/// On macOS it posts the event via `CGEventPost(kCGHIDEventTap, …)`.
209/// On other platforms it logs a warning and returns immediately — the binary
210/// compiles on all targets.
211///
212/// # Manual verification
213///
214/// `execute` is intentionally excluded from the automated test suite because
215/// it would need to intercept the OS event queue. Smoke-test it manually:
216/// bind a button to any action in the GUI and confirm the expected system event
217/// fires when the button is pressed.
218#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
219pub enum Action {
220    // ── Mouse ────────────────────────────────────────────────────────────────
221    /// Primary mouse button.
222    LeftClick,
223    /// Secondary mouse button.
224    RightClick,
225    /// Middle mouse button (wheel click).
226    MiddleClick,
227
228    // ── Editing ──────────────────────────────────────────────────────────────
229    /// Copy the current selection (⌘C / Ctrl+C).
230    Copy,
231    /// Paste from the clipboard (⌘V / Ctrl+V).
232    Paste,
233    /// Cut the current selection (⌘X / Ctrl+X).
234    Cut,
235    /// Undo the last action (⌘Z / Ctrl+Z).
236    Undo,
237    /// Redo the last undone action (⌘⇧Z / Ctrl+Y).
238    Redo,
239    /// Select all content (⌘A / Ctrl+A).
240    SelectAll,
241    /// Open the find / search bar (⌘F / Ctrl+F).
242    Find,
243    /// Save the current document (⌘S / Ctrl+S).
244    Save,
245
246    // ── Browser / Navigation ──────────────────────────────────────────────────
247    /// Navigate backward in browser history.
248    BrowserBack,
249    /// Navigate forward in browser history.
250    BrowserForward,
251    /// Open a new tab (⌘T / Ctrl+T).
252    NewTab,
253    /// Close the current tab (⌘W / Ctrl+W).
254    CloseTab,
255    /// Reopen the last closed tab (⌘⇧T / Ctrl+Shift+T).
256    ReopenTab,
257    /// Switch to the next tab (⌃⇥ / Ctrl+Tab).
258    NextTab,
259    /// Switch to the previous tab (⌃⇧⇥ / Ctrl+Shift+Tab).
260    PrevTab,
261    /// Reload the current page (⌘R / Ctrl+R).
262    ReloadPage,
263
264    // ── Navigation / Window ───────────────────────────────────────────────────
265    /// macOS Mission Control (⌃↑).
266    MissionControl,
267    /// macOS App Exposé — all windows for the current app (⌃↓).
268    AppExpose,
269    /// Show the desktop (hide all windows).
270    ShowDesktop,
271    /// Open Launchpad.
272    LaunchpadShow,
273
274    // ── System ────────────────────────────────────────────────────────────────
275    /// Lock the screen.
276    LockScreen,
277    /// Capture a screenshot.
278    Screenshot,
279
280    // ── Media ────────────────────────────────────────────────────────────────
281    /// Toggle media play/pause.
282    PlayPause,
283    /// Skip to the next track.
284    NextTrack,
285    /// Go back to the previous track.
286    PrevTrack,
287    /// Increase system volume.
288    VolumeUp,
289    /// Decrease system volume.
290    VolumeDown,
291    /// Toggle system mute.
292    MuteVolume,
293
294    // ── DPI ──────────────────────────────────────────────────────────────────
295    /// Step through the configured DPI preset list (P1.7).
296    CycleDpiPresets,
297    /// Jump to a specific zero-based preset in the device's DPI preset list.
298    /// Out-of-range indices clamp to the list length at fire time (P1.7).
299    SetDpiPreset(u8),
300    /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
301    ToggleSmartShift,
302
303    // ── Scroll ───────────────────────────────────────────────────────────────
304    /// Synthesise a vertical scroll-up tick.
305    ScrollUp,
306    /// Synthesise a vertical scroll-down tick.
307    ScrollDown,
308    /// Synthesise a horizontal scroll-left tick.
309    HorizontalScrollLeft,
310    /// Synthesise a horizontal scroll-right tick.
311    HorizontalScrollRight,
312
313    // ── Custom ───────────────────────────────────────────────────────────────
314    /// Replay an arbitrary recorded key chord (P1.3).
315    ///
316    /// Holds the structured chord data so `execute` can post the real
317    /// keystroke (macOS: CGEventPost with the encoded modifier flags).
318    /// The `display` field is used by [`Action::label`] so the popover
319    /// shows the user-friendly chord name.
320    CustomShortcut(KeyCombo),
321}
322
323/// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or
324/// hand-authored in `config.toml`.
325///
326/// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format
327/// is a compact integer, not a string. `key_code` is the macOS virtual key
328/// (kVK_*); other platforms map at `execute` time when they grow real
329/// support.
330///
331/// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it
332/// from the captured chord; we keep it in the struct so older configs
333/// continue to render the same label without re-deriving on every load.
334#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
335pub struct KeyCombo {
336    /// Bitmask of [`Self::MOD_CMD`] etc.
337    pub modifiers: u8,
338    /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for
339    /// modifier-only placeholders that the recorder UI rejects.
340    pub key_code: u16,
341    /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a
342    /// generated label at runtime.
343    #[serde(default)]
344    pub display: String,
345}
346
347impl KeyCombo {
348    pub const MOD_CMD: u8 = 1 << 0;
349    pub const MOD_SHIFT: u8 = 1 << 1;
350    pub const MOD_CTRL: u8 = 1 << 2;
351    pub const MOD_OPTION: u8 = 1 << 3;
352
353    /// Build the human-readable label from the modifier bitmask + key code.
354    /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the
355    /// commonly-recognised letters; the recorder UI usually overrides this
356    /// with its own derivation.
357    #[must_use]
358    pub fn rendered_label(&self) -> String {
359        if !self.display.is_empty() {
360            return self.display.clone();
361        }
362        let mut out = String::new();
363        if self.modifiers & Self::MOD_CTRL != 0 {
364            out.push('⌃');
365        }
366        if self.modifiers & Self::MOD_OPTION != 0 {
367            out.push('⌥');
368        }
369        if self.modifiers & Self::MOD_SHIFT != 0 {
370            out.push('⇧');
371        }
372        if self.modifiers & Self::MOD_CMD != 0 {
373            out.push('⌘');
374        }
375        match self.key_code {
376            0x00 => out.push('A'),
377            0x01 => out.push('S'),
378            0x02 => out.push('D'),
379            0x03 => out.push('F'),
380            0x06 => out.push('Z'),
381            0x07 => out.push('X'),
382            0x08 => out.push('C'),
383            0x09 => out.push('V'),
384            0x0B => out.push('B'),
385            0x0C => out.push('Q'),
386            0x0D => out.push('W'),
387            0x0E => out.push('E'),
388            0x0F => out.push('R'),
389            0x10 => out.push('Y'),
390            0x11 => out.push('T'),
391            0x20 => out.push('U'),
392            0x22 => out.push('I'),
393            0x1F => out.push('O'),
394            0x23 => out.push('P'),
395            _ => {
396                use std::fmt::Write as _;
397                let _ = write!(out, "key 0x{:02X}", self.key_code);
398            }
399        }
400        out
401    }
402}
403
404impl Action {
405    /// Display label for the popover row.
406    ///
407    /// Returns `String` rather than `&str` so parameterized variants (e.g.
408    /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
409    /// includes their payload.
410    #[must_use]
411    pub fn label(&self) -> String {
412        match self {
413            Action::LeftClick => "Left Click".into(),
414            Action::RightClick => "Right Click".into(),
415            Action::MiddleClick => "Middle Click".into(),
416            Action::Copy => "Copy".into(),
417            Action::Paste => "Paste".into(),
418            Action::Cut => "Cut".into(),
419            Action::Undo => "Undo".into(),
420            Action::Redo => "Redo".into(),
421            Action::SelectAll => "Select All".into(),
422            Action::Find => "Find".into(),
423            Action::Save => "Save".into(),
424            Action::BrowserBack => "Browser Back".into(),
425            Action::BrowserForward => "Browser Forward".into(),
426            Action::NewTab => "New Tab".into(),
427            Action::CloseTab => "Close Tab".into(),
428            Action::ReopenTab => "Reopen Tab".into(),
429            Action::NextTab => "Next Tab".into(),
430            Action::PrevTab => "Previous Tab".into(),
431            Action::ReloadPage => "Reload Page".into(),
432            Action::MissionControl => "Mission Control".into(),
433            Action::AppExpose => "App Exposé".into(),
434            Action::ShowDesktop => "Show Desktop".into(),
435            Action::LaunchpadShow => "Launchpad".into(),
436            Action::LockScreen => "Lock Screen".into(),
437            Action::Screenshot => "Screenshot".into(),
438            Action::PlayPause => "Play / Pause".into(),
439            Action::NextTrack => "Next Track".into(),
440            Action::PrevTrack => "Previous Track".into(),
441            Action::VolumeUp => "Volume Up".into(),
442            Action::VolumeDown => "Volume Down".into(),
443            Action::MuteVolume => "Mute".into(),
444            Action::CycleDpiPresets => "Cycle DPI Presets".into(),
445            Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
446            Action::ToggleSmartShift => "Toggle SmartShift".into(),
447            Action::ScrollUp => "Scroll Up".into(),
448            Action::ScrollDown => "Scroll Down".into(),
449            Action::HorizontalScrollLeft => "Scroll Left".into(),
450            Action::HorizontalScrollRight => "Scroll Right".into(),
451            Action::CustomShortcut(combo) => combo.rendered_label(),
452        }
453    }
454
455    /// Which [`Category`] this action belongs to, used for popover grouping.
456    #[must_use]
457    pub fn category(&self) -> Category {
458        match self {
459            Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
460            // CustomShortcut is assigned to Editing so it doesn't need a
461            // separate arm (it's not in the picker catalog).
462            Action::Copy
463            | Action::Paste
464            | Action::Cut
465            | Action::Undo
466            | Action::Redo
467            | Action::SelectAll
468            | Action::Find
469            | Action::Save
470            | Action::CustomShortcut(_) => Category::Editing,
471            Action::BrowserBack
472            | Action::BrowserForward
473            | Action::NewTab
474            | Action::CloseTab
475            | Action::ReopenTab
476            | Action::NextTab
477            | Action::PrevTab
478            | Action::ReloadPage => Category::Browser,
479            Action::MissionControl
480            | Action::AppExpose
481            | Action::ShowDesktop
482            | Action::LaunchpadShow => Category::Navigation,
483            Action::LockScreen | Action::Screenshot => Category::System,
484            Action::PlayPause
485            | Action::NextTrack
486            | Action::PrevTrack
487            | Action::VolumeUp
488            | Action::VolumeDown
489            | Action::MuteVolume => Category::Media,
490            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
491                Category::Dpi
492            }
493            Action::ScrollUp
494            | Action::ScrollDown
495            | Action::HorizontalScrollLeft
496            | Action::HorizontalScrollRight => Category::Scroll,
497        }
498    }
499
500    /// All pickable actions in a deterministic order.
501    ///
502    /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
503    /// "Record shortcut…" (P1.3), not selected from the catalog.
504    #[must_use]
505    pub fn catalog() -> Vec<Action> {
506        vec![
507            // Mouse
508            Action::LeftClick,
509            Action::RightClick,
510            Action::MiddleClick,
511            // Editing
512            Action::Copy,
513            Action::Paste,
514            Action::Cut,
515            Action::Undo,
516            Action::Redo,
517            Action::SelectAll,
518            Action::Find,
519            Action::Save,
520            // Browser
521            Action::BrowserBack,
522            Action::BrowserForward,
523            Action::NewTab,
524            Action::CloseTab,
525            Action::ReopenTab,
526            Action::NextTab,
527            Action::PrevTab,
528            Action::ReloadPage,
529            // Navigation
530            Action::MissionControl,
531            Action::AppExpose,
532            Action::ShowDesktop,
533            Action::LaunchpadShow,
534            // System
535            Action::LockScreen,
536            Action::Screenshot,
537            // Media
538            Action::PlayPause,
539            Action::NextTrack,
540            Action::PrevTrack,
541            Action::VolumeUp,
542            Action::VolumeDown,
543            Action::MuteVolume,
544            // DPI
545            Action::CycleDpiPresets,
546            Action::ToggleSmartShift,
547            // Scroll
548            Action::ScrollUp,
549            Action::ScrollDown,
550            Action::HorizontalScrollLeft,
551            Action::HorizontalScrollRight,
552        ]
553    }
554
555    /// Synthesise the OS-level event for this action.
556    ///
557    /// On macOS, key events are posted via `CGEventPost(kCGHIDEventTap, …)`
558    /// using virtual key codes from the standard US keyboard layout.
559    /// Mouse-click variants and actions with no direct CGEvent equivalent
560    /// (e.g. `CycleDpiPresets`, `ToggleSmartShift`) are handled at the hook
561    /// layer (P0.1) and log a debug trace here instead.
562    ///
563    /// On other platforms a warning is logged and the function returns
564    /// immediately — the binary compiles clean on all targets.
565    pub fn execute(&self) {
566        #[cfg(target_os = "macos")]
567        self.execute_macos();
568
569        #[cfg(not(target_os = "macos"))]
570        {
571            tracing::warn!(
572                action = self.label(),
573                "Action::execute unsupported on this platform"
574            );
575        }
576    }
577
578    /// macOS implementation: dispatch to the appropriate event helper.
579    #[cfg(target_os = "macos")]
580    fn execute_macos(&self) {
581        use core_graphics::event::CGEventFlags;
582
583        // Modifier bit shorthands.
584        let cmd = CGEventFlags::CGEventFlagCommand;
585        let shift = CGEventFlags::CGEventFlagShift;
586        let ctrl = CGEventFlags::CGEventFlagControl;
587        let none = CGEventFlags::CGEventFlagNull;
588
589        match self {
590            // ── Mouse clicks: delegated to the hook layer ─────────────────────
591            Action::LeftClick | Action::RightClick | Action::MiddleClick => {
592                tracing::debug!(
593                    action = self.label(),
594                    "mouse-click execute delegated to hook layer"
595                );
596            }
597            // ── Editing ───────────────────────────────────────────────────────
598            Action::Copy => macos::post_key(VK_C, cmd),
599            Action::Paste => macos::post_key(VK_V, cmd),
600            Action::Cut => macos::post_key(VK_X, cmd),
601            Action::Undo => macos::post_key(VK_Z, cmd),
602            Action::Redo => macos::post_key(VK_Z, cmd | shift),
603            Action::SelectAll => macos::post_key(VK_A, cmd),
604            Action::Find => macos::post_key(VK_F, cmd),
605            Action::Save => macos::post_key(VK_S, cmd),
606            // ── Browser / Navigation ──────────────────────────────────────────
607            // BrowserBack/Forward: Cmd+[ / Cmd+] as keyboard fallback; hook
608            // layer handles the physical mouse buttons directly.
609            // kVK_ANSI_LeftBracket = 0x21, kVK_ANSI_RightBracket = 0x1E
610            Action::BrowserBack => macos::post_key(0x21, cmd),
611            Action::BrowserForward => macos::post_key(0x1E, cmd),
612            Action::NewTab => macos::post_key(VK_T, cmd),
613            Action::CloseTab => macos::post_key(VK_W, cmd),
614            Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
615            Action::NextTab => macos::post_key(VK_TAB, ctrl),
616            Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
617            Action::ReloadPage => macos::post_key(VK_R, cmd),
618            // ── Navigation / Window ───────────────────────────────────────────
619            // Mission Control = Ctrl+Up (kVK_UpArrow = 0x7E)
620            Action::MissionControl => macos::post_key(0x7E, ctrl),
621            // App Exposé = Ctrl+Down (kVK_DownArrow = 0x7D)
622            Action::AppExpose => macos::post_key(0x7D, ctrl),
623            // Show Desktop = Cmd+F3 (kVK_F3 = 0x63)
624            Action::ShowDesktop => macos::post_key(0x63, cmd),
625            // Launchpad = F4 (kVK_F4 = 0x76)
626            Action::LaunchpadShow => macos::post_key(0x76, none),
627            // ── System ────────────────────────────────────────────────────────
628            // Lock screen = Cmd+Ctrl+Q (kVK_ANSI_Q = 0x0C)
629            Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
630            // Screenshot = Cmd+Shift+3 (kVK_ANSI_3 = 0x14)
631            Action::Screenshot => macos::post_key(0x14, cmd | shift),
632            // ── Media ─────────────────────────────────────────────────────────
633            // NX_KEYTYPE_PLAY=16, NEXT=17, PREVIOUS=18 via NSSystemDefined stub.
634            Action::PlayPause => macos::post_media_key(0),
635            Action::NextTrack => macos::post_media_key(1),
636            Action::PrevTrack => macos::post_media_key(2),
637            // kVK_VolumeUp/Down/Mute = 0x48/0x49/0x4A (ADB codes)
638            Action::VolumeUp => macos::post_key(0x48, none),
639            Action::VolumeDown => macos::post_key(0x49, none),
640            Action::MuteVolume => macos::post_key(0x4A, none),
641            // ── DPI / SmartShift: handled at hook/HID layer ───────────────────
642            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
643                tracing::debug!(
644                    action = self.label(),
645                    "device action handled by hook/HID layer"
646                );
647            }
648            // ── Scroll ────────────────────────────────────────────────────────
649            Action::ScrollUp
650            | Action::ScrollDown
651            | Action::HorizontalScrollLeft
652            | Action::HorizontalScrollRight => macos::post_scroll(self),
653            // ── Custom ────────────────────────────────────────────────────────
654            Action::CustomShortcut(combo) => {
655                // P1.3: post the recorded chord. `key_code == 0` is the
656                // "modifier-only placeholder" the recorder UI rejects;
657                // skip it here too so a malformed config doesn't fire
658                // bare modifier presses.
659                if combo.key_code == 0 {
660                    tracing::warn!(
661                        chord = %combo.rendered_label(),
662                        "CustomShortcut with no key code — press ignored"
663                    );
664                    return;
665                }
666                let mut flags = CGEventFlags::CGEventFlagNull;
667                if combo.modifiers & KeyCombo::MOD_CMD != 0 {
668                    flags |= CGEventFlags::CGEventFlagCommand;
669                }
670                if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
671                    flags |= CGEventFlags::CGEventFlagShift;
672                }
673                if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
674                    flags |= CGEventFlags::CGEventFlagControl;
675                }
676                if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
677                    flags |= CGEventFlags::CGEventFlagAlternate;
678                }
679                macos::post_key(combo.key_code, flags);
680            }
681        }
682    }
683}
684
685// ── macOS virtual key codes ────────────────────────────────────────────────
686// Source: <HIToolbox/Events.h> kVK_* constants. Values are layout-independent
687// for the US ANSI keyboard.
688#[cfg(target_os = "macos")]
689const VK_A: u16 = 0x00;
690#[cfg(target_os = "macos")]
691const VK_C: u16 = 0x08;
692#[cfg(target_os = "macos")]
693const VK_F: u16 = 0x03;
694#[cfg(target_os = "macos")]
695const VK_R: u16 = 0x0F;
696#[cfg(target_os = "macos")]
697const VK_S: u16 = 0x01;
698#[cfg(target_os = "macos")]
699const VK_T: u16 = 0x11;
700#[cfg(target_os = "macos")]
701const VK_V: u16 = 0x09;
702#[cfg(target_os = "macos")]
703const VK_W: u16 = 0x0D;
704#[cfg(target_os = "macos")]
705const VK_X: u16 = 0x07;
706#[cfg(target_os = "macos")]
707const VK_Z: u16 = 0x06;
708#[cfg(target_os = "macos")]
709const VK_TAB: u16 = 0x30;
710
711/// Platform helpers for synthesising OS-level input events on macOS.
712#[cfg(target_os = "macos")]
713mod macos {
714    use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation, ScrollEventUnit};
715    use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
716
717    use crate::binding::Action;
718
719    /// Post a key-down + key-up pair for `vk` with `flags` set.
720    pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
721        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
722            tracing::warn!("CGEventSource::new failed");
723            return;
724        };
725        let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
726            tracing::warn!("CGEvent::new_keyboard_event(down) failed");
727            return;
728        };
729        down.set_flags(flags);
730        down.post(CGEventTapLocation::HID);
731        let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
732            tracing::warn!("CGEvent::new_keyboard_event(up) failed");
733            return;
734        };
735        up.set_flags(flags);
736        up.post(CGEventTapLocation::HID);
737    }
738
739    /// Post a media key event (Play/Pause, Next, Previous).
740    ///
741    /// `kind`: 0 = play/pause, 1 = next track, 2 = previous track.
742    ///
743    /// The proper implementation uses an `NSSystemDefined` event (type 14,
744    /// subtype 8) which requires AppKit bindings. Until those land this
745    /// function logs a debug trace so manual smoke tests can confirm the
746    /// correct execution path.
747    pub(super) fn post_media_key(kind: i32) {
748        // NX_KEYTYPE_PLAY=16, NX_KEYTYPE_NEXT=17, NX_KEYTYPE_PREVIOUS=18.
749        let nx_key: i64 = match kind {
750            0 => 16,
751            1 => 17,
752            _ => 18,
753        };
754        tracing::debug!(
755            nx_key,
756            "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
757        );
758    }
759
760    /// Post a synthetic scroll event for `action` (one of the `Scroll*` variants).
761    pub(super) fn post_scroll(action: &Action) {
762        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
763            tracing::warn!("CGEventSource::new failed for scroll");
764            return;
765        };
766        let (v, h): (i32, i32) = match action {
767            Action::ScrollUp => (3, 0),
768            Action::ScrollDown => (-3, 0),
769            Action::HorizontalScrollLeft => (0, -3),
770            Action::HorizontalScrollRight => (0, 3),
771            _ => return,
772        };
773        let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
774            tracing::warn!("CGEvent::new_scroll_event failed");
775            return;
776        };
777        ev.post(CGEventTapLocation::HID);
778    }
779}
780
781/// Sensible defaults for a fresh device so the panel isn't empty on first run.
782///
783/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
784/// MX-line devices: thumb wheel click → App Exposé, gesture button →
785/// Mission Control. The thumb wheel isn't captured yet; the gesture button is
786/// (per-direction, see [`default_gesture_binding`]). The bindings persist
787/// regardless so the user only configures once.
788///
789/// `GestureButton`'s entry here is the legacy single-binding placeholder;
790/// the per-direction sub-bindings live in [`default_gesture_binding`] and
791/// are what the UI now edits.
792#[must_use]
793pub fn default_binding(button: ButtonId) -> Action {
794    match button {
795        ButtonId::LeftClick => Action::LeftClick,
796        ButtonId::RightClick => Action::RightClick,
797        ButtonId::MiddleClick => Action::MiddleClick,
798        ButtonId::Back => Action::BrowserBack,
799        ButtonId::Forward => Action::BrowserForward,
800        ButtonId::DpiToggle => Action::CycleDpiPresets,
801        ButtonId::Thumbwheel => Action::AppExpose,
802        ButtonId::GestureButton => Action::MissionControl,
803    }
804}
805
806/// Per-direction defaults for the gesture button. These are captured live over
807/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
808/// defaults give the picker something sensible to show on first run.
809#[must_use]
810pub fn default_gesture_binding(direction: GestureDirection) -> Action {
811    match direction {
812        GestureDirection::Up => Action::MissionControl,
813        GestureDirection::Down => Action::ShowDesktop,
814        GestureDirection::Left => Action::PrevTab,
815        GestureDirection::Right => Action::NextTab,
816        GestureDirection::Click => Action::AppExpose,
817    }
818}
819
820#[cfg(test)]
821#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
822mod tests {
823    use std::collections::BTreeMap;
824
825    use serde::{Deserialize, Serialize};
826
827    use super::*;
828
829    // ── Roundtrip wrapper: defined here so it precedes any `let` statements ──
830
831    /// Minimal TOML-serializable wrapper used by `roundtrip`.
832    /// Defined at module scope to satisfy `clippy::items_after_statements`.
833    #[derive(Serialize, Deserialize)]
834    struct RoundtripWrapper {
835        binding: BTreeMap<ButtonId, Action>,
836    }
837
838    // ── Catalog tests ─────────────────────────────────────────────────────────
839
840    #[test]
841    fn catalog_has_at_least_29_entries() {
842        let catalog = Action::catalog();
843        assert!(
844            catalog.len() >= 29,
845            "catalog has {} entries, need ≥ 29",
846            catalog.len()
847        );
848    }
849
850    #[test]
851    fn catalog_excludes_custom_shortcut() {
852        let catalog = Action::catalog();
853        for action in &catalog {
854            assert!(
855                !matches!(action, Action::CustomShortcut(_)),
856                "catalog must not contain CustomShortcut"
857            );
858        }
859    }
860
861    // ── Gesture classification ────────────────────────────────────────────────
862
863    #[test]
864    fn classify_gesture_short_travel_is_click() {
865        assert_eq!(classify_gesture(3, -2, 32), GestureDirection::Click);
866        assert_eq!(classify_gesture(0, 0, 32), GestureDirection::Click);
867    }
868
869    #[test]
870    fn classify_gesture_dominant_axis_wins() {
871        assert_eq!(classify_gesture(120, 5, 32), GestureDirection::Right);
872        assert_eq!(classify_gesture(-120, 5, 32), GestureDirection::Left);
873        assert_eq!(classify_gesture(5, 120, 32), GestureDirection::Down);
874        assert_eq!(classify_gesture(5, -120, 32), GestureDirection::Up);
875    }
876
877    #[test]
878    fn classify_gesture_ties_favor_horizontal() {
879        assert_eq!(classify_gesture(50, 50, 32), GestureDirection::Right);
880        assert_eq!(classify_gesture(-50, -50, 32), GestureDirection::Left);
881    }
882
883    // ── TOML roundtrip ────────────────────────────────────────────────────────
884
885    /// Serialize then deserialize `action` through TOML, using a wrapper
886    /// struct because TOML requires a top-level table.
887    fn roundtrip(action: &Action) -> Action {
888        let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
889        map.insert(ButtonId::Back, action.clone());
890        let w = RoundtripWrapper { binding: map };
891        let s = toml::to_string(&w).expect("serialize");
892        let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
893        back.binding
894            .into_values()
895            .next()
896            .expect("binding present after roundtrip")
897    }
898
899    #[test]
900    fn all_catalog_variants_roundtrip_toml() {
901        for action in Action::catalog() {
902            let back = roundtrip(&action);
903            assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
904        }
905    }
906
907    #[test]
908    fn custom_shortcut_roundtrips_toml() {
909        let action = Action::CustomShortcut(KeyCombo {
910            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
911            key_code: 0x23, // kVK_ANSI_P
912            display: "⌘⇧P".into(),
913        });
914        assert_eq!(roundtrip(&action), action);
915    }
916
917    #[test]
918    fn key_combo_rendered_label_uses_display_when_set() {
919        let combo = KeyCombo {
920            modifiers: 0,
921            key_code: 0,
922            display: "preset".into(),
923        };
924        assert_eq!(combo.rendered_label(), "preset");
925    }
926
927    #[test]
928    fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
929        let combo = KeyCombo {
930            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
931            key_code: 0x23, // P
932            display: String::new(),
933        };
934        assert_eq!(combo.rendered_label(), "⇧⌘P");
935    }
936
937    // ── Category tests ────────────────────────────────────────────────────────
938
939    #[test]
940    fn category_editing_variants() {
941        assert_eq!(Action::Copy.category(), Category::Editing);
942        assert_eq!(Action::Undo.category(), Category::Editing);
943        assert_eq!(Action::SelectAll.category(), Category::Editing);
944        assert_eq!(Action::Find.category(), Category::Editing);
945        assert_eq!(Action::Save.category(), Category::Editing);
946        assert_eq!(Action::Cut.category(), Category::Editing);
947        assert_eq!(Action::Redo.category(), Category::Editing);
948        assert_eq!(Action::Paste.category(), Category::Editing);
949    }
950
951    #[test]
952    fn category_browser_variants() {
953        assert_eq!(Action::BrowserBack.category(), Category::Browser);
954        assert_eq!(Action::BrowserForward.category(), Category::Browser);
955        assert_eq!(Action::NewTab.category(), Category::Browser);
956        assert_eq!(Action::CloseTab.category(), Category::Browser);
957        assert_eq!(Action::ReopenTab.category(), Category::Browser);
958        assert_eq!(Action::NextTab.category(), Category::Browser);
959        assert_eq!(Action::PrevTab.category(), Category::Browser);
960        assert_eq!(Action::ReloadPage.category(), Category::Browser);
961    }
962
963    #[test]
964    fn category_media_variants() {
965        assert_eq!(Action::PlayPause.category(), Category::Media);
966        assert_eq!(Action::NextTrack.category(), Category::Media);
967        assert_eq!(Action::PrevTrack.category(), Category::Media);
968        assert_eq!(Action::VolumeUp.category(), Category::Media);
969        assert_eq!(Action::VolumeDown.category(), Category::Media);
970        assert_eq!(Action::MuteVolume.category(), Category::Media);
971    }
972
973    #[test]
974    fn category_mouse_variants() {
975        assert_eq!(Action::LeftClick.category(), Category::Mouse);
976        assert_eq!(Action::RightClick.category(), Category::Mouse);
977        assert_eq!(Action::MiddleClick.category(), Category::Mouse);
978    }
979
980    #[test]
981    fn category_dpi_variants() {
982        assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
983        assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
984    }
985
986    #[test]
987    fn category_scroll_variants() {
988        assert_eq!(Action::ScrollUp.category(), Category::Scroll);
989        assert_eq!(Action::ScrollDown.category(), Category::Scroll);
990        assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
991        assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
992    }
993
994    #[test]
995    fn category_navigation_variants() {
996        assert_eq!(Action::MissionControl.category(), Category::Navigation);
997        assert_eq!(Action::AppExpose.category(), Category::Navigation);
998        assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
999        assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1000    }
1001
1002    #[test]
1003    fn category_system_variants() {
1004        assert_eq!(Action::LockScreen.category(), Category::System);
1005        assert_eq!(Action::Screenshot.category(), Category::System);
1006    }
1007
1008    // ── Category label smoke test ─────────────────────────────────────────────
1009
1010    #[test]
1011    fn category_labels_are_nonempty() {
1012        let categories = [
1013            Category::Editing,
1014            Category::Browser,
1015            Category::Media,
1016            Category::Mouse,
1017            Category::Dpi,
1018            Category::Scroll,
1019            Category::Navigation,
1020            Category::System,
1021        ];
1022        for cat in categories {
1023            assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1024        }
1025    }
1026
1027    // ── Default binding ───────────────────────────────────────────────────────
1028
1029    #[test]
1030    fn dpi_toggle_default_is_cycle_dpi_presets() {
1031        assert_eq!(
1032            default_binding(ButtonId::DpiToggle),
1033            Action::CycleDpiPresets
1034        );
1035    }
1036}