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