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    /// Show the desktop (hide all windows).
286    ShowDesktop,
287    /// Open Launchpad.
288    LaunchpadShow,
289
290    // ── System ────────────────────────────────────────────────────────────────
291    /// Lock the screen.
292    LockScreen,
293    /// Capture a screenshot.
294    Screenshot,
295
296    // ── Media ────────────────────────────────────────────────────────────────
297    /// Toggle media play/pause.
298    PlayPause,
299    /// Skip to the next track.
300    NextTrack,
301    /// Go back to the previous track.
302    PrevTrack,
303    /// Increase system volume.
304    VolumeUp,
305    /// Decrease system volume.
306    VolumeDown,
307    /// Toggle system mute.
308    MuteVolume,
309
310    // ── DPI ──────────────────────────────────────────────────────────────────
311    /// Step through the configured DPI preset list (P1.7).
312    CycleDpiPresets,
313    /// Jump to a specific zero-based preset in the device's DPI preset list.
314    /// Out-of-range indices clamp to the list length at fire time (P1.7).
315    SetDpiPreset(u8),
316    /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
317    ToggleSmartShift,
318
319    // ── Scroll ───────────────────────────────────────────────────────────────
320    /// Synthesise a vertical scroll-up tick.
321    ScrollUp,
322    /// Synthesise a vertical scroll-down tick.
323    ScrollDown,
324    /// Synthesise a horizontal scroll-left tick.
325    HorizontalScrollLeft,
326    /// Synthesise a horizontal scroll-right tick.
327    HorizontalScrollRight,
328
329    // ── Custom ───────────────────────────────────────────────────────────────
330    /// Replay an arbitrary recorded key chord (P1.3).
331    ///
332    /// Holds the structured chord data so `execute` can post the real
333    /// keystroke (macOS: CGEventPost with the encoded modifier flags).
334    /// The `display` field is used by [`Action::label`] so the popover
335    /// shows the user-friendly chord name.
336    CustomShortcut(KeyCombo),
337}
338
339/// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or
340/// hand-authored in `config.toml`.
341///
342/// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format
343/// is a compact integer, not a string. `key_code` is the macOS virtual key
344/// (kVK_*); other platforms map at `execute` time when they grow real
345/// support.
346///
347/// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it
348/// from the captured chord; we keep it in the struct so older configs
349/// continue to render the same label without re-deriving on every load.
350#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
351pub struct KeyCombo {
352    /// Bitmask of [`Self::MOD_CMD`] etc.
353    pub modifiers: u8,
354    /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for
355    /// modifier-only placeholders that the recorder UI rejects.
356    pub key_code: u16,
357    /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a
358    /// generated label at runtime.
359    #[serde(default)]
360    pub display: String,
361}
362
363impl KeyCombo {
364    pub const MOD_CMD: u8 = 1 << 0;
365    pub const MOD_SHIFT: u8 = 1 << 1;
366    pub const MOD_CTRL: u8 = 1 << 2;
367    pub const MOD_OPTION: u8 = 1 << 3;
368
369    /// Build the human-readable label from the modifier bitmask + key code.
370    /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the
371    /// commonly-recognised letters; the recorder UI usually overrides this
372    /// with its own derivation.
373    #[must_use]
374    pub fn rendered_label(&self) -> String {
375        if !self.display.is_empty() {
376            return self.display.clone();
377        }
378        let mut out = String::new();
379        if self.modifiers & Self::MOD_CTRL != 0 {
380            out.push('⌃');
381        }
382        if self.modifiers & Self::MOD_OPTION != 0 {
383            out.push('⌥');
384        }
385        if self.modifiers & Self::MOD_SHIFT != 0 {
386            out.push('⇧');
387        }
388        if self.modifiers & Self::MOD_CMD != 0 {
389            out.push('⌘');
390        }
391        match self.key_code {
392            0x00 => out.push('A'),
393            0x01 => out.push('S'),
394            0x02 => out.push('D'),
395            0x03 => out.push('F'),
396            0x06 => out.push('Z'),
397            0x07 => out.push('X'),
398            0x08 => out.push('C'),
399            0x09 => out.push('V'),
400            0x0B => out.push('B'),
401            0x0C => out.push('Q'),
402            0x0D => out.push('W'),
403            0x0E => out.push('E'),
404            0x0F => out.push('R'),
405            0x10 => out.push('Y'),
406            0x11 => out.push('T'),
407            0x20 => out.push('U'),
408            0x22 => out.push('I'),
409            0x1F => out.push('O'),
410            0x23 => out.push('P'),
411            _ => {
412                use std::fmt::Write as _;
413                let _ = write!(out, "key 0x{:02X}", self.key_code);
414            }
415        }
416        out
417    }
418}
419
420impl Action {
421    /// Display label for the popover row.
422    ///
423    /// Returns `String` rather than `&str` so parameterized variants (e.g.
424    /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
425    /// includes their payload.
426    #[must_use]
427    pub fn label(&self) -> String {
428        match self {
429            Action::LeftClick => "Left Click".into(),
430            Action::RightClick => "Right Click".into(),
431            Action::MiddleClick => "Middle Click".into(),
432            Action::Copy => "Copy".into(),
433            Action::Paste => "Paste".into(),
434            Action::Cut => "Cut".into(),
435            Action::Undo => "Undo".into(),
436            Action::Redo => "Redo".into(),
437            Action::SelectAll => "Select All".into(),
438            Action::Find => "Find".into(),
439            Action::Save => "Save".into(),
440            Action::BrowserBack => "Browser Back".into(),
441            Action::BrowserForward => "Browser Forward".into(),
442            Action::NewTab => "New Tab".into(),
443            Action::CloseTab => "Close Tab".into(),
444            Action::ReopenTab => "Reopen Tab".into(),
445            Action::NextTab => "Next Tab".into(),
446            Action::PrevTab => "Previous Tab".into(),
447            Action::ReloadPage => "Reload Page".into(),
448            Action::MissionControl => "Mission Control".into(),
449            Action::AppExpose => "App Exposé".into(),
450            Action::ShowDesktop => "Show Desktop".into(),
451            Action::LaunchpadShow => "Launchpad".into(),
452            Action::LockScreen => "Lock Screen".into(),
453            Action::Screenshot => "Screenshot".into(),
454            Action::PlayPause => "Play / Pause".into(),
455            Action::NextTrack => "Next Track".into(),
456            Action::PrevTrack => "Previous Track".into(),
457            Action::VolumeUp => "Volume Up".into(),
458            Action::VolumeDown => "Volume Down".into(),
459            Action::MuteVolume => "Mute".into(),
460            Action::CycleDpiPresets => "Cycle DPI Presets".into(),
461            Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
462            Action::ToggleSmartShift => "Toggle SmartShift".into(),
463            Action::ScrollUp => "Scroll Up".into(),
464            Action::ScrollDown => "Scroll Down".into(),
465            Action::HorizontalScrollLeft => "Scroll Left".into(),
466            Action::HorizontalScrollRight => "Scroll Right".into(),
467            Action::CustomShortcut(combo) => combo.rendered_label(),
468        }
469    }
470
471    /// Which [`Category`] this action belongs to, used for popover grouping.
472    #[must_use]
473    pub fn category(&self) -> Category {
474        match self {
475            Action::LeftClick | Action::RightClick | Action::MiddleClick => Category::Mouse,
476            // CustomShortcut is assigned to Editing so it doesn't need a
477            // separate arm (it's not in the picker catalog).
478            Action::Copy
479            | Action::Paste
480            | Action::Cut
481            | Action::Undo
482            | Action::Redo
483            | Action::SelectAll
484            | Action::Find
485            | Action::Save
486            | Action::CustomShortcut(_) => Category::Editing,
487            Action::BrowserBack
488            | Action::BrowserForward
489            | Action::NewTab
490            | Action::CloseTab
491            | Action::ReopenTab
492            | Action::NextTab
493            | Action::PrevTab
494            | Action::ReloadPage => Category::Browser,
495            Action::MissionControl
496            | Action::AppExpose
497            | Action::ShowDesktop
498            | Action::LaunchpadShow => Category::Navigation,
499            Action::LockScreen | Action::Screenshot => Category::System,
500            Action::PlayPause
501            | Action::NextTrack
502            | Action::PrevTrack
503            | Action::VolumeUp
504            | Action::VolumeDown
505            | Action::MuteVolume => Category::Media,
506            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
507                Category::Dpi
508            }
509            Action::ScrollUp
510            | Action::ScrollDown
511            | Action::HorizontalScrollLeft
512            | Action::HorizontalScrollRight => Category::Scroll,
513        }
514    }
515
516    /// All pickable actions in a deterministic order.
517    ///
518    /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
519    /// "Record shortcut…" (P1.3), not selected from the catalog.
520    #[must_use]
521    pub fn catalog() -> Vec<Action> {
522        vec![
523            // Mouse
524            Action::LeftClick,
525            Action::RightClick,
526            Action::MiddleClick,
527            // Editing
528            Action::Copy,
529            Action::Paste,
530            Action::Cut,
531            Action::Undo,
532            Action::Redo,
533            Action::SelectAll,
534            Action::Find,
535            Action::Save,
536            // Browser
537            Action::BrowserBack,
538            Action::BrowserForward,
539            Action::NewTab,
540            Action::CloseTab,
541            Action::ReopenTab,
542            Action::NextTab,
543            Action::PrevTab,
544            Action::ReloadPage,
545            // Navigation
546            Action::MissionControl,
547            Action::AppExpose,
548            Action::ShowDesktop,
549            Action::LaunchpadShow,
550            // System
551            Action::LockScreen,
552            Action::Screenshot,
553            // Media
554            Action::PlayPause,
555            Action::NextTrack,
556            Action::PrevTrack,
557            Action::VolumeUp,
558            Action::VolumeDown,
559            Action::MuteVolume,
560            // DPI
561            Action::CycleDpiPresets,
562            Action::ToggleSmartShift,
563            // Scroll
564            Action::ScrollUp,
565            Action::ScrollDown,
566            Action::HorizontalScrollLeft,
567            Action::HorizontalScrollRight,
568        ]
569    }
570
571    /// Synthesise the OS-level event for this action.
572    ///
573    /// On macOS, key events are posted via `CGEventPost(kCGHIDEventTap, …)`
574    /// using virtual key codes from the standard US keyboard layout, and the
575    /// `LeftClick`/`RightClick`/`MiddleClick` variants synthesise a mouse click
576    /// at the current cursor location. The WindowServer actions (`MissionControl`,
577    /// `AppExpose`, `ShowDesktop`, `LaunchpadShow`) are posted straight to the
578    /// Dock via `CoreDockSendNotification`. Device-side actions (`CycleDpiPresets`,
579    /// `SetDpiPreset`, `ToggleSmartShift`) have no CGEvent equivalent and are
580    /// handled at the hook/HID layer, logging a trace here.
581    ///
582    /// On other platforms a warning is logged and the function returns
583    /// immediately — the binary compiles clean on all targets.
584    pub fn execute(&self) {
585        #[cfg(target_os = "macos")]
586        self.execute_macos();
587
588        #[cfg(not(target_os = "macos"))]
589        {
590            tracing::warn!(
591                action = self.label(),
592                "Action::execute unsupported on this platform"
593            );
594        }
595    }
596
597    /// macOS implementation: dispatch to the appropriate event helper.
598    #[cfg(target_os = "macos")]
599    fn execute_macos(&self) {
600        use core_graphics::event::{CGEventFlags, CGMouseButton};
601
602        // Modifier bit shorthands.
603        let cmd = CGEventFlags::CGEventFlagCommand;
604        let shift = CGEventFlags::CGEventFlagShift;
605        let ctrl = CGEventFlags::CGEventFlagControl;
606        let none = CGEventFlags::CGEventFlagNull;
607
608        match self {
609            // ── Mouse clicks: synthesise a click at the cursor ────────────────
610            // Remapping a *different* button to a click lands here (e.g. Back →
611            // MiddleClick). A button left on its own native click never reaches
612            // this — the hook passes it straight through to the OS.
613            Action::LeftClick => macos::post_click(CGMouseButton::Left),
614            Action::RightClick => macos::post_click(CGMouseButton::Right),
615            Action::MiddleClick => macos::post_click(CGMouseButton::Center),
616            // ── Editing ───────────────────────────────────────────────────────
617            Action::Copy => macos::post_key(VK_C, cmd),
618            Action::Paste => macos::post_key(VK_V, cmd),
619            Action::Cut => macos::post_key(VK_X, cmd),
620            Action::Undo => macos::post_key(VK_Z, cmd),
621            Action::Redo => macos::post_key(VK_Z, cmd | shift),
622            Action::SelectAll => macos::post_key(VK_A, cmd),
623            Action::Find => macos::post_key(VK_F, cmd),
624            Action::Save => macos::post_key(VK_S, cmd),
625            // ── Browser / Navigation ──────────────────────────────────────────
626            // BrowserBack/Forward: Cmd+[ / Cmd+] as keyboard fallback; hook
627            // layer handles the physical mouse buttons directly.
628            // kVK_ANSI_LeftBracket = 0x21, kVK_ANSI_RightBracket = 0x1E
629            Action::BrowserBack => macos::post_key(0x21, cmd),
630            Action::BrowserForward => macos::post_key(0x1E, cmd),
631            Action::NewTab => macos::post_key(VK_T, cmd),
632            Action::CloseTab => macos::post_key(VK_W, cmd),
633            Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
634            Action::NextTab => macos::post_key(VK_TAB, ctrl),
635            Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
636            Action::ReloadPage => macos::post_key(VK_R, cmd),
637            // ── Navigation / Window: posted straight to the Dock ──────────────
638            // Synthesising these shortcuts is unreliable — the WindowServer
639            // matcher needs the exact configured key (incl. the Fn flag) and
640            // Show Desktop ignores synthetic events entirely — so they go to the
641            // Dock via `CoreDockSendNotification`, which fires regardless of the
642            // user's keyboard settings.
643            Action::MissionControl => macos::mission_control(),
644            Action::AppExpose => macos::app_expose(),
645            Action::ShowDesktop => macos::show_desktop(),
646            Action::LaunchpadShow => macos::launchpad(),
647            // ── System ────────────────────────────────────────────────────────
648            // Lock screen = Cmd+Ctrl+Q (kVK_ANSI_Q = 0x0C)
649            Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
650            // Screenshot = Cmd+Shift+3 (kVK_ANSI_3 = 0x14)
651            Action::Screenshot => macos::post_key(0x14, cmd | shift),
652            // ── Media ─────────────────────────────────────────────────────────
653            // NX_KEYTYPE_PLAY=16, NEXT=17, PREVIOUS=18 via NSSystemDefined stub.
654            Action::PlayPause => macos::post_media_key(0),
655            Action::NextTrack => macos::post_media_key(1),
656            Action::PrevTrack => macos::post_media_key(2),
657            // kVK_VolumeUp/Down/Mute = 0x48/0x49/0x4A (ADB codes)
658            Action::VolumeUp => macos::post_key(0x48, none),
659            Action::VolumeDown => macos::post_key(0x49, none),
660            Action::MuteVolume => macos::post_key(0x4A, none),
661            // ── DPI / SmartShift: handled at hook/HID layer ───────────────────
662            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
663                tracing::debug!(
664                    action = self.label(),
665                    "device action handled by hook/HID layer"
666                );
667            }
668            // ── Scroll ────────────────────────────────────────────────────────
669            Action::ScrollUp
670            | Action::ScrollDown
671            | Action::HorizontalScrollLeft
672            | Action::HorizontalScrollRight => macos::post_scroll(self),
673            // ── Custom ────────────────────────────────────────────────────────
674            Action::CustomShortcut(combo) => {
675                // P1.3: post the recorded chord. `key_code == 0` is the
676                // "modifier-only placeholder" the recorder UI rejects;
677                // skip it here too so a malformed config doesn't fire
678                // bare modifier presses.
679                if combo.key_code == 0 {
680                    tracing::warn!(
681                        chord = %combo.rendered_label(),
682                        "CustomShortcut with no key code — press ignored"
683                    );
684                    return;
685                }
686                let mut flags = CGEventFlags::CGEventFlagNull;
687                if combo.modifiers & KeyCombo::MOD_CMD != 0 {
688                    flags |= CGEventFlags::CGEventFlagCommand;
689                }
690                if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
691                    flags |= CGEventFlags::CGEventFlagShift;
692                }
693                if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
694                    flags |= CGEventFlags::CGEventFlagControl;
695                }
696                if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
697                    flags |= CGEventFlags::CGEventFlagAlternate;
698                }
699                macos::post_key(combo.key_code, flags);
700            }
701        }
702    }
703}
704
705/// Synthesise a horizontal scroll of `delta` wheel lines at the current focus.
706///
707/// Used by the gesture/thumbwheel capture watcher to re-inject the MX thumb
708/// wheel's scrolling after the wheel has been diverted over HID++ to capture its
709/// click. `delta` is the device's raw rotation; its sign follows the wheel's
710/// rotation convention and its magnitude (one line per rotation increment) may
711/// need tuning per device, since the diverted resolution differs from native.
712///
713/// No-op (logs nothing) on platforms without CGEvent.
714pub fn post_horizontal_scroll(delta: i32) {
715    #[cfg(target_os = "macos")]
716    macos::post_horizontal_scroll(delta);
717
718    #[cfg(not(target_os = "macos"))]
719    let _ = delta;
720}
721
722// ── macOS virtual key codes ────────────────────────────────────────────────
723// Source: <HIToolbox/Events.h> kVK_* constants. Values are layout-independent
724// for the US ANSI keyboard.
725#[cfg(target_os = "macos")]
726const VK_A: u16 = 0x00;
727#[cfg(target_os = "macos")]
728const VK_C: u16 = 0x08;
729#[cfg(target_os = "macos")]
730const VK_F: u16 = 0x03;
731#[cfg(target_os = "macos")]
732const VK_R: u16 = 0x0F;
733#[cfg(target_os = "macos")]
734const VK_S: u16 = 0x01;
735#[cfg(target_os = "macos")]
736const VK_T: u16 = 0x11;
737#[cfg(target_os = "macos")]
738const VK_V: u16 = 0x09;
739#[cfg(target_os = "macos")]
740const VK_W: u16 = 0x0D;
741#[cfg(target_os = "macos")]
742const VK_X: u16 = 0x07;
743#[cfg(target_os = "macos")]
744const VK_Z: u16 = 0x06;
745#[cfg(target_os = "macos")]
746const VK_TAB: u16 = 0x30;
747
748/// Platform helpers for synthesising OS-level input events on macOS.
749#[cfg(target_os = "macos")]
750mod macos {
751    use core_graphics::event::{
752        CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, ScrollEventUnit,
753    };
754    use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
755    use core_graphics::geometry::CGPoint;
756
757    use crate::binding::Action;
758
759    /// Post a mouse-down + mouse-up pair for `button` at the cursor's current
760    /// location.
761    ///
762    /// Posted at the HID tap location, so OpenLogi's own event tap sees the
763    /// synthetic click too: a `LeftClick`/`RightClick` flows straight through
764    /// (the tap never owns the primary buttons), and a `MiddleClick` is left
765    /// alone unless the user has *also* remapped the middle button.
766    pub(super) fn post_click(button: CGMouseButton) {
767        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
768            tracing::warn!("CGEventSource::new failed for click");
769            return;
770        };
771        // A fresh event reports the current pointer location; mouse events need
772        // an explicit position or they land at (0, 0).
773        let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
774        let (down, up) = match button {
775            CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
776            CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
777            CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
778        };
779        for (kind, phase) in [(down, "down"), (up, "up")] {
780            if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
781                ev.post(CGEventTapLocation::HID);
782            } else {
783                tracing::warn!(phase, "CGEvent::new_mouse_event failed");
784            }
785        }
786    }
787
788    /// Post a key-down + key-up pair for `vk` with `flags` set.
789    pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
790        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
791            tracing::warn!("CGEventSource::new failed");
792            return;
793        };
794        let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
795            tracing::warn!("CGEvent::new_keyboard_event(down) failed");
796            return;
797        };
798        down.set_flags(flags);
799        down.post(CGEventTapLocation::HID);
800        let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
801            tracing::warn!("CGEvent::new_keyboard_event(up) failed");
802            return;
803        };
804        up.set_flags(flags);
805        up.post(CGEventTapLocation::HID);
806    }
807
808    /// Post a media key event (Play/Pause, Next, Previous).
809    ///
810    /// `kind`: 0 = play/pause, 1 = next track, 2 = previous track.
811    ///
812    /// The proper implementation uses an `NSSystemDefined` event (type 14,
813    /// subtype 8) which requires AppKit bindings. Until those land this
814    /// function logs a debug trace so manual smoke tests can confirm the
815    /// correct execution path.
816    pub(super) fn post_media_key(kind: i32) {
817        // NX_KEYTYPE_PLAY=16, NX_KEYTYPE_NEXT=17, NX_KEYTYPE_PREVIOUS=18.
818        let nx_key: i64 = match kind {
819            0 => 16,
820            1 => 17,
821            _ => 18,
822        };
823        tracing::debug!(
824            nx_key,
825            "media key event: NSSystemDefined stub — full AppKit impl tracked in P1.x"
826        );
827    }
828
829    /// Post a synthetic scroll event for `action` (one of the `Scroll*` variants).
830    pub(super) fn post_scroll(action: &Action) {
831        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
832            tracing::warn!("CGEventSource::new failed for scroll");
833            return;
834        };
835        let (v, h): (i32, i32) = match action {
836            Action::ScrollUp => (3, 0),
837            Action::ScrollDown => (-3, 0),
838            Action::HorizontalScrollLeft => (0, -3),
839            Action::HorizontalScrollRight => (0, 3),
840            _ => return,
841        };
842        let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
843            tracing::warn!("CGEvent::new_scroll_event failed");
844            return;
845        };
846        ev.post(CGEventTapLocation::HID);
847    }
848
849    /// Post a horizontal scroll of `delta` lines (wheel2 axis). Line units suit
850    /// the thumb wheel's ratchet-like increments better than pixels.
851    pub(super) fn post_horizontal_scroll(delta: i32) {
852        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
853            tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
854            return;
855        };
856        let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
857            tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
858            return;
859        };
860        ev.post(CGEventTapLocation::HID);
861    }
862
863    pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
864
865    /// WindowServer window/space actions (Mission Control, App Exposé, Show
866    /// Desktop, Launchpad).
867    ///
868    /// These are driven by the Dock, and synthesising their keyboard shortcut is
869    /// unreliable — the WindowServer matcher needs the exact configured key
870    /// (incl. the Fn flag) and Show Desktop's in particular doesn't respond. So
871    /// we post the action straight to the Dock via the private
872    /// `CoreDockSendNotification` SPI, which fires it regardless of the user's
873    /// Keyboard settings.
874    ///
875    /// Isolated in its own submodule so the `unsafe` the `dlopen`/`dlsym` FFI
876    /// needs is scoped here rather than spread across the platform helpers.
877    #[allow(
878        unsafe_code,
879        reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
880    )]
881    mod dock {
882        use std::ffi::{CStr, c_char, c_int, c_void};
883        use std::sync::OnceLock;
884
885        use core_foundation::base::TCFType;
886        use core_foundation::string::CFString;
887
888        /// Show all windows across spaces (Mission Control).
889        pub(crate) fn mission_control() {
890            send("com.apple.expose.awake");
891        }
892
893        /// Show the front app's windows (App Exposé).
894        pub(crate) fn app_expose() {
895            send("com.apple.expose.front.awake");
896        }
897
898        /// Move all windows aside to reveal the desktop.
899        pub(crate) fn show_desktop() {
900            send("com.apple.showdesktop.awake");
901        }
902
903        /// Toggle Launchpad. A no-op on macOS 26, which removed Launchpad.
904        pub(crate) fn launchpad() {
905            send("com.apple.launchpad.toggle");
906        }
907
908        /// Post `notification` to the Dock. Logs and returns on any failure.
909        fn send(notification: &str) {
910            let Some(core_dock_send) = core_dock_send_notification() else {
911                tracing::warn!(notification, "CoreDockSendNotification unavailable");
912                return;
913            };
914            let name = CFString::new(notification);
915            // SAFETY: resolved AppServices symbol called with its documented
916            // signature; `name` is a live CFString for the call's duration.
917            let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
918            if err != 0 {
919                tracing::warn!(notification, err, "CoreDockSendNotification failed");
920            }
921        }
922
923        type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
924
925        /// Resolve `CoreDockSendNotification` from `ApplicationServices`, caching
926        /// the `dlopen` handle for the process lifetime. `None` if unavailable.
927        fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
928            const RTLD_LAZY: c_int = 0x1;
929            const APP_SERVICES: &CStr =
930                c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
931            static HANDLE: OnceLock<usize> = OnceLock::new();
932
933            // SAFETY: `dlopen`/`dlsym` come from libSystem; APP_SERVICES is a
934            // valid C string. The handle is cached and intentionally never
935            // closed.
936            let sym = unsafe {
937                let handle =
938                    *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
939                if handle == 0 {
940                    return None;
941                }
942                dlsym(handle as *mut c_void, c"CoreDockSendNotification".as_ptr())
943            };
944            // SAFETY: the symbol, when present, has the documented signature.
945            (!sym.is_null()).then(|| unsafe {
946                std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym)
947            })
948        }
949
950        unsafe extern "C" {
951            fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
952            fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
953        }
954    }
955}
956
957/// Sensible defaults for a fresh device so the panel isn't empty on first run.
958///
959/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
960/// MX-line devices: thumb wheel click → App Exposé, gesture button →
961/// Mission Control. The thumb wheel isn't captured yet; the gesture button is
962/// (per-direction, see [`default_gesture_binding`]). The bindings persist
963/// regardless so the user only configures once.
964///
965/// `GestureButton`'s entry here is the legacy single-binding placeholder;
966/// the per-direction sub-bindings live in [`default_gesture_binding`] and
967/// are what the UI now edits.
968#[must_use]
969pub fn default_binding(button: ButtonId) -> Action {
970    match button {
971        ButtonId::LeftClick => Action::LeftClick,
972        ButtonId::RightClick => Action::RightClick,
973        ButtonId::MiddleClick => Action::MiddleClick,
974        ButtonId::Back => Action::BrowserBack,
975        ButtonId::Forward => Action::BrowserForward,
976        ButtonId::DpiToggle => Action::CycleDpiPresets,
977        ButtonId::Thumbwheel => Action::AppExpose,
978        ButtonId::GestureButton => Action::MissionControl,
979    }
980}
981
982/// Per-direction defaults for the gesture button. These are captured live over
983/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
984/// defaults give the picker something sensible to show on first run.
985#[must_use]
986pub fn default_gesture_binding(direction: GestureDirection) -> Action {
987    match direction {
988        GestureDirection::Up => Action::MissionControl,
989        GestureDirection::Down => Action::ShowDesktop,
990        GestureDirection::Left => Action::PrevTab,
991        GestureDirection::Right => Action::NextTab,
992        GestureDirection::Click => Action::AppExpose,
993    }
994}
995
996#[cfg(test)]
997#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
998mod tests {
999    use std::collections::BTreeMap;
1000
1001    use serde::{Deserialize, Serialize};
1002
1003    use super::*;
1004
1005    // ── Roundtrip wrapper: defined here so it precedes any `let` statements ──
1006
1007    /// Minimal TOML-serializable wrapper used by `roundtrip`.
1008    /// Defined at module scope to satisfy `clippy::items_after_statements`.
1009    #[derive(Serialize, Deserialize)]
1010    struct RoundtripWrapper {
1011        binding: BTreeMap<ButtonId, Action>,
1012    }
1013
1014    // ── Catalog tests ─────────────────────────────────────────────────────────
1015
1016    #[test]
1017    fn catalog_has_at_least_29_entries() {
1018        let catalog = Action::catalog();
1019        assert!(
1020            catalog.len() >= 29,
1021            "catalog has {} entries, need ≥ 29",
1022            catalog.len()
1023        );
1024    }
1025
1026    #[test]
1027    fn catalog_excludes_custom_shortcut() {
1028        let catalog = Action::catalog();
1029        for action in &catalog {
1030            assert!(
1031                !matches!(action, Action::CustomShortcut(_)),
1032                "catalog must not contain CustomShortcut"
1033            );
1034        }
1035    }
1036
1037    // ── Gesture classification ────────────────────────────────────────────────
1038
1039    #[test]
1040    fn detect_swipe_below_threshold_keeps_accumulating() {
1041        // Too little travel to commit — caller keeps summing raw-XY.
1042        assert_eq!(detect_swipe(40, 5), None);
1043        assert_eq!(detect_swipe(0, 0), None);
1044    }
1045
1046    #[test]
1047    fn detect_swipe_commits_clean_direction() {
1048        assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
1049        assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
1050        assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
1051        assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
1052    }
1053
1054    #[test]
1055    fn detect_swipe_rejects_diagonal() {
1056        // Past the threshold but too diagonal (cross axis beyond the band).
1057        assert_eq!(detect_swipe(60, 60), None);
1058        assert_eq!(detect_swipe(-60, -60), None);
1059    }
1060
1061    // ── TOML roundtrip ────────────────────────────────────────────────────────
1062
1063    /// Serialize then deserialize `action` through TOML, using a wrapper
1064    /// struct because TOML requires a top-level table.
1065    fn roundtrip(action: &Action) -> Action {
1066        let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
1067        map.insert(ButtonId::Back, action.clone());
1068        let w = RoundtripWrapper { binding: map };
1069        let s = toml::to_string(&w).expect("serialize");
1070        let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
1071        back.binding
1072            .into_values()
1073            .next()
1074            .expect("binding present after roundtrip")
1075    }
1076
1077    #[test]
1078    fn all_catalog_variants_roundtrip_toml() {
1079        for action in Action::catalog() {
1080            let back = roundtrip(&action);
1081            assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
1082        }
1083    }
1084
1085    #[test]
1086    fn custom_shortcut_roundtrips_toml() {
1087        let action = Action::CustomShortcut(KeyCombo {
1088            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1089            key_code: 0x23, // kVK_ANSI_P
1090            display: "⌘⇧P".into(),
1091        });
1092        assert_eq!(roundtrip(&action), action);
1093    }
1094
1095    #[test]
1096    fn key_combo_rendered_label_uses_display_when_set() {
1097        let combo = KeyCombo {
1098            modifiers: 0,
1099            key_code: 0,
1100            display: "preset".into(),
1101        };
1102        assert_eq!(combo.rendered_label(), "preset");
1103    }
1104
1105    #[test]
1106    fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
1107        let combo = KeyCombo {
1108            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1109            key_code: 0x23, // P
1110            display: String::new(),
1111        };
1112        assert_eq!(combo.rendered_label(), "⇧⌘P");
1113    }
1114
1115    // ── Category tests ────────────────────────────────────────────────────────
1116
1117    #[test]
1118    fn category_editing_variants() {
1119        assert_eq!(Action::Copy.category(), Category::Editing);
1120        assert_eq!(Action::Undo.category(), Category::Editing);
1121        assert_eq!(Action::SelectAll.category(), Category::Editing);
1122        assert_eq!(Action::Find.category(), Category::Editing);
1123        assert_eq!(Action::Save.category(), Category::Editing);
1124        assert_eq!(Action::Cut.category(), Category::Editing);
1125        assert_eq!(Action::Redo.category(), Category::Editing);
1126        assert_eq!(Action::Paste.category(), Category::Editing);
1127    }
1128
1129    #[test]
1130    fn category_browser_variants() {
1131        assert_eq!(Action::BrowserBack.category(), Category::Browser);
1132        assert_eq!(Action::BrowserForward.category(), Category::Browser);
1133        assert_eq!(Action::NewTab.category(), Category::Browser);
1134        assert_eq!(Action::CloseTab.category(), Category::Browser);
1135        assert_eq!(Action::ReopenTab.category(), Category::Browser);
1136        assert_eq!(Action::NextTab.category(), Category::Browser);
1137        assert_eq!(Action::PrevTab.category(), Category::Browser);
1138        assert_eq!(Action::ReloadPage.category(), Category::Browser);
1139    }
1140
1141    #[test]
1142    fn category_media_variants() {
1143        assert_eq!(Action::PlayPause.category(), Category::Media);
1144        assert_eq!(Action::NextTrack.category(), Category::Media);
1145        assert_eq!(Action::PrevTrack.category(), Category::Media);
1146        assert_eq!(Action::VolumeUp.category(), Category::Media);
1147        assert_eq!(Action::VolumeDown.category(), Category::Media);
1148        assert_eq!(Action::MuteVolume.category(), Category::Media);
1149    }
1150
1151    #[test]
1152    fn category_mouse_variants() {
1153        assert_eq!(Action::LeftClick.category(), Category::Mouse);
1154        assert_eq!(Action::RightClick.category(), Category::Mouse);
1155        assert_eq!(Action::MiddleClick.category(), Category::Mouse);
1156    }
1157
1158    #[test]
1159    fn category_dpi_variants() {
1160        assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
1161        assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
1162    }
1163
1164    #[test]
1165    fn category_scroll_variants() {
1166        assert_eq!(Action::ScrollUp.category(), Category::Scroll);
1167        assert_eq!(Action::ScrollDown.category(), Category::Scroll);
1168        assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
1169        assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
1170    }
1171
1172    #[test]
1173    fn category_navigation_variants() {
1174        assert_eq!(Action::MissionControl.category(), Category::Navigation);
1175        assert_eq!(Action::AppExpose.category(), Category::Navigation);
1176        assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
1177        assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1178    }
1179
1180    #[test]
1181    fn category_system_variants() {
1182        assert_eq!(Action::LockScreen.category(), Category::System);
1183        assert_eq!(Action::Screenshot.category(), Category::System);
1184    }
1185
1186    // ── Category label smoke test ─────────────────────────────────────────────
1187
1188    #[test]
1189    fn category_labels_are_nonempty() {
1190        let categories = [
1191            Category::Editing,
1192            Category::Browser,
1193            Category::Media,
1194            Category::Mouse,
1195            Category::Dpi,
1196            Category::Scroll,
1197            Category::Navigation,
1198            Category::System,
1199        ];
1200        for cat in categories {
1201            assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1202        }
1203    }
1204
1205    // ── Default binding ───────────────────────────────────────────────────────
1206
1207    #[test]
1208    fn dpi_toggle_default_is_cycle_dpi_presets() {
1209        assert_eq!(
1210            default_binding(ButtonId::DpiToggle),
1211            Action::CycleDpiPresets
1212        );
1213    }
1214}