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    /// Mouse "back" side button (extra button 4). Synthesizes the real mouse
373    /// button event, which browsers and most apps interpret as "navigate back"
374    /// natively — unlike [`Action::BrowserBack`], which sends ⌘[ and is ignored
375    /// by many apps.
376    MouseBack,
377    /// Mouse "forward" side button (extra button 5). Native counterpart to
378    /// [`Action::MouseBack`]; see [`Action::BrowserForward`] for the ⌘] form.
379    MouseForward,
380
381    // ── Editing ──────────────────────────────────────────────────────────────
382    /// Copy the current selection (⌘C / Ctrl+C).
383    Copy,
384    /// Paste from the clipboard (⌘V / Ctrl+V).
385    Paste,
386    /// Cut the current selection (⌘X / Ctrl+X).
387    Cut,
388    /// Undo the last action (⌘Z / Ctrl+Z).
389    Undo,
390    /// Redo the last undone action (⌘⇧Z on macOS / Ctrl+Shift+Z on Linux).
391    ///
392    /// Note: Ctrl+Y is the dominant redo shortcut in LibreOffice and many GTK
393    /// apps. Ctrl+Shift+Z is used here because it mirrors the macOS convention
394    /// and works in GNOME text fields, browsers, and Electron apps. If Ctrl+Y
395    /// coverage is needed, a `CustomShortcut` binding is the escape hatch.
396    Redo,
397    /// Select all content (⌘A / Ctrl+A).
398    SelectAll,
399    /// Open the find / search bar (⌘F / Ctrl+F).
400    Find,
401    /// Save the current document (⌘S / Ctrl+S).
402    Save,
403
404    // ── Browser / Navigation ──────────────────────────────────────────────────
405    /// Navigate backward in browser history.
406    BrowserBack,
407    /// Navigate forward in browser history.
408    BrowserForward,
409    /// Open a new tab (⌘T / Ctrl+T).
410    NewTab,
411    /// Close the current tab (⌘W / Ctrl+W).
412    CloseTab,
413    /// Reopen the last closed tab (⌘⇧T / Ctrl+Shift+T).
414    ReopenTab,
415    /// Switch to the next tab (⌃⇥ / Ctrl+Tab).
416    NextTab,
417    /// Switch to the previous tab (⌃⇧⇥ / Ctrl+Shift+Tab).
418    PrevTab,
419    /// Reload the current page (⌘R / Ctrl+R).
420    ReloadPage,
421
422    // ── Navigation / Window ───────────────────────────────────────────────────
423    /// macOS Mission Control (⌃↑).
424    MissionControl,
425    /// macOS App Exposé — all windows for the current app (⌃↓).
426    AppExpose,
427    /// Switch to the previous desktop / Space.
428    PreviousDesktop,
429    /// Switch to the next desktop / Space.
430    NextDesktop,
431    /// Show the desktop (hide all windows).
432    ShowDesktop,
433    /// Open Launchpad.
434    LaunchpadShow,
435
436    // ── System ────────────────────────────────────────────────────────────────
437    /// Lock the screen (⌘⌃Q on macOS).
438    ///
439    /// On Linux, calls `org.freedesktop.login1.Manager.LockSession($XDG_SESSION_ID)`
440    /// on the system bus (current session only). Falls back to Super+L when
441    /// `$XDG_SESSION_ID` is unset or on non-systemd systems.
442    LockScreen,
443    /// Capture a screenshot.
444    Screenshot,
445
446    // ── Media ────────────────────────────────────────────────────────────────
447    /// Toggle media play/pause.
448    PlayPause,
449    /// Skip to the next track.
450    NextTrack,
451    /// Go back to the previous track.
452    PrevTrack,
453    /// Increase system volume.
454    VolumeUp,
455    /// Decrease system volume.
456    VolumeDown,
457    /// Toggle system mute.
458    MuteVolume,
459
460    // ── DPI ──────────────────────────────────────────────────────────────────
461    /// Step through the configured DPI preset list (P1.7).
462    CycleDpiPresets,
463    /// Jump to a specific zero-based preset in the device's DPI preset list.
464    /// Out-of-range indices clamp to the list length at fire time (P1.7).
465    SetDpiPreset(u8),
466    /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
467    ToggleSmartShift,
468
469    // ── Scroll ───────────────────────────────────────────────────────────────
470    /// Synthesise a vertical scroll-up tick.
471    ScrollUp,
472    /// Synthesise a vertical scroll-down tick.
473    ScrollDown,
474    /// Synthesise a horizontal scroll-left tick.
475    HorizontalScrollLeft,
476    /// Synthesise a horizontal scroll-right tick.
477    HorizontalScrollRight,
478
479    // ── Custom ───────────────────────────────────────────────────────────────
480    /// Replay an arbitrary recorded key chord (P1.3).
481    ///
482    /// Holds the structured chord data so `execute` can post the real
483    /// keystroke (macOS: CGEventPost with the encoded modifier flags).
484    /// The `display` field is used by [`Action::label`] so the popover
485    /// shows the user-friendly chord name.
486    CustomShortcut(KeyCombo),
487}
488
489/// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or
490/// hand-authored in `config.toml`.
491///
492/// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format
493/// is a compact integer, not a string. `key_code` is the macOS virtual key
494/// (`kVK_*`); on Linux, `Action::execute` maps it to an evdev `KeyCode` via
495/// `linux::macos_vk_to_linux`.
496///
497/// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it
498/// from the captured chord; we keep it in the struct so older configs
499/// continue to render the same label without re-deriving on every load.
500#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
501pub struct KeyCombo {
502    /// Bitmask of [`Self::MOD_CMD`] etc.
503    pub modifiers: u8,
504    /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for
505    /// modifier-only placeholders that the recorder UI rejects. On Linux,
506    /// `Action::execute` translates this to an evdev `KeyCode`.
507    pub key_code: u16,
508    /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a
509    /// generated label at runtime.
510    #[serde(default)]
511    pub display: String,
512}
513
514impl KeyCombo {
515    pub const MOD_CMD: u8 = 1 << 0;
516    pub const MOD_SHIFT: u8 = 1 << 1;
517    pub const MOD_CTRL: u8 = 1 << 2;
518    pub const MOD_OPTION: u8 = 1 << 3;
519
520    /// Build the human-readable label from the modifier bitmask + key code.
521    /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the
522    /// commonly-recognised letters; the recorder UI usually overrides this
523    /// with its own derivation.
524    #[must_use]
525    pub fn rendered_label(&self) -> String {
526        if !self.display.is_empty() {
527            return self.display.clone();
528        }
529        let mut out = String::new();
530        if self.modifiers & Self::MOD_CTRL != 0 {
531            out.push('⌃');
532        }
533        if self.modifiers & Self::MOD_OPTION != 0 {
534            out.push('⌥');
535        }
536        if self.modifiers & Self::MOD_SHIFT != 0 {
537            out.push('⇧');
538        }
539        if self.modifiers & Self::MOD_CMD != 0 {
540            out.push('⌘');
541        }
542        match self.key_code {
543            0x00 => out.push('A'),
544            0x01 => out.push('S'),
545            0x02 => out.push('D'),
546            0x03 => out.push('F'),
547            0x06 => out.push('Z'),
548            0x07 => out.push('X'),
549            0x08 => out.push('C'),
550            0x09 => out.push('V'),
551            0x0B => out.push('B'),
552            0x0C => out.push('Q'),
553            0x0D => out.push('W'),
554            0x0E => out.push('E'),
555            0x0F => out.push('R'),
556            0x10 => out.push('Y'),
557            0x11 => out.push('T'),
558            0x20 => out.push('U'),
559            0x22 => out.push('I'),
560            0x1F => out.push('O'),
561            0x23 => out.push('P'),
562            _ => {
563                use std::fmt::Write as _;
564                let _ = write!(out, "key 0x{:02X}", self.key_code);
565            }
566        }
567        out
568    }
569}
570
571/// What a single rebindable [`ButtonId`] does: either one [`Action`], or — for a
572/// raw-XY-capable button placed in gesture mode — a per-[`GestureDirection`]
573/// map (hold + swipe up/down/left/right, or a plain click).
574///
575/// There has only ever been one binding map per device; a gesture binding is
576/// just a binding whose payload is a direction map instead of a single action.
577///
578/// # Serialization
579///
580/// `#[serde(untagged)]`: [`Single`](Binding::Single) serializes exactly as the
581/// bare [`Action`] did before (a string `"BrowserBack"`, or a single-key table
582/// for the payload variants), and [`Gesture`](Binding::Gesture) serializes as a
583/// table keyed by [`GestureDirection`] names (`Up`/`Down`/`Left`/`Right`/
584/// `Click`).
585///
586/// The two arms are disambiguated by the **zero overlap** between [`Action`]
587/// variant names and [`GestureDirection`] variant names — untagged tries
588/// `Single(Action)` first, and a table keyed by `Up` etc. cannot parse as an
589/// externally-tagged `Action`, so it falls through to `Gesture`. A payload
590/// action like `{ SetDpiPreset = 2 }` is a valid externally-tagged `Action`, so
591/// it stays `Single` and never reaches the `Gesture` arm. This invariant is the
592/// entire safety basis for untagged routing; the `binding_untagged_*` tests
593/// guard it (a future `Action` named `Up`/`Down`/`Left`/`Right`/`Click` would
594/// silently mis-route, and those tests would fail).
595#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
596#[serde(untagged)]
597pub enum Binding {
598    /// One action, fired on press. The shape every non-gesture button uses.
599    Single(Action),
600    /// Per-direction sub-bindings for a button in gesture mode. Keyed by the
601    /// committed swipe direction, with [`GestureDirection::Click`] holding the
602    /// plain-click (no-swipe) action.
603    Gesture(BTreeMap<GestureDirection, Action>),
604}
605
606impl Binding {
607    /// The plain-click action for this binding: the [`Single`](Binding::Single)
608    /// action, or the [`Gesture`](Binding::Gesture) map's
609    /// [`Click`](GestureDirection::Click) entry. Falls back to [`Action::None`]
610    /// when a gesture binding has no explicit `Click`.
611    ///
612    /// Lets the click-dispatch path stay binding-shape-agnostic.
613    #[must_use]
614    pub fn click_action(&self) -> Action {
615        match self {
616            Binding::Single(action) => action.clone(),
617            Binding::Gesture(map) => map
618                .get(&GestureDirection::Click)
619                .cloned()
620                .unwrap_or(Action::None),
621        }
622    }
623
624    /// The action bound to `direction`, if this is a gesture binding.
625    /// [`Single`](Binding::Single) has no directions and returns `None`.
626    #[must_use]
627    pub fn direction_action(&self, direction: GestureDirection) -> Option<&Action> {
628        match self {
629            Binding::Single(_) => None,
630            Binding::Gesture(map) => map.get(&direction),
631        }
632    }
633
634    /// Whether this binding drives raw-XY swipe capture (the
635    /// [`Gesture`](Binding::Gesture) arm).
636    #[must_use]
637    pub fn is_gesture(&self) -> bool {
638        matches!(self, Binding::Gesture(_))
639    }
640
641    /// Promote a [`Single`](Binding::Single) binding in place to a
642    /// [`Gesture`](Binding::Gesture), keeping its action as the
643    /// [`GestureDirection::Click`] entry and leaving the swipe arms unbound.
644    /// A no-op when this is already a [`Gesture`].
645    pub fn upgrade_to_gesture(&mut self) {
646        if let Binding::Single(action) = self {
647            let mut map = BTreeMap::new();
648            map.insert(GestureDirection::Click, action.clone());
649            *self = Binding::Gesture(map);
650        }
651    }
652
653    /// Fill any unbound directions of a [`Gesture`](Binding::Gesture) binding
654    /// with their canonical [`default_gesture_binding`], so a button promoted to
655    /// the gesture role always exposes the full five-direction set — rather than
656    /// leaving swipe arms the GUI renders as defaults but the runtime never
657    /// dispatches. A no-op on [`Single`](Binding::Single) and on directions
658    /// already bound (existing user choices are preserved).
659    pub fn fill_gesture_defaults(&mut self) {
660        if let Binding::Gesture(map) = self {
661            for dir in GestureDirection::ALL {
662                map.entry(dir)
663                    .or_insert_with(|| default_gesture_binding(dir));
664            }
665        }
666    }
667}
668
669impl From<Action> for Binding {
670    fn from(action: Action) -> Self {
671        Binding::Single(action)
672    }
673}
674
675impl Action {
676    /// Display label for the popover row.
677    ///
678    /// Returns `String` rather than `&str` so parameterized variants (e.g.
679    /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
680    /// includes their payload.
681    #[must_use]
682    pub fn label(&self) -> String {
683        match self {
684            Action::None => "Do Nothing".into(),
685            Action::LeftClick => "Left Click".into(),
686            Action::RightClick => "Right Click".into(),
687            Action::MiddleClick => "Middle Click".into(),
688            Action::MouseBack => "Back (Button 4)".into(),
689            Action::MouseForward => "Forward (Button 5)".into(),
690            Action::Copy => "Copy".into(),
691            Action::Paste => "Paste".into(),
692            Action::Cut => "Cut".into(),
693            Action::Undo => "Undo".into(),
694            Action::Redo => "Redo".into(),
695            Action::SelectAll => "Select All".into(),
696            Action::Find => "Find".into(),
697            Action::Save => "Save".into(),
698            Action::BrowserBack => "Browser Back".into(),
699            Action::BrowserForward => "Browser Forward".into(),
700            Action::NewTab => "New Tab".into(),
701            Action::CloseTab => "Close Tab".into(),
702            Action::ReopenTab => "Reopen Tab".into(),
703            Action::NextTab => "Next Tab".into(),
704            Action::PrevTab => "Previous Tab".into(),
705            Action::ReloadPage => "Reload Page".into(),
706            Action::MissionControl => "Mission Control".into(),
707            Action::AppExpose => "App Exposé".into(),
708            Action::PreviousDesktop => "Previous Desktop".into(),
709            Action::NextDesktop => "Next Desktop".into(),
710            Action::ShowDesktop => "Show Desktop".into(),
711            Action::LaunchpadShow => "Launchpad".into(),
712            Action::LockScreen => "Lock Screen".into(),
713            Action::Screenshot => "Screenshot".into(),
714            Action::PlayPause => "Play / Pause".into(),
715            Action::NextTrack => "Next Track".into(),
716            Action::PrevTrack => "Previous Track".into(),
717            Action::VolumeUp => "Volume Up".into(),
718            Action::VolumeDown => "Volume Down".into(),
719            Action::MuteVolume => "Mute".into(),
720            Action::CycleDpiPresets => "Cycle DPI Presets".into(),
721            Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
722            Action::ToggleSmartShift => "Toggle SmartShift".into(),
723            Action::ScrollUp => "Scroll Up".into(),
724            Action::ScrollDown => "Scroll Down".into(),
725            Action::HorizontalScrollLeft => "Scroll Left".into(),
726            Action::HorizontalScrollRight => "Scroll Right".into(),
727            Action::CustomShortcut(combo) => combo.rendered_label(),
728        }
729    }
730
731    /// Which [`Category`] this action belongs to, used for popover grouping.
732    #[must_use]
733    pub fn category(&self) -> Category {
734        match self {
735            Action::LeftClick
736            | Action::RightClick
737            | Action::MiddleClick
738            | Action::MouseBack
739            | Action::MouseForward => Category::Mouse,
740            // CustomShortcut is assigned to Editing so it doesn't need a
741            // separate arm (it's not in the picker catalog).
742            Action::Copy
743            | Action::Paste
744            | Action::Cut
745            | Action::Undo
746            | Action::Redo
747            | Action::SelectAll
748            | Action::Find
749            | Action::Save
750            | Action::CustomShortcut(_) => Category::Editing,
751            Action::BrowserBack
752            | Action::BrowserForward
753            | Action::NewTab
754            | Action::CloseTab
755            | Action::ReopenTab
756            | Action::NextTab
757            | Action::PrevTab
758            | Action::ReloadPage => Category::Browser,
759            Action::MissionControl
760            | Action::AppExpose
761            | Action::PreviousDesktop
762            | Action::NextDesktop
763            | Action::ShowDesktop
764            | Action::LaunchpadShow => Category::Navigation,
765            Action::None | Action::LockScreen | Action::Screenshot => Category::System,
766            Action::PlayPause
767            | Action::NextTrack
768            | Action::PrevTrack
769            | Action::VolumeUp
770            | Action::VolumeDown
771            | Action::MuteVolume => Category::Media,
772            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
773                Category::Dpi
774            }
775            Action::ScrollUp
776            | Action::ScrollDown
777            | Action::HorizontalScrollLeft
778            | Action::HorizontalScrollRight => Category::Scroll,
779        }
780    }
781
782    /// All pickable actions in a deterministic order.
783    ///
784    /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
785    /// "Record shortcut…" (P1.3), not selected from the catalog.
786    #[must_use]
787    pub fn catalog() -> Vec<Action> {
788        vec![
789            // Mouse
790            Action::LeftClick,
791            Action::RightClick,
792            Action::MiddleClick,
793            Action::MouseBack,
794            Action::MouseForward,
795            // Editing
796            Action::Copy,
797            Action::Paste,
798            Action::Cut,
799            Action::Undo,
800            Action::Redo,
801            Action::SelectAll,
802            Action::Find,
803            Action::Save,
804            // Browser
805            Action::BrowserBack,
806            Action::BrowserForward,
807            Action::NewTab,
808            Action::CloseTab,
809            Action::ReopenTab,
810            Action::NextTab,
811            Action::PrevTab,
812            Action::ReloadPage,
813            // Navigation
814            Action::MissionControl,
815            Action::AppExpose,
816            Action::PreviousDesktop,
817            Action::NextDesktop,
818            Action::ShowDesktop,
819            Action::LaunchpadShow,
820            // System
821            Action::None,
822            Action::LockScreen,
823            Action::Screenshot,
824            // Media
825            Action::PlayPause,
826            Action::NextTrack,
827            Action::PrevTrack,
828            Action::VolumeUp,
829            Action::VolumeDown,
830            Action::MuteVolume,
831            // DPI
832            Action::CycleDpiPresets,
833            Action::ToggleSmartShift,
834            // Scroll
835            Action::ScrollUp,
836            Action::ScrollDown,
837            Action::HorizontalScrollLeft,
838            Action::HorizontalScrollRight,
839        ]
840    }
841
842    /// Synthesise the OS-level event for this action.
843    ///
844    /// On macOS, key events are posted via `CGEventPost(kCGHIDEventTap, …)`
845    /// using virtual key codes from the standard US keyboard layout, and the
846    /// `LeftClick`/`RightClick`/`MiddleClick` variants synthesise a mouse click
847    /// at the current cursor location. The WindowServer actions (`MissionControl`,
848    /// `AppExpose`, `ShowDesktop`, `LaunchpadShow`) are posted straight to the
849    /// Dock via `CoreDockSendNotification`. Device-side actions (`CycleDpiPresets`,
850    /// `SetDpiPreset`, `ToggleSmartShift`) have no CGEvent equivalent and are
851    /// handled at the hook/HID layer, logging a trace here.
852    ///
853    /// On Linux, key and scroll events are injected via a lazily-created `uinput`
854    /// virtual device. Mouse clicks inject `BTN_*` events. macOS-only window
855    /// manager actions (`MissionControl`, `AppExpose`, `ShowDesktop`,
856    /// `LaunchpadShow`) have no universal Linux equivalent and are silently
857    /// skipped (debug-logged). `CustomShortcut` maps macOS `kVK_*` codes to
858    /// Linux key codes; macOS Cmd maps to Ctrl.
859    ///
860    /// On Windows, key and mouse events are synthesised via `SendInput`. The
861    /// macOS window-manager actions map to their Windows equivalents (e.g.
862    /// `MissionControl` → Win+Tab, `ShowDesktop` → Win+D); `CustomShortcut`
863    /// maps macOS `kVK_*` codes to Windows virtual-key codes, with Cmd mapped to
864    /// Ctrl.
865    ///
866    /// On other platforms a warning is logged and the function returns
867    /// immediately — the binary compiles clean on all targets.
868    pub fn execute(&self) {
869        #[cfg(target_os = "macos")]
870        self.execute_macos();
871
872        #[cfg(target_os = "linux")]
873        self.execute_linux();
874
875        #[cfg(target_os = "windows")]
876        self.execute_windows();
877
878        #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
879        {
880            tracing::warn!(
881                action = self.label(),
882                "Action::execute unsupported on this platform"
883            );
884        }
885    }
886
887    /// Linux implementation: inject events via a shared `uinput` virtual device.
888    #[cfg(target_os = "linux")]
889    fn execute_linux(&self) {
890        use evdev::{KeyCode, RelativeAxisCode};
891        let ctrl = KeyCode::KEY_LEFTCTRL;
892        let shift = KeyCode::KEY_LEFTSHIFT;
893        let alt = KeyCode::KEY_LEFTALT;
894        match self {
895            // ── Mouse clicks ──────────────────────────────────────────────────
896            Action::LeftClick => linux::click(KeyCode::BTN_LEFT),
897            Action::RightClick => linux::click(KeyCode::BTN_RIGHT),
898            Action::MiddleClick => linux::click(KeyCode::BTN_MIDDLE),
899            // Extra mouse buttons: BTN_SIDE/BTN_EXTRA are the evdev side
900            // buttons ("back"/"forward") browsers handle natively.
901            Action::MouseBack => linux::click(KeyCode::BTN_SIDE),
902            Action::MouseForward => linux::click(KeyCode::BTN_EXTRA),
903            // ── Editing ───────────────────────────────────────────────────────
904            Action::Copy => linux::press_key(&[ctrl], KeyCode::KEY_C),
905            Action::Paste => linux::press_key(&[ctrl], KeyCode::KEY_V),
906            Action::Cut => linux::press_key(&[ctrl], KeyCode::KEY_X),
907            Action::Undo => linux::press_key(&[ctrl], KeyCode::KEY_Z),
908            // Redo is Ctrl+Shift+Z on Linux (matches macOS ⌘⇧Z convention).
909            Action::Redo => linux::press_key(&[ctrl, shift], KeyCode::KEY_Z),
910            Action::SelectAll => linux::press_key(&[ctrl], KeyCode::KEY_A),
911            Action::Find => linux::press_key(&[ctrl], KeyCode::KEY_F),
912            Action::Save => linux::press_key(&[ctrl], KeyCode::KEY_S),
913            // ── Browser / Navigation ──────────────────────────────────────────
914            Action::BrowserBack => linux::press_key(&[alt], KeyCode::KEY_LEFT),
915            Action::BrowserForward => linux::press_key(&[alt], KeyCode::KEY_RIGHT),
916            Action::NewTab => linux::press_key(&[ctrl], KeyCode::KEY_T),
917            Action::CloseTab => linux::press_key(&[ctrl], KeyCode::KEY_W),
918            Action::ReopenTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_T),
919            Action::NextTab => linux::press_key(&[ctrl], KeyCode::KEY_TAB),
920            Action::PrevTab => linux::press_key(&[ctrl, shift], KeyCode::KEY_TAB),
921            Action::ReloadPage => linux::press_key(&[ctrl], KeyCode::KEY_R),
922            // ── Navigation — macOS-specific ───────────────────────────────────
923            // No universal Linux equivalent; the compositor shortcut varies.
924            Action::MissionControl
925            | Action::AppExpose
926            | Action::ShowDesktop
927            | Action::LaunchpadShow => {
928                tracing::debug!(
929                    action = self.label(),
930                    "no Linux equivalent — action skipped"
931                );
932            }
933            // Ctrl+Alt+←/→ is the default in GNOME and KDE.
934            Action::PreviousDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_LEFT),
935            Action::NextDesktop => linux::press_key(&[ctrl, alt], KeyCode::KEY_RIGHT),
936            // ── System ────────────────────────────────────────────────────────
937            // logind LockSessions() via the system bus; falls back to Super+L.
938            Action::LockScreen => linux::lock_screen(),
939            Action::Screenshot => linux::press_key(&[], KeyCode::KEY_SYSRQ),
940            // ── Media ─────────────────────────────────────────────────────────
941            // MPRIS targets the running media player; XF86 volume keys go to the
942            // system mixer (PulseAudio/PipeWire) which is what users expect.
943            Action::PlayPause => linux::mpris_command("PlayPause"),
944            Action::NextTrack => linux::mpris_command("Next"),
945            Action::PrevTrack => linux::mpris_command("Previous"),
946            Action::VolumeUp => linux::press_key(&[], KeyCode::KEY_VOLUMEUP),
947            Action::VolumeDown => linux::press_key(&[], KeyCode::KEY_VOLUMEDOWN),
948            Action::MuteVolume => linux::press_key(&[], KeyCode::KEY_MUTE),
949            // ── DPI / SmartShift: handled at hook/HID layer ───────────────────
950            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
951                tracing::debug!(
952                    action = self.label(),
953                    "device action handled by hook/HID layer"
954                );
955            }
956            // ── Scroll ────────────────────────────────────────────────────────
957            Action::ScrollUp => linux::scroll(RelativeAxisCode::REL_WHEEL, 3),
958            Action::ScrollDown => linux::scroll(RelativeAxisCode::REL_WHEEL, -3),
959            Action::HorizontalScrollLeft => linux::scroll(RelativeAxisCode::REL_HWHEEL, -3),
960            Action::HorizontalScrollRight => linux::scroll(RelativeAxisCode::REL_HWHEEL, 3),
961            // ── No-op ─────────────────────────────────────────────────────────
962            Action::None => {}
963            // ── Custom shortcut ───────────────────────────────────────────────
964            Action::CustomShortcut(combo) => {
965                if combo.key_code == 0 {
966                    tracing::warn!(
967                        chord = %combo.rendered_label(),
968                        "CustomShortcut with no key code — press ignored"
969                    );
970                    return;
971                }
972                let Some(key) = linux::macos_vk_to_linux(combo.key_code) else {
973                    tracing::warn!(
974                        key_code = combo.key_code,
975                        "CustomShortcut key code has no Linux mapping — press ignored"
976                    );
977                    return;
978                };
979                linux::press_key(&linux::modifiers_to_keycodes(combo.modifiers), key);
980            }
981        }
982    }
983
984    /// macOS implementation: dispatch to the appropriate event helper.
985    #[cfg(target_os = "macos")]
986    fn execute_macos(&self) {
987        use core_graphics::event::{CGEventFlags, CGMouseButton};
988
989        // Modifier bit shorthands.
990        let cmd = CGEventFlags::CGEventFlagCommand;
991        let shift = CGEventFlags::CGEventFlagShift;
992        let ctrl = CGEventFlags::CGEventFlagControl;
993
994        match self {
995            // Suppressed input: captured but deliberately produces no event.
996            Action::None => {}
997            // ── Mouse clicks: synthesise a click at the cursor ────────────────
998            // Remapping a *different* button to a click lands here (e.g. Back →
999            // MiddleClick). A button left on its own native click never reaches
1000            // this — the hook passes it straight through to the OS.
1001            Action::LeftClick => macos::post_click(CGMouseButton::Left),
1002            Action::RightClick => macos::post_click(CGMouseButton::Right),
1003            Action::MiddleClick => macos::post_click(CGMouseButton::Center),
1004            // Extra mouse buttons: post the real button4/5 the OS treats as
1005            // back/forward. Button numbers are 0-indexed (3 = back / "button 4",
1006            // 4 = forward / "button 5").
1007            Action::MouseBack => macos::post_other_button(3),
1008            Action::MouseForward => macos::post_other_button(4),
1009            // ── Editing ───────────────────────────────────────────────────────
1010            Action::Copy => macos::post_key(VK_C, cmd),
1011            Action::Paste => macos::post_key(VK_V, cmd),
1012            Action::Cut => macos::post_key(VK_X, cmd),
1013            Action::Undo => macos::post_key(VK_Z, cmd),
1014            Action::Redo => macos::post_key(VK_Z, cmd | shift),
1015            Action::SelectAll => macos::post_key(VK_A, cmd),
1016            Action::Find => macos::post_key(VK_F, cmd),
1017            Action::Save => macos::post_key(VK_S, cmd),
1018            // ── Browser / Navigation ──────────────────────────────────────────
1019            // BrowserBack/Forward: Cmd+[ / Cmd+] as keyboard fallback; hook
1020            // layer handles the physical mouse buttons directly.
1021            // kVK_ANSI_LeftBracket = 0x21, kVK_ANSI_RightBracket = 0x1E
1022            Action::BrowserBack => macos::post_key(0x21, cmd),
1023            Action::BrowserForward => macos::post_key(0x1E, cmd),
1024            Action::NewTab => macos::post_key(VK_T, cmd),
1025            Action::CloseTab => macos::post_key(VK_W, cmd),
1026            Action::ReopenTab => macos::post_key(VK_T, cmd | shift),
1027            Action::NextTab => macos::post_key(VK_TAB, ctrl),
1028            Action::PrevTab => macos::post_key(VK_TAB, ctrl | shift),
1029            Action::ReloadPage => macos::post_key(VK_R, cmd),
1030            // ── Navigation / Window: posted straight to the Dock ──────────────
1031            // Synthesising these shortcuts is unreliable — the WindowServer
1032            // matcher needs the exact configured key (incl. the Fn flag) and
1033            // Show Desktop ignores synthetic events entirely — so they go to the
1034            // Dock via `CoreDockSendNotification`, which fires regardless of the
1035            // user's keyboard settings.
1036            Action::MissionControl => macos::mission_control(),
1037            Action::AppExpose => macos::app_expose(),
1038            Action::PreviousDesktop => macos::previous_desktop(),
1039            Action::NextDesktop => macos::next_desktop(),
1040            Action::ShowDesktop => macos::show_desktop(),
1041            Action::LaunchpadShow => macos::launchpad(),
1042            // ── System ────────────────────────────────────────────────────────
1043            // Lock screen = Cmd+Ctrl+Q (kVK_ANSI_Q = 0x0C)
1044            Action::LockScreen => macos::post_key(0x0C, cmd | ctrl),
1045            // Screenshot = Cmd+Shift+3 (kVK_ANSI_3 = 0x14)
1046            Action::Screenshot => macos::post_key(0x14, cmd | shift),
1047            // ── Media ─────────────────────────────────────────────────────────
1048            // Media/volume controls are NX system-defined keys, not ordinary
1049            // keyboard virtual-key events. Posting kVK_Volume* through
1050            // CGEventCreateKeyboardEvent is ignored by macOS' volume handler.
1051            Action::PlayPause => macos::post_media_key(macos::NX_KEYTYPE_PLAY),
1052            Action::NextTrack => macos::post_media_key(macos::NX_KEYTYPE_NEXT),
1053            Action::PrevTrack => macos::post_media_key(macos::NX_KEYTYPE_PREVIOUS),
1054            Action::VolumeUp => macos::post_media_key(macos::NX_KEYTYPE_SOUND_UP),
1055            Action::VolumeDown => macos::post_media_key(macos::NX_KEYTYPE_SOUND_DOWN),
1056            Action::MuteVolume => macos::post_media_key(macos::NX_KEYTYPE_MUTE),
1057            // ── DPI / SmartShift: handled at hook/HID layer ───────────────────
1058            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
1059                tracing::debug!(
1060                    action = self.label(),
1061                    "device action handled by hook/HID layer"
1062                );
1063            }
1064            // ── Scroll ────────────────────────────────────────────────────────
1065            Action::ScrollUp
1066            | Action::ScrollDown
1067            | Action::HorizontalScrollLeft
1068            | Action::HorizontalScrollRight => macos::post_scroll(self),
1069            // ── Custom ────────────────────────────────────────────────────────
1070            Action::CustomShortcut(combo) => {
1071                // P1.3: post the recorded chord. `key_code == 0` is the
1072                // "modifier-only placeholder" the recorder UI rejects;
1073                // skip it here too so a malformed config doesn't fire
1074                // bare modifier presses.
1075                if combo.key_code == 0 {
1076                    tracing::warn!(
1077                        chord = %combo.rendered_label(),
1078                        "CustomShortcut with no key code — press ignored"
1079                    );
1080                    return;
1081                }
1082                let mut flags = CGEventFlags::CGEventFlagNull;
1083                if combo.modifiers & KeyCombo::MOD_CMD != 0 {
1084                    flags |= CGEventFlags::CGEventFlagCommand;
1085                }
1086                if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
1087                    flags |= CGEventFlags::CGEventFlagShift;
1088                }
1089                if combo.modifiers & KeyCombo::MOD_CTRL != 0 {
1090                    flags |= CGEventFlags::CGEventFlagControl;
1091                }
1092                if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
1093                    flags |= CGEventFlags::CGEventFlagAlternate;
1094                }
1095                macos::post_key(combo.key_code, flags);
1096            }
1097        }
1098    }
1099
1100    /// Windows implementation: synthesise events via `SendInput`. macOS
1101    /// window-manager actions map to their Windows equivalents; `CustomShortcut`
1102    /// maps macOS `kVK_*` codes to Windows virtual-key codes (Cmd → Ctrl).
1103    #[cfg(target_os = "windows")]
1104    fn execute_windows(&self) {
1105        match self {
1106            Action::LeftClick => windows::post_click(windows::MouseButton::Left),
1107            Action::RightClick => windows::post_click(windows::MouseButton::Right),
1108            Action::MiddleClick => windows::post_click(windows::MouseButton::Middle),
1109            Action::MouseBack => windows::post_click(windows::MouseButton::Back),
1110            Action::MouseForward => windows::post_click(windows::MouseButton::Forward),
1111            Action::Copy => windows::post_key(windows::VK_C, &[windows::VK_CONTROL]),
1112            Action::Paste => windows::post_key(windows::VK_V, &[windows::VK_CONTROL]),
1113            Action::Cut => windows::post_key(windows::VK_X, &[windows::VK_CONTROL]),
1114            Action::Undo => windows::post_key(windows::VK_Z, &[windows::VK_CONTROL]),
1115            Action::Redo => windows::post_key(windows::VK_Y, &[windows::VK_CONTROL]),
1116            Action::SelectAll => windows::post_key(windows::VK_A, &[windows::VK_CONTROL]),
1117            Action::Find => windows::post_key(windows::VK_F, &[windows::VK_CONTROL]),
1118            Action::Save => windows::post_key(windows::VK_S, &[windows::VK_CONTROL]),
1119            Action::BrowserBack => windows::post_key(windows::VK_BROWSER_BACK, &[]),
1120            Action::BrowserForward => windows::post_key(windows::VK_BROWSER_FORWARD, &[]),
1121            Action::NewTab => windows::post_key(windows::VK_T, &[windows::VK_CONTROL]),
1122            Action::CloseTab => windows::post_key(windows::VK_W, &[windows::VK_CONTROL]),
1123            Action::ReopenTab => {
1124                windows::post_key(windows::VK_T, &[windows::VK_CONTROL, windows::VK_SHIFT]);
1125            }
1126            Action::NextTab => windows::post_key(windows::VK_TAB, &[windows::VK_CONTROL]),
1127            Action::PrevTab => {
1128                windows::post_key(windows::VK_TAB, &[windows::VK_CONTROL, windows::VK_SHIFT]);
1129            }
1130            Action::ReloadPage => windows::post_key(windows::VK_R, &[windows::VK_CONTROL]),
1131            Action::MissionControl | Action::AppExpose => {
1132                windows::post_key(windows::VK_TAB, &[windows::VK_LWIN]);
1133            }
1134            Action::PreviousDesktop => {
1135                windows::post_key(windows::VK_LEFT, &[windows::VK_LWIN, windows::VK_CONTROL]);
1136            }
1137            Action::NextDesktop => {
1138                windows::post_key(windows::VK_RIGHT, &[windows::VK_LWIN, windows::VK_CONTROL]);
1139            }
1140            Action::ShowDesktop => windows::post_key(windows::VK_D, &[windows::VK_LWIN]),
1141            Action::LaunchpadShow => windows::post_key(windows::VK_LWIN, &[]),
1142            Action::LockScreen => windows::post_key(windows::VK_L, &[windows::VK_LWIN]),
1143            Action::Screenshot => {
1144                windows::post_key(windows::VK_S, &[windows::VK_LWIN, windows::VK_SHIFT]);
1145            }
1146            Action::PlayPause => windows::post_key(windows::VK_MEDIA_PLAY_PAUSE, &[]),
1147            Action::NextTrack => windows::post_key(windows::VK_MEDIA_NEXT_TRACK, &[]),
1148            Action::PrevTrack => windows::post_key(windows::VK_MEDIA_PREV_TRACK, &[]),
1149            Action::VolumeUp => windows::post_key(windows::VK_VOLUME_UP, &[]),
1150            Action::VolumeDown => windows::post_key(windows::VK_VOLUME_DOWN, &[]),
1151            Action::MuteVolume => windows::post_key(windows::VK_VOLUME_MUTE, &[]),
1152            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
1153                tracing::debug!(
1154                    action = self.label(),
1155                    "device action handled by hook/HID layer"
1156                );
1157            }
1158            Action::ScrollUp
1159            | Action::ScrollDown
1160            | Action::HorizontalScrollLeft
1161            | Action::HorizontalScrollRight => windows::post_scroll(self),
1162            Action::CustomShortcut(combo) => windows::post_custom_shortcut(combo),
1163            Action::None => {}
1164        }
1165    }
1166}
1167
1168/// Synthesise a horizontal scroll of `delta` wheel lines at the current focus.
1169///
1170/// Used by the gesture/thumbwheel capture watcher to re-inject the MX thumb
1171/// wheel's scrolling after the wheel has been diverted over HID++ to capture its
1172/// click. `delta` is the device's raw rotation; its sign follows the wheel's
1173/// rotation convention and its magnitude (one line per rotation increment) may
1174/// need tuning per device, since the diverted resolution differs from native.
1175///
1176/// No-op (logs nothing) on platforms without a supported injection mechanism.
1177pub fn post_horizontal_scroll(delta: i32) {
1178    #[cfg(target_os = "macos")]
1179    macos::post_horizontal_scroll(delta);
1180
1181    // `delta` is already in "one line per rotation increment" units (see doc
1182    // above), which matches REL_HWHEEL's convention of one unit per detent.
1183    // This is intentionally different from Action::HorizontalScrollLeft/Right,
1184    // which hardcode ±3 as a fixed "scroll tick" with no device delta involved.
1185    #[cfg(target_os = "linux")]
1186    linux::scroll(evdev::RelativeAxisCode::REL_HWHEEL, delta);
1187
1188    #[cfg(target_os = "windows")]
1189    windows::post_horizontal_scroll(delta);
1190
1191    #[cfg(not(any(target_os = "macos", target_os = "linux", target_os = "windows")))]
1192    let _ = delta;
1193}
1194
1195/// Return the `/dev/input/eventN` node for the action-injector uinput device,
1196/// initialising it if needed.
1197///
1198/// Intended for debugging and manual smoke-testing (e.g. attaching `evtest`
1199/// before firing `Action::execute`). Returns `None` on non-Linux platforms or
1200/// when the device could not be created (e.g. `/dev/uinput` not writable).
1201#[cfg(target_os = "linux")]
1202#[must_use]
1203pub fn action_device_path() -> Option<std::path::PathBuf> {
1204    linux::device_node()
1205}
1206
1207// ── macOS virtual key codes ────────────────────────────────────────────────
1208// Source: <HIToolbox/Events.h> kVK_* constants. Values are layout-independent
1209// for the US ANSI keyboard.
1210#[cfg(target_os = "macos")]
1211const VK_A: u16 = 0x00;
1212#[cfg(target_os = "macos")]
1213const VK_C: u16 = 0x08;
1214#[cfg(target_os = "macos")]
1215const VK_F: u16 = 0x03;
1216#[cfg(target_os = "macos")]
1217const VK_R: u16 = 0x0F;
1218#[cfg(target_os = "macos")]
1219const VK_S: u16 = 0x01;
1220#[cfg(target_os = "macos")]
1221const VK_T: u16 = 0x11;
1222#[cfg(target_os = "macos")]
1223const VK_V: u16 = 0x09;
1224#[cfg(target_os = "macos")]
1225const VK_W: u16 = 0x0D;
1226#[cfg(target_os = "macos")]
1227const VK_X: u16 = 0x07;
1228#[cfg(target_os = "macos")]
1229const VK_Z: u16 = 0x06;
1230#[cfg(target_os = "macos")]
1231const VK_TAB: u16 = 0x30;
1232
1233/// Stamped into the `EVENT_SOURCE_USER_DATA` field of every mouse event
1234/// [`Action::execute`] synthesizes on macOS, so OpenLogi's own `CGEventTap` can
1235/// recognize and skip its own injections. Without it, a gesture/button action
1236/// that posts a mouse button (e.g. a remapped `MiddleClick`) would re-enter the
1237/// hook — and for a gesture button, be misread as a fresh hold, looping. The
1238/// value is arbitrary but distinctive ("OLGI"); real events carry `0` here.
1239pub const SYNTHETIC_EVENT_USER_DATA: i64 = 0x4F4C_4749;
1240
1241/// Platform helpers for synthesising OS-level input events on macOS.
1242#[cfg(target_os = "macos")]
1243mod macos {
1244    use core_graphics::event::{
1245        CGEvent, CGEventFlags, CGEventTapLocation, CGEventType, CGMouseButton, EventField,
1246        ScrollEventUnit,
1247    };
1248    use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1249    use core_graphics::geometry::CGPoint;
1250
1251    use crate::binding::Action;
1252
1253    // NX_KEYTYPE_* constants from <IOKit/hidsystem/ev_keymap.h>.
1254    pub(super) const NX_KEYTYPE_SOUND_UP: i32 = 0;
1255    pub(super) const NX_KEYTYPE_SOUND_DOWN: i32 = 1;
1256    pub(super) const NX_KEYTYPE_MUTE: i32 = 7;
1257    pub(super) const NX_KEYTYPE_PLAY: i32 = 16;
1258    pub(super) const NX_KEYTYPE_NEXT: i32 = 17;
1259    pub(super) const NX_KEYTYPE_PREVIOUS: i32 = 18;
1260
1261    /// Post a mouse-down + mouse-up pair for `button` at the cursor's current
1262    /// location.
1263    ///
1264    /// Posted at the HID tap location, so OpenLogi's own event tap sees the
1265    /// synthetic click too: a `LeftClick`/`RightClick` flows straight through
1266    /// (the tap never owns the primary buttons), and a `MiddleClick` is left
1267    /// alone unless the user has *also* remapped the middle button.
1268    pub(super) fn post_click(button: CGMouseButton) {
1269        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1270            tracing::warn!("CGEventSource::new failed for click");
1271            return;
1272        };
1273        // A fresh event reports the current pointer location; mouse events need
1274        // an explicit position or they land at (0, 0).
1275        let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
1276        let (down, up) = match button {
1277            CGMouseButton::Left => (CGEventType::LeftMouseDown, CGEventType::LeftMouseUp),
1278            CGMouseButton::Right => (CGEventType::RightMouseDown, CGEventType::RightMouseUp),
1279            CGMouseButton::Center => (CGEventType::OtherMouseDown, CGEventType::OtherMouseUp),
1280        };
1281        for (kind, phase) in [(down, "down"), (up, "up")] {
1282            if let Ok(ev) = CGEvent::new_mouse_event(src.clone(), kind, location, button) {
1283                tag_synthetic(&ev);
1284                ev.post(CGEventTapLocation::HID);
1285            } else {
1286                tracing::warn!(phase, "CGEvent::new_mouse_event failed");
1287            }
1288        }
1289    }
1290
1291    /// Post a down + up pair for an "extra" mouse button by its raw button
1292    /// number (3 = back / "button 4", 4 = forward / "button 5"). These are the
1293    /// native events browsers and most apps interpret as back/forward.
1294    ///
1295    /// `CGMouseButton` only names Left/Right/Center, so we create an
1296    /// `OtherMouse` event and override `MOUSE_EVENT_BUTTON_NUMBER` to address
1297    /// buttons ≥ 3. Tagged via [`tag_synthetic`] so OpenLogi's own event tap
1298    /// ignores it instead of re-translating it into a Back/Forward press.
1299    pub(super) fn post_other_button(button_number: i64) {
1300        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1301            tracing::warn!("CGEventSource::new failed for extra mouse button");
1302            return;
1303        };
1304        let location = CGEvent::new(src.clone()).map_or(CGPoint::new(0., 0.), |e| e.location());
1305        for (kind, phase) in [
1306            (CGEventType::OtherMouseDown, "down"),
1307            (CGEventType::OtherMouseUp, "up"),
1308        ] {
1309            if let Ok(ev) =
1310                CGEvent::new_mouse_event(src.clone(), kind, location, CGMouseButton::Center)
1311            {
1312                ev.set_integer_value_field(EventField::MOUSE_EVENT_BUTTON_NUMBER, button_number);
1313                tag_synthetic(&ev);
1314                ev.post(CGEventTapLocation::HID);
1315            } else {
1316                tracing::warn!(phase, "CGEvent::new_mouse_event failed for extra button");
1317            }
1318        }
1319    }
1320
1321    /// Stamp [`SYNTHETIC_EVENT_USER_DATA`](super::SYNTHETIC_EVENT_USER_DATA)
1322    /// into the event's source user-data so OpenLogi's own event tap recognises
1323    /// and skips its own injections instead of treating them as fresh input
1324    /// (e.g. re-translating a synthesized button 4/5 into a Back/Forward press,
1325    /// or misreading a remapped click as a new gesture hold).
1326    fn tag_synthetic(ev: &CGEvent) {
1327        ev.set_integer_value_field(
1328            EventField::EVENT_SOURCE_USER_DATA,
1329            super::SYNTHETIC_EVENT_USER_DATA,
1330        );
1331    }
1332
1333    /// Post a key-down + key-up pair for `vk` with `flags` set.
1334    pub(super) fn post_key(vk: u16, flags: CGEventFlags) {
1335        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1336            tracing::warn!("CGEventSource::new failed");
1337            return;
1338        };
1339        let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1340            tracing::warn!("CGEvent::new_keyboard_event(down) failed");
1341            return;
1342        };
1343        down.set_flags(flags);
1344        down.post(CGEventTapLocation::HID);
1345        let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1346            tracing::warn!("CGEvent::new_keyboard_event(up) failed");
1347            return;
1348        };
1349        up.set_flags(flags);
1350        up.post(CGEventTapLocation::HID);
1351    }
1352
1353    /// Post a media/system key event (play/pause, track navigation, volume).
1354    ///
1355    /// Runs on the hook/gesture dispatch threads, which have no run loop to
1356    /// drain autorelease pools, and both `NSEvent` creation and the `CGEvent`
1357    /// getter autorelease temporaries — so the exchange sits inside an
1358    /// explicit `autoreleasepool`, same as the hook's `frontmost_bundle_id`.
1359    pub(super) fn post_media_key(nx_key: i32) {
1360        use objc2::rc::autoreleasepool;
1361        use objc2_app_kit::{NSEvent, NSEventModifierFlags, NSEventType};
1362        use objc2_core_graphics::{CGEvent, CGEventTapLocation};
1363        use objc2_foundation::NSPoint;
1364
1365        const NX_SUBTYPE_AUX_CONTROL_BUTTONS: i16 = 8;
1366        const NX_KEY_DOWN: i32 = 0x0A;
1367        const NX_KEY_UP: i32 = 0x0B;
1368
1369        autoreleasepool(|_| {
1370            for (state, phase) in [(NX_KEY_DOWN, "down"), (NX_KEY_UP, "up")] {
1371                // data1 layout for subtype 8: high word is NX_KEYTYPE_*, next byte
1372                // is key state (0x0A down, 0x0B up), low bit is repeat (0 here).
1373                let data1 = ((nx_key << 16) | (state << 8)) as isize;
1374                let Some(ns_event) = NSEvent::otherEventWithType_location_modifierFlags_timestamp_windowNumber_context_subtype_data1_data2(
1375                    NSEventType::SystemDefined,
1376                    NSPoint::new(0.0, 0.0),
1377                    NSEventModifierFlags::empty(),
1378                    0.0,
1379                    0,
1380                    None,
1381                    NX_SUBTYPE_AUX_CONTROL_BUTTONS,
1382                    data1,
1383                    0,
1384                ) else {
1385                    tracing::warn!(nx_key, phase, "NSEvent::otherEventWithType failed");
1386                    return;
1387                };
1388                let Some(cg_event) = ns_event.CGEvent() else {
1389                    tracing::warn!(nx_key, phase, "NSEvent::CGEvent failed");
1390                    return;
1391                };
1392                CGEvent::post(CGEventTapLocation::HIDEventTap, Some(&cg_event));
1393            }
1394        });
1395    }
1396
1397    /// Post a synthetic scroll event for `action` (one of the `Scroll*` variants).
1398    pub(super) fn post_scroll(action: &Action) {
1399        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1400            tracing::warn!("CGEventSource::new failed for scroll");
1401            return;
1402        };
1403        let (v, h): (i32, i32) = match action {
1404            Action::ScrollUp => (3, 0),
1405            Action::ScrollDown => (-3, 0),
1406            Action::HorizontalScrollLeft => (0, -3),
1407            Action::HorizontalScrollRight => (0, 3),
1408            _ => return,
1409        };
1410        let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::PIXEL, 2, v, h, 0) else {
1411            tracing::warn!("CGEvent::new_scroll_event failed");
1412            return;
1413        };
1414        ev.post(CGEventTapLocation::HID);
1415    }
1416
1417    /// Post a horizontal scroll of `delta` lines (wheel2 axis). Line units suit
1418    /// the thumb wheel's ratchet-like increments better than pixels.
1419    pub(super) fn post_horizontal_scroll(delta: i32) {
1420        let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1421            tracing::warn!("CGEventSource::new failed for thumbwheel scroll");
1422            return;
1423        };
1424        let Ok(ev) = CGEvent::new_scroll_event(src, ScrollEventUnit::LINE, 2, 0, delta, 0) else {
1425            tracing::warn!("CGEvent::new_scroll_event failed for thumbwheel");
1426            return;
1427        };
1428        ev.post(CGEventTapLocation::HID);
1429    }
1430
1431    pub(super) use dock::{app_expose, launchpad, mission_control, show_desktop};
1432    pub(super) use symbolic_hotkey::{next_desktop, previous_desktop};
1433
1434    use app_services::symbol as app_services_symbol;
1435
1436    /// Shared resolver for private ApplicationServices SPI used by the Dock and
1437    /// symbolic-hotkey helpers.
1438    #[allow(
1439        unsafe_code,
1440        reason = "private ApplicationServices SPI symbols are resolved via dlopen/dlsym FFI"
1441    )]
1442    mod app_services {
1443        use std::ffi::{CStr, c_char, c_int, c_void};
1444        use std::sync::OnceLock;
1445
1446        /// Resolve a symbol from ApplicationServices, caching the `dlopen`
1447        /// handle for the process lifetime. Returns `None` if the framework or
1448        /// symbol is unavailable on this macOS version.
1449        pub(super) fn symbol(symbol: &CStr) -> Option<*mut c_void> {
1450            const RTLD_LAZY: c_int = 0x1;
1451            const APP_SERVICES: &CStr =
1452                c"/System/Library/Frameworks/ApplicationServices.framework/ApplicationServices";
1453            static HANDLE: OnceLock<usize> = OnceLock::new();
1454
1455            // SAFETY: `dlopen`/`dlsym` come from libSystem; APP_SERVICES and
1456            // `symbol` are valid C strings. The handle is cached and
1457            // intentionally never closed.
1458            let sym = unsafe {
1459                let handle =
1460                    *HANDLE.get_or_init(|| dlopen(APP_SERVICES.as_ptr(), RTLD_LAZY) as usize);
1461                if handle == 0 {
1462                    return None;
1463                }
1464                dlsym(handle as *mut c_void, symbol.as_ptr())
1465            };
1466            (!sym.is_null()).then_some(sym)
1467        }
1468
1469        unsafe extern "C" {
1470            fn dlopen(filename: *const c_char, flag: c_int) -> *mut c_void;
1471            fn dlsym(handle: *mut c_void, symbol: *const c_char) -> *mut c_void;
1472        }
1473    }
1474
1475    /// WindowServer window/space actions (Mission Control, App Exposé, Show
1476    /// Desktop, Launchpad).
1477    ///
1478    /// These are driven by the Dock, and synthesising their keyboard shortcut is
1479    /// unreliable — the WindowServer matcher needs the exact configured key
1480    /// (incl. the Fn flag) and Show Desktop's in particular doesn't respond. So
1481    /// we post the action straight to the Dock via the private
1482    /// `CoreDockSendNotification` SPI, which fires it regardless of the user's
1483    /// Keyboard settings.
1484    ///
1485    /// Isolated in its own submodule so the `unsafe` the `dlopen`/`dlsym` FFI
1486    /// needs is scoped here rather than spread across the platform helpers.
1487    #[allow(
1488        unsafe_code,
1489        reason = "the private CoreDockSendNotification SPI is only reachable via dlopen/dlsym FFI"
1490    )]
1491    mod dock {
1492        use std::ffi::{c_int, c_void};
1493
1494        use core_foundation::base::TCFType;
1495        use core_foundation::string::CFString;
1496
1497        use super::app_services_symbol;
1498
1499        /// Show all windows across spaces (Mission Control).
1500        pub(crate) fn mission_control() {
1501            send("com.apple.expose.awake");
1502        }
1503
1504        /// Show the front app's windows (App Exposé).
1505        pub(crate) fn app_expose() {
1506            send("com.apple.expose.front.awake");
1507        }
1508
1509        /// Move all windows aside to reveal the desktop.
1510        pub(crate) fn show_desktop() {
1511            send("com.apple.showdesktop.awake");
1512        }
1513
1514        /// Toggle Launchpad. A no-op on macOS 26, which removed Launchpad.
1515        pub(crate) fn launchpad() {
1516            send("com.apple.launchpad.toggle");
1517        }
1518
1519        /// Post `notification` to the Dock. Logs and returns on any failure.
1520        fn send(notification: &str) {
1521            let Some(core_dock_send) = core_dock_send_notification() else {
1522                tracing::warn!(notification, "CoreDockSendNotification unavailable");
1523                return;
1524            };
1525            let name = CFString::new(notification);
1526            // SAFETY: resolved AppServices symbol called with its documented
1527            // signature; `name` is a live CFString for the call's duration.
1528            let err = unsafe { core_dock_send(name.as_concrete_TypeRef().cast(), 0) };
1529            if err != 0 {
1530                tracing::warn!(notification, err, "CoreDockSendNotification failed");
1531            }
1532        }
1533
1534        type CoreDockSendNotificationFn = unsafe extern "C" fn(*const c_void, c_int) -> c_int;
1535
1536        /// Resolve `CoreDockSendNotification` from `ApplicationServices`, caching
1537        /// the `dlopen` handle for the process lifetime. `None` if unavailable.
1538        fn core_dock_send_notification() -> Option<CoreDockSendNotificationFn> {
1539            let sym = app_services_symbol(c"CoreDockSendNotification")?;
1540            // SAFETY: the symbol, when present, has the documented signature.
1541            Some(unsafe { std::mem::transmute::<*mut c_void, CoreDockSendNotificationFn>(sym) })
1542        }
1543    }
1544
1545    /// macOS Space switching actions.
1546    ///
1547    /// Use the system symbolic hotkey records for "Move left a space" (79) and
1548    /// "Move right a space" (81). That respects the user's configured shortcut
1549    /// instead of assuming Ctrl+Left/Right, and temporarily enables the symbolic
1550    /// hotkey when the user has disabled it.
1551    #[allow(
1552        unsafe_code,
1553        reason = "CGS symbolic hotkey SPI is only reachable via dlopen/dlsym FFI"
1554    )]
1555    mod symbolic_hotkey {
1556        use std::ffi::{c_int, c_uint, c_ushort, c_void};
1557
1558        use core_graphics::event::{CGEvent, CGEventFlags, CGEventTapLocation};
1559        use core_graphics::event_source::{CGEventSource, CGEventSourceStateID};
1560
1561        use super::app_services_symbol;
1562
1563        const SPACE_LEFT: u32 = 79;
1564        const SPACE_RIGHT: u32 = 81;
1565
1566        /// Switch to the previous desktop / Space.
1567        pub(crate) fn previous_desktop() {
1568            post_symbolic_hotkey(SPACE_LEFT);
1569        }
1570
1571        /// Switch to the next desktop / Space.
1572        pub(crate) fn next_desktop() {
1573            post_symbolic_hotkey(SPACE_RIGHT);
1574        }
1575
1576        fn post_symbolic_hotkey(hotkey: u32) {
1577            let Some(cgs) = cgs_hotkey_api() else {
1578                tracing::warn!(hotkey, "CGS symbolic hotkey API unavailable");
1579                return;
1580            };
1581
1582            let mut key_equivalent = 0_u16;
1583            let mut virtual_key = 0_u16;
1584            let mut modifiers = 0_u32;
1585
1586            // SAFETY: resolved AppServices symbols are called with their
1587            // expected signatures and valid out-parameters.
1588            let err = unsafe {
1589                (cgs.get_value)(
1590                    hotkey,
1591                    &raw mut key_equivalent,
1592                    &raw mut virtual_key,
1593                    &raw mut modifiers,
1594                )
1595            };
1596            if err != 0 {
1597                tracing::warn!(hotkey, err, "CGSGetSymbolicHotKeyValue failed");
1598                return;
1599            }
1600
1601            // SAFETY: resolved AppServices symbol called with its expected
1602            // signature.
1603            let was_enabled = unsafe { (cgs.is_enabled)(hotkey) };
1604            if !was_enabled {
1605                // SAFETY: resolved AppServices symbol called with its expected
1606                // signature.
1607                let err = unsafe { (cgs.set_enabled)(hotkey, true) };
1608                if err != 0 {
1609                    tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(true) failed");
1610                }
1611            }
1612
1613            post_key(virtual_key, modifiers);
1614
1615            if !was_enabled {
1616                // SAFETY: resolved AppServices symbol called with its expected
1617                // signature.
1618                let err = unsafe { (cgs.set_enabled)(hotkey, false) };
1619                if err != 0 {
1620                    tracing::warn!(hotkey, err, "CGSSetSymbolicHotKeyEnabled(false) failed");
1621                }
1622            }
1623        }
1624
1625        fn post_key(vk: u16, modifiers: u32) {
1626            let Ok(src) = CGEventSource::new(CGEventSourceStateID::HIDSystemState) else {
1627                tracing::warn!("CGEventSource::new failed for symbolic hotkey");
1628                return;
1629            };
1630            let Ok(down) = CGEvent::new_keyboard_event(src.clone(), vk, true) else {
1631                tracing::warn!(vk, "CGEvent::new_keyboard_event(down) failed");
1632                return;
1633            };
1634            let flags = CGEventFlags::from_bits_truncate(u64::from(modifiers));
1635            down.set_flags(flags);
1636            down.post(CGEventTapLocation::Session);
1637
1638            let Ok(up) = CGEvent::new_keyboard_event(src, vk, false) else {
1639                tracing::warn!(vk, "CGEvent::new_keyboard_event(up) failed");
1640                return;
1641            };
1642            up.set_flags(flags);
1643            up.post(CGEventTapLocation::Session);
1644        }
1645
1646        #[derive(Clone, Copy)]
1647        struct CgsHotkeyApi {
1648            get_value: CgsGetSymbolicHotKeyValueFn,
1649            is_enabled: CgsIsSymbolicHotKeyEnabledFn,
1650            set_enabled: CgsSetSymbolicHotKeyEnabledFn,
1651        }
1652
1653        type CgsGetSymbolicHotKeyValueFn =
1654            unsafe extern "C" fn(c_uint, *mut c_ushort, *mut c_ushort, *mut c_uint) -> c_int;
1655        type CgsIsSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint) -> bool;
1656        type CgsSetSymbolicHotKeyEnabledFn = unsafe extern "C" fn(c_uint, bool) -> c_int;
1657
1658        fn cgs_hotkey_api() -> Option<CgsHotkeyApi> {
1659            let get_value = app_services_symbol(c"CGSGetSymbolicHotKeyValue")?;
1660            let is_enabled = app_services_symbol(c"CGSIsSymbolicHotKeyEnabled")?;
1661            let set_enabled = app_services_symbol(c"CGSSetSymbolicHotKeyEnabled")?;
1662
1663            // SAFETY: the symbols, when present, have the private SPI
1664            // signatures declared above.
1665            Some(unsafe {
1666                CgsHotkeyApi {
1667                    get_value: std::mem::transmute::<*mut c_void, CgsGetSymbolicHotKeyValueFn>(
1668                        get_value,
1669                    ),
1670                    is_enabled: std::mem::transmute::<*mut c_void, CgsIsSymbolicHotKeyEnabledFn>(
1671                        is_enabled,
1672                    ),
1673                    set_enabled: std::mem::transmute::<*mut c_void, CgsSetSymbolicHotKeyEnabledFn>(
1674                        set_enabled,
1675                    ),
1676                }
1677            })
1678        }
1679    }
1680}
1681
1682/// Sensible defaults for a fresh device so the panel isn't empty on first run.
1683///
1684/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
1685/// MX-line devices: thumb wheel click → App Exposé, gesture button →
1686/// Mission Control. The thumb wheel isn't captured yet; the gesture button is
1687/// (per-direction, see [`default_gesture_binding`]). The bindings persist
1688/// regardless so the user only configures once.
1689///
1690/// `GestureButton`'s entry here is vestigial: in the merged [`Binding`] model
1691/// the gesture button defaults to [`Binding::Gesture`] (see
1692/// [`default_binding_for`]), so this single-action value is never the source of
1693/// truth for it. It is retained only so the per-button-`Action` callers (the
1694/// hook map, scroll defaults, labels) stay total.
1695#[must_use]
1696pub fn default_binding(button: ButtonId) -> Action {
1697    match button {
1698        ButtonId::LeftClick => Action::LeftClick,
1699        ButtonId::RightClick => Action::RightClick,
1700        ButtonId::MiddleClick => Action::MiddleClick,
1701        ButtonId::Back => Action::BrowserBack,
1702        ButtonId::Forward => Action::BrowserForward,
1703        ButtonId::DpiToggle => Action::CycleDpiPresets,
1704        ButtonId::Thumbwheel => Action::AppExpose,
1705        // The thumb wheel scrolls horizontally by default: rotating it produces
1706        // continuous horizontal scroll, with "up" → right and "down" → left.
1707        // The wheel watcher renders these two actions as smooth, sensitivity-
1708        // scaled scrolling rather than the discrete per-press burst a button
1709        // would get (see `watchers::gesture`).
1710        ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
1711        ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
1712        ButtonId::GestureButton => Action::MissionControl,
1713    }
1714}
1715
1716/// Per-direction defaults for the gesture button. These are captured live over
1717/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
1718/// defaults give the picker something sensible to show on first run.
1719#[must_use]
1720pub fn default_gesture_binding(direction: GestureDirection) -> Action {
1721    match direction {
1722        GestureDirection::Up => Action::MissionControl,
1723        GestureDirection::Down => Action::ShowDesktop,
1724        GestureDirection::Left => Action::PrevTab,
1725        GestureDirection::Right => Action::NextTab,
1726        GestureDirection::Click => Action::AppExpose,
1727    }
1728}
1729
1730/// The canonical default [`Binding`] for a fresh button in the merged model.
1731///
1732/// [`ButtonId::GestureButton`] defaults to [`Binding::Gesture`] populated from
1733/// [`default_gesture_binding`] — preserving the existing per-direction swipe
1734/// behavior — so the GUI mode toggle and the runtime agree it starts in gesture
1735/// mode. Every other button defaults to [`Binding::Single`] of its
1736/// [`default_binding`].
1737///
1738/// This is the seed when a button is first promoted to a gesture binding (see
1739/// [`Config::set_gesture_direction`](crate::config::Config::set_gesture_direction)),
1740/// so a freshly-customized gesture button always carries a full default
1741/// direction map — including a [`GestureDirection::Click`] — rather than a sparse
1742/// map whose click would project to a no-op [`Action::None`].
1743#[must_use]
1744pub fn default_binding_for(button: ButtonId) -> Binding {
1745    match button {
1746        ButtonId::GestureButton => Binding::Gesture(
1747            GestureDirection::ALL
1748                .into_iter()
1749                .map(|d| (d, default_gesture_binding(d)))
1750                .collect(),
1751        ),
1752        other => Binding::Single(default_binding(other)),
1753    }
1754}
1755
1756/// Linux helpers for synthesising OS-level input events via a shared `uinput`
1757/// virtual device.
1758///
1759/// The device is created lazily on first use. If `/dev/uinput` is inaccessible
1760/// (missing group membership or udev rule) every call logs a `warn` and returns
1761/// without panicking.
1762#[cfg(target_os = "linux")]
1763mod linux {
1764    use std::io;
1765    use std::sync::{LazyLock, Mutex};
1766
1767    use evdev::uinput::VirtualDevice;
1768    use evdev::{AttributeSet, EventType, InputEvent, KeyCode, RelativeAxisCode};
1769    use zbus::blocking::Connection as DbusConn;
1770
1771    const DEVICE_NAME: &str = "OpenLogi action injector";
1772
1773    static VIRTUAL_INPUT: LazyLock<Option<Mutex<VirtualDevice>>> = LazyLock::new(|| {
1774        build()
1775            .map(Mutex::new)
1776            .map_err(|e| tracing::warn!("failed to create uinput action device: {e}"))
1777            .ok()
1778    });
1779
1780    #[rustfmt::skip]
1781    const KEY_CAPABILITIES: &[KeyCode] = &[
1782        // Letters
1783        KeyCode::KEY_A, KeyCode::KEY_B, KeyCode::KEY_C, KeyCode::KEY_D,
1784        KeyCode::KEY_E, KeyCode::KEY_F, KeyCode::KEY_G, KeyCode::KEY_H,
1785        KeyCode::KEY_I, KeyCode::KEY_J, KeyCode::KEY_K, KeyCode::KEY_L,
1786        KeyCode::KEY_M, KeyCode::KEY_N, KeyCode::KEY_O, KeyCode::KEY_P,
1787        KeyCode::KEY_Q, KeyCode::KEY_R, KeyCode::KEY_S, KeyCode::KEY_T,
1788        KeyCode::KEY_U, KeyCode::KEY_V, KeyCode::KEY_W, KeyCode::KEY_X,
1789        KeyCode::KEY_Y, KeyCode::KEY_Z,
1790        // Digits
1791        KeyCode::KEY_0, KeyCode::KEY_1, KeyCode::KEY_2, KeyCode::KEY_3,
1792        KeyCode::KEY_4, KeyCode::KEY_5, KeyCode::KEY_6, KeyCode::KEY_7,
1793        KeyCode::KEY_8, KeyCode::KEY_9,
1794        // Punctuation / symbols
1795        KeyCode::KEY_MINUS,      KeyCode::KEY_EQUAL,   KeyCode::KEY_LEFTBRACE,
1796        KeyCode::KEY_RIGHTBRACE, KeyCode::KEY_BACKSLASH, KeyCode::KEY_SEMICOLON,
1797        KeyCode::KEY_APOSTROPHE, KeyCode::KEY_GRAVE,   KeyCode::KEY_COMMA,
1798        KeyCode::KEY_DOT,        KeyCode::KEY_SLASH,
1799        // Navigation / editing
1800        KeyCode::KEY_LEFT,  KeyCode::KEY_RIGHT, KeyCode::KEY_UP,       KeyCode::KEY_DOWN,
1801        KeyCode::KEY_HOME,  KeyCode::KEY_END,   KeyCode::KEY_PAGEUP,   KeyCode::KEY_PAGEDOWN,
1802        KeyCode::KEY_TAB,   KeyCode::KEY_ENTER, KeyCode::KEY_BACKSPACE, KeyCode::KEY_DELETE,
1803        KeyCode::KEY_ESC,   KeyCode::KEY_SPACE,
1804        // Modifiers (KEY_LEFTMETA used by the LockScreen Super+L fallback)
1805        KeyCode::KEY_LEFTCTRL, KeyCode::KEY_LEFTSHIFT, KeyCode::KEY_LEFTALT, KeyCode::KEY_LEFTMETA,
1806        // Function keys
1807        KeyCode::KEY_F1,  KeyCode::KEY_F2,  KeyCode::KEY_F3,  KeyCode::KEY_F4,
1808        KeyCode::KEY_F5,  KeyCode::KEY_F6,  KeyCode::KEY_F7,  KeyCode::KEY_F8,
1809        KeyCode::KEY_F9,  KeyCode::KEY_F10, KeyCode::KEY_F11, KeyCode::KEY_F12,
1810        // System
1811        KeyCode::KEY_SYSRQ,
1812        // Multimedia
1813        KeyCode::KEY_PLAYPAUSE, KeyCode::KEY_NEXTSONG, KeyCode::KEY_PREVIOUSSONG,
1814        KeyCode::KEY_VOLUMEUP,  KeyCode::KEY_VOLUMEDOWN, KeyCode::KEY_MUTE,
1815        // Mouse buttons (injected as EV_KEY with BTN_* codes). The side pair
1816        // must be registered here or the kernel silently drops their events.
1817        KeyCode::BTN_LEFT, KeyCode::BTN_RIGHT, KeyCode::BTN_MIDDLE,
1818        KeyCode::BTN_SIDE, KeyCode::BTN_EXTRA,
1819    ];
1820
1821    fn build() -> io::Result<VirtualDevice> {
1822        let mut keys = AttributeSet::<KeyCode>::default();
1823        for &k in KEY_CAPABILITIES {
1824            keys.insert(k);
1825        }
1826
1827        // Only scroll axes: the device never emits cursor movement, so leaving
1828        // out REL_X/REL_Y keeps libinput from classifying it as a pointer —
1829        // which can otherwise cause injected key/wheel events to be grabbed by
1830        // pointer-grabbing X11 clients or routed oddly by some Wayland compositors.
1831        let mut axes = AttributeSet::<RelativeAxisCode>::default();
1832        for a in [RelativeAxisCode::REL_WHEEL, RelativeAxisCode::REL_HWHEEL] {
1833            axes.insert(a);
1834        }
1835
1836        VirtualDevice::builder()?
1837            .name(DEVICE_NAME)
1838            .with_keys(&keys)?
1839            .with_relative_axes(&axes)?
1840            .build()
1841    }
1842
1843    fn emit(events: &[InputEvent]) {
1844        if let Some(m) = &*VIRTUAL_INPUT {
1845            if let Ok(mut guard) = m.lock() {
1846                if let Err(e) = guard.emit(events) {
1847                    tracing::warn!("uinput action emit failed: {e}");
1848                }
1849            } else {
1850                tracing::warn!("uinput action device mutex poisoned");
1851            }
1852        } else {
1853            // Device creation failed at init; already logged once in LazyLock.
1854            tracing::debug!("uinput action device unavailable — action skipped");
1855        }
1856    }
1857
1858    fn syn() -> InputEvent {
1859        InputEvent::new(EventType::SYNCHRONIZATION.0, 0, 0)
1860    }
1861
1862    fn key_ev(code: KeyCode, value: i32) -> InputEvent {
1863        InputEvent::new(EventType::KEY.0, code.0, value)
1864    }
1865
1866    fn rel_ev(axis: RelativeAxisCode, value: i32) -> InputEvent {
1867        InputEvent::new(EventType::RELATIVE.0, axis.0, value)
1868    }
1869
1870    /// Inject modifier-down + key-down in one SYN frame, then key-up +
1871    /// modifier-up in a second SYN frame.
1872    ///
1873    /// Two separate frames give the kernel distinct timestamps for press and
1874    /// release, which matches what the kernel `uinput` docs show and avoids
1875    /// toolkits treating a zero-duration event as invalid.
1876    pub(super) fn press_key(mods: &[KeyCode], key: KeyCode) {
1877        // Down phase.
1878        let mut down: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1879        for &m in mods {
1880            down.push(key_ev(m, 1));
1881        }
1882        down.push(key_ev(key, 1));
1883        down.push(syn());
1884        emit(&down);
1885
1886        // Up phase.
1887        let mut up: Vec<InputEvent> = Vec::with_capacity(mods.len() + 2);
1888        up.push(key_ev(key, 0));
1889        for &m in mods.iter().rev() {
1890            up.push(key_ev(m, 0));
1891        }
1892        up.push(syn());
1893        emit(&up);
1894    }
1895
1896    /// Inject a button-down in one SYN frame and button-up in a second.
1897    pub(super) fn click(button: KeyCode) {
1898        emit(&[key_ev(button, 1), syn()]);
1899        emit(&[key_ev(button, 0), syn()]);
1900    }
1901
1902    /// Inject a single relative-axis delta followed by `SYN_REPORT`.
1903    pub(super) fn scroll(axis: RelativeAxisCode, value: i32) {
1904        emit(&[rel_ev(axis, value), syn()]);
1905    }
1906
1907    /// Force the virtual device to initialise (if it hasn't already) and return
1908    /// its `/dev/input/eventN` node path.
1909    ///
1910    /// Uses `VirtualDevice::enumerate_dev_nodes()` which returns the correct
1911    /// `/dev/input/eventN` path directly. Returns `None` if the device couldn't
1912    /// be created or if the node hasn't appeared yet (udev typically creates it
1913    /// within a few milliseconds of the `ioctl`).
1914    pub(super) fn device_node() -> Option<std::path::PathBuf> {
1915        // Touch the LazyLock to force initialisation.
1916        let _ = &*VIRTUAL_INPUT;
1917        // Give udev a moment to create the /dev node.
1918        std::thread::sleep(std::time::Duration::from_millis(150));
1919        if let Some(m) = &*VIRTUAL_INPUT
1920            && let Ok(mut guard) = m.lock()
1921        {
1922            return guard.enumerate_dev_nodes_blocking().ok()?.flatten().next();
1923        }
1924        None
1925    }
1926
1927    /// Convert a [`KeyCombo`] modifier bitmask to the evdev keys to hold.
1928    ///
1929    /// macOS Cmd (`MOD_CMD`) and Ctrl (`MOD_CTRL`) both map to `KEY_LEFTCTRL`;
1930    /// the bitwise-OR check deduplicates them so at most one Ctrl is pushed.
1931    /// Order is canonical: Ctrl → Shift → Alt.
1932    pub(super) fn modifiers_to_keycodes(modifiers: u8) -> Vec<KeyCode> {
1933        use crate::binding::KeyCombo;
1934        let mut mods = Vec::new();
1935        if modifiers & (KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL) != 0 {
1936            mods.push(KeyCode::KEY_LEFTCTRL);
1937        }
1938        if modifiers & KeyCombo::MOD_SHIFT != 0 {
1939            mods.push(KeyCode::KEY_LEFTSHIFT);
1940        }
1941        if modifiers & KeyCombo::MOD_OPTION != 0 {
1942            mods.push(KeyCode::KEY_LEFTALT);
1943        }
1944        mods
1945    }
1946
1947    /// Map a macOS `kVK_*` virtual key code to the corresponding Linux `KeyCode`.
1948    ///
1949    /// Source: `HIToolbox/Events.h` (macOS side) and
1950    /// `linux/input-event-codes.h` (Linux side). Only the codes the recorder UI
1951    /// is likely to produce are mapped; unknown codes return `None`.
1952    pub(super) fn macos_vk_to_linux(vk: u16) -> Option<KeyCode> {
1953        Some(match vk {
1954            0x00 => KeyCode::KEY_A,          // kVK_ANSI_A
1955            0x01 => KeyCode::KEY_S,          // kVK_ANSI_S
1956            0x02 => KeyCode::KEY_D,          // kVK_ANSI_D
1957            0x03 => KeyCode::KEY_F,          // kVK_ANSI_F
1958            0x04 => KeyCode::KEY_H,          // kVK_ANSI_H
1959            0x05 => KeyCode::KEY_G,          // kVK_ANSI_G
1960            0x06 => KeyCode::KEY_Z,          // kVK_ANSI_Z
1961            0x07 => KeyCode::KEY_X,          // kVK_ANSI_X
1962            0x08 => KeyCode::KEY_C,          // kVK_ANSI_C
1963            0x09 => KeyCode::KEY_V,          // kVK_ANSI_V
1964            0x0B => KeyCode::KEY_B,          // kVK_ANSI_B
1965            0x0C => KeyCode::KEY_Q,          // kVK_ANSI_Q
1966            0x0D => KeyCode::KEY_W,          // kVK_ANSI_W
1967            0x0E => KeyCode::KEY_E,          // kVK_ANSI_E
1968            0x0F => KeyCode::KEY_R,          // kVK_ANSI_R
1969            0x10 => KeyCode::KEY_Y,          // kVK_ANSI_Y
1970            0x11 => KeyCode::KEY_T,          // kVK_ANSI_T
1971            0x12 => KeyCode::KEY_1,          // kVK_ANSI_1
1972            0x13 => KeyCode::KEY_2,          // kVK_ANSI_2
1973            0x14 => KeyCode::KEY_3,          // kVK_ANSI_3
1974            0x15 => KeyCode::KEY_4,          // kVK_ANSI_4
1975            0x16 => KeyCode::KEY_6,          // kVK_ANSI_6
1976            0x17 => KeyCode::KEY_5,          // kVK_ANSI_5
1977            0x18 => KeyCode::KEY_EQUAL,      // kVK_ANSI_Equal
1978            0x19 => KeyCode::KEY_9,          // kVK_ANSI_9
1979            0x1A => KeyCode::KEY_7,          // kVK_ANSI_7
1980            0x1B => KeyCode::KEY_MINUS,      // kVK_ANSI_Minus
1981            0x1C => KeyCode::KEY_8,          // kVK_ANSI_8
1982            0x1D => KeyCode::KEY_0,          // kVK_ANSI_0
1983            0x1E => KeyCode::KEY_RIGHTBRACE, // kVK_ANSI_RightBracket
1984            0x1F => KeyCode::KEY_O,          // kVK_ANSI_O
1985            0x20 => KeyCode::KEY_U,          // kVK_ANSI_U
1986            0x21 => KeyCode::KEY_LEFTBRACE,  // kVK_ANSI_LeftBracket
1987            0x22 => KeyCode::KEY_I,          // kVK_ANSI_I
1988            0x23 => KeyCode::KEY_P,          // kVK_ANSI_P
1989            0x24 => KeyCode::KEY_ENTER,      // kVK_Return
1990            0x25 => KeyCode::KEY_L,          // kVK_ANSI_L
1991            0x26 => KeyCode::KEY_J,          // kVK_ANSI_J
1992            0x27 => KeyCode::KEY_APOSTROPHE, // kVK_ANSI_Quote
1993            0x28 => KeyCode::KEY_K,          // kVK_ANSI_K
1994            0x29 => KeyCode::KEY_SEMICOLON,  // kVK_ANSI_Semicolon
1995            0x2A => KeyCode::KEY_BACKSLASH,  // kVK_ANSI_Backslash
1996            0x2B => KeyCode::KEY_COMMA,      // kVK_ANSI_Comma
1997            0x2C => KeyCode::KEY_SLASH,      // kVK_ANSI_Slash
1998            0x2D => KeyCode::KEY_N,          // kVK_ANSI_N
1999            0x2E => KeyCode::KEY_M,          // kVK_ANSI_M
2000            0x2F => KeyCode::KEY_DOT,        // kVK_ANSI_Period
2001            0x30 => KeyCode::KEY_TAB,        // kVK_Tab
2002            0x31 => KeyCode::KEY_SPACE,      // kVK_Space
2003            0x32 => KeyCode::KEY_GRAVE,      // kVK_ANSI_Grave
2004            0x33 => KeyCode::KEY_BACKSPACE,  // kVK_Delete (= Backspace on macOS)
2005            0x35 => KeyCode::KEY_ESC,        // kVK_Escape
2006            0x60 => KeyCode::KEY_F5,         // kVK_F5
2007            0x61 => KeyCode::KEY_F6,         // kVK_F6
2008            0x62 => KeyCode::KEY_F7,         // kVK_F7
2009            0x63 => KeyCode::KEY_F3,         // kVK_F3
2010            0x64 => KeyCode::KEY_F8,         // kVK_F8
2011            0x65 => KeyCode::KEY_F9,         // kVK_F9
2012            0x67 => KeyCode::KEY_F11,        // kVK_F11
2013            0x6D => KeyCode::KEY_F10,        // kVK_F10
2014            0x6F => KeyCode::KEY_F12,        // kVK_F12
2015            0x76 => KeyCode::KEY_F4,         // kVK_F4
2016            0x78 => KeyCode::KEY_F2,         // kVK_F2
2017            0x7A => KeyCode::KEY_F1,         // kVK_F1
2018            0x73 => KeyCode::KEY_HOME,       // kVK_Home
2019            0x77 => KeyCode::KEY_END,        // kVK_End
2020            0x74 => KeyCode::KEY_PAGEUP,     // kVK_PageUp
2021            0x79 => KeyCode::KEY_PAGEDOWN,   // kVK_PageDown
2022            0x75 => KeyCode::KEY_DELETE,     // kVK_ForwardDelete
2023            0x7B => KeyCode::KEY_LEFT,       // kVK_LeftArrow
2024            0x7C => KeyCode::KEY_RIGHT,      // kVK_RightArrow
2025            0x7D => KeyCode::KEY_DOWN,       // kVK_DownArrow
2026            0x7E => KeyCode::KEY_UP,         // kVK_UpArrow
2027            _ => return None,
2028        })
2029    }
2030
2031    // ── D-Bus helpers ────────────────────────────────────────────────────────
2032
2033    static SESSION_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
2034        DbusConn::session()
2035            .map_err(|e| tracing::warn!("D-Bus session bus unavailable: {e}"))
2036            .ok()
2037    });
2038
2039    static SYSTEM_BUS: LazyLock<Option<DbusConn>> = LazyLock::new(|| {
2040        DbusConn::system()
2041            .map_err(|e| tracing::warn!("D-Bus system bus unavailable: {e}"))
2042            .ok()
2043    });
2044
2045    /// Lock the screen via logind `LockSession($XDG_SESSION_ID)` on the system
2046    /// bus, falling back to Super+L.
2047    ///
2048    /// Only the session identified by `$XDG_SESSION_ID` is locked; if the
2049    /// variable is unset the D-Bus path is skipped entirely to avoid locking
2050    /// all sessions on the machine. Super+L covers non-systemd systems and the
2051    /// no-session-id case.
2052    pub(super) fn lock_screen() {
2053        if let (Some(conn), Ok(id)) = (SYSTEM_BUS.as_ref(), std::env::var("XDG_SESSION_ID")) {
2054            match conn.call_method(
2055                Some("org.freedesktop.login1"),
2056                "/org/freedesktop/login1",
2057                Some("org.freedesktop.login1.Manager"),
2058                "LockSession",
2059                &(id.as_str(),),
2060            ) {
2061                Ok(_) => {
2062                    tracing::debug!("LockScreen via logind");
2063                    return;
2064                }
2065                Err(e) => tracing::warn!("logind LockSession failed: {e}"),
2066            }
2067        }
2068        // Super+L is the standard lock shortcut on GNOME and KDE.
2069        tracing::debug!("LockScreen via Super+L key combo");
2070        press_key(&[KeyCode::KEY_LEFTMETA], KeyCode::KEY_L);
2071    }
2072
2073    /// Send `command` to the first MPRIS-capable media player on the session bus,
2074    /// falling back to the corresponding XF86 multimedia key only if no MPRIS
2075    /// player is found. When a player is found but the call fails, the fallback
2076    /// is suppressed to avoid double-toggling (the player likely handles the
2077    /// XF86 key too).
2078    pub(super) fn mpris_command(command: &str) {
2079        if try_mpris_command(command).is_none() {
2080            let fallback = match command {
2081                "PlayPause" => KeyCode::KEY_PLAYPAUSE,
2082                "Next" => KeyCode::KEY_NEXTSONG,
2083                "Previous" => KeyCode::KEY_PREVIOUSSONG,
2084                _ => return,
2085            };
2086            press_key(&[], fallback);
2087        }
2088    }
2089
2090    fn try_mpris_command(command: &str) -> Option<()> {
2091        let conn = SESSION_BUS.as_ref()?;
2092        let reply = conn
2093            .call_method(
2094                Some("org.freedesktop.DBus"),
2095                "/org/freedesktop/DBus",
2096                Some("org.freedesktop.DBus"),
2097                "ListNames",
2098                &(),
2099            )
2100            .ok()?;
2101        let names = reply.body().deserialize::<Vec<String>>().ok()?;
2102        let Some(player) = names
2103            .iter()
2104            .find(|n| n.starts_with("org.mpris.MediaPlayer2."))
2105        else {
2106            tracing::debug!("no MPRIS player found — {command} via XF86 key fallback");
2107            return None;
2108        };
2109        match conn.call_method(
2110            Some(player.as_str()),
2111            "/org/mpris/MediaPlayer2",
2112            Some("org.mpris.MediaPlayer2.Player"),
2113            command,
2114            &(),
2115        ) {
2116            Ok(_) => {
2117                tracing::debug!("MPRIS {command} via {player}");
2118                Some(())
2119            }
2120            Err(e) => {
2121                // Player was identified — suppress XF86 fallback to avoid
2122                // double-toggling if the player also handles multimedia keys.
2123                tracing::warn!("MPRIS {command} on {player} failed: {e}");
2124                Some(())
2125            }
2126        }
2127    }
2128}
2129
2130/// Translate a macOS virtual key code (`kVK_*`, captured when a `CustomShortcut`
2131/// was recorded on macOS) to the equivalent Windows virtual-key code, so a chord
2132/// synced from a Mac fires the right key on Windows.
2133///
2134/// Covers letters, digits, the ANSI punctuation keys, whitespace/editing keys,
2135/// navigation, and F1–F20 — every key a shortcut realistically uses. Modifier
2136/// keys are applied separately from `KeyCombo::modifiers`; the numeric keypad,
2137/// media, and volume keys are intentionally omitted (they are modifiers or
2138/// already have dedicated actions). `None` for an unmapped code, which
2139/// `post_custom_shortcut` warns-and-drops.
2140///
2141/// Source codes: `<HIToolbox/Events.h>` kVK_* constants. Targets: Win32
2142/// virtual-key codes (letters/digits are their ASCII values; F1 = 0x70).
2143#[cfg_attr(
2144    not(target_os = "windows"),
2145    allow(
2146        dead_code,
2147        reason = "pure key-code table is exercised by host unit tests; its only runtime caller is the Windows-gated post_custom_shortcut"
2148    )
2149)]
2150fn mac_virtual_key_to_windows(key_code: u16) -> Option<u16> {
2151    Some(match key_code {
2152        // ── Letters (Windows VK_A..VK_Z = ASCII 'A'..'Z') ──
2153        0x00 => 0x41, // A
2154        0x0B => 0x42, // B
2155        0x08 => 0x43, // C
2156        0x02 => 0x44, // D
2157        0x0E => 0x45, // E
2158        0x03 => 0x46, // F
2159        0x05 => 0x47, // G
2160        0x04 => 0x48, // H
2161        0x22 => 0x49, // I
2162        0x26 => 0x4A, // J
2163        0x28 => 0x4B, // K
2164        0x25 => 0x4C, // L
2165        0x2E => 0x4D, // M
2166        0x2D => 0x4E, // N
2167        0x1F => 0x4F, // O
2168        0x23 => 0x50, // P
2169        0x0C => 0x51, // Q
2170        0x0F => 0x52, // R
2171        0x01 => 0x53, // S
2172        0x11 => 0x54, // T
2173        0x20 => 0x55, // U
2174        0x09 => 0x56, // V
2175        0x0D => 0x57, // W
2176        0x07 => 0x58, // X
2177        0x10 => 0x59, // Y
2178        0x06 => 0x5A, // Z
2179        // ── Digits (Windows VK_0..VK_9 = ASCII '0'..'9') ──
2180        0x1D => 0x30, // 0
2181        0x12 => 0x31, // 1
2182        0x13 => 0x32, // 2
2183        0x14 => 0x33, // 3
2184        0x15 => 0x34, // 4
2185        0x17 => 0x35, // 5
2186        0x16 => 0x36, // 6
2187        0x1A => 0x37, // 7
2188        0x1C => 0x38, // 8
2189        0x19 => 0x39, // 9
2190        // ── ANSI punctuation (Windows VK_OEM_*) ──
2191        0x1B => 0xBD, // -  VK_OEM_MINUS
2192        0x18 => 0xBB, // =  VK_OEM_PLUS
2193        0x21 => 0xDB, // [  VK_OEM_4
2194        0x1E => 0xDD, // ]  VK_OEM_6
2195        0x2A => 0xDC, // \  VK_OEM_5
2196        0x29 => 0xBA, // ;  VK_OEM_1
2197        0x27 => 0xDE, // '  VK_OEM_7
2198        0x2B => 0xBC, // ,  VK_OEM_COMMA
2199        0x2F => 0xBE, // .  VK_OEM_PERIOD
2200        0x2C => 0xBF, // /  VK_OEM_2
2201        0x32 => 0xC0, // `  VK_OEM_3
2202        // ── Whitespace / editing ──
2203        0x24 => 0x0D, // Return     VK_RETURN
2204        0x30 => 0x09, // Tab        VK_TAB
2205        0x31 => 0x20, // Space      VK_SPACE
2206        0x33 => 0x08, // Backspace  VK_BACK
2207        0x35 => 0x1B, // Escape     VK_ESCAPE
2208        // ── Navigation ──
2209        0x73 => 0x24, // Home          VK_HOME
2210        0x77 => 0x23, // End           VK_END
2211        0x74 => 0x21, // PageUp        VK_PRIOR
2212        0x79 => 0x22, // PageDown      VK_NEXT
2213        0x75 => 0x2E, // ForwardDelete VK_DELETE
2214        0x7B => 0x25, // LeftArrow     VK_LEFT
2215        0x7C => 0x27, // RightArrow    VK_RIGHT
2216        0x7D => 0x28, // DownArrow     VK_DOWN
2217        0x7E => 0x26, // UpArrow       VK_UP
2218        // ── Function keys (Windows VK_F1 = 0x70, sequential through VK_F24) ──
2219        0x7A => 0x70, // F1
2220        0x78 => 0x71, // F2
2221        0x63 => 0x72, // F3
2222        0x76 => 0x73, // F4
2223        0x60 => 0x74, // F5
2224        0x61 => 0x75, // F6
2225        0x62 => 0x76, // F7
2226        0x64 => 0x77, // F8
2227        0x65 => 0x78, // F9
2228        0x6D => 0x79, // F10
2229        0x67 => 0x7A, // F11
2230        0x6F => 0x7B, // F12
2231        0x69 => 0x7C, // F13
2232        0x6B => 0x7D, // F14
2233        0x71 => 0x7E, // F15
2234        0x6A => 0x7F, // F16
2235        0x40 => 0x80, // F17
2236        0x4F => 0x81, // F18
2237        0x50 => 0x82, // F19
2238        0x5A => 0x83, // F20
2239        _ => return None,
2240    })
2241}
2242
2243#[cfg(target_os = "windows")]
2244#[allow(unsafe_code, reason = "SendInput is the Win32 API for synthetic input")]
2245mod windows {
2246    use std::mem::size_of;
2247
2248    use windows_sys::Win32::UI::Input::KeyboardAndMouse::{
2249        INPUT, INPUT_0, INPUT_KEYBOARD, INPUT_MOUSE, KEYBDINPUT, KEYEVENTF_KEYUP,
2250        MOUSEEVENTF_HWHEEL, MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, MOUSEEVENTF_MIDDLEDOWN,
2251        MOUSEEVENTF_MIDDLEUP, MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, MOUSEEVENTF_WHEEL,
2252        MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, MOUSEINPUT, SendInput,
2253    };
2254
2255    use crate::binding::{Action, KeyCombo};
2256
2257    const WHEEL_DELTA: i32 = 120;
2258
2259    pub(super) const VK_A: u16 = 0x41;
2260    pub(super) const VK_C: u16 = 0x43;
2261    pub(super) const VK_D: u16 = 0x44;
2262    pub(super) const VK_F: u16 = 0x46;
2263    pub(super) const VK_L: u16 = 0x4C;
2264    pub(super) const VK_R: u16 = 0x52;
2265    pub(super) const VK_S: u16 = 0x53;
2266    pub(super) const VK_T: u16 = 0x54;
2267    pub(super) const VK_V: u16 = 0x56;
2268    pub(super) const VK_W: u16 = 0x57;
2269    pub(super) const VK_X: u16 = 0x58;
2270    pub(super) const VK_Y: u16 = 0x59;
2271    pub(super) const VK_Z: u16 = 0x5A;
2272    pub(super) const VK_TAB: u16 = 0x09;
2273    pub(super) const VK_LEFT: u16 = 0x25;
2274    pub(super) const VK_RIGHT: u16 = 0x27;
2275    pub(super) const VK_SHIFT: u16 = 0x10;
2276    pub(super) const VK_CONTROL: u16 = 0x11;
2277    pub(super) const VK_MENU: u16 = 0x12;
2278    pub(super) const VK_LWIN: u16 = 0x5B;
2279    pub(super) const VK_BROWSER_BACK: u16 = 0xA6;
2280    pub(super) const VK_BROWSER_FORWARD: u16 = 0xA7;
2281    pub(super) const VK_VOLUME_MUTE: u16 = 0xAD;
2282    pub(super) const VK_VOLUME_DOWN: u16 = 0xAE;
2283    pub(super) const VK_VOLUME_UP: u16 = 0xAF;
2284    pub(super) const VK_MEDIA_NEXT_TRACK: u16 = 0xB0;
2285    pub(super) const VK_MEDIA_PREV_TRACK: u16 = 0xB1;
2286    pub(super) const VK_MEDIA_PLAY_PAUSE: u16 = 0xB3;
2287
2288    #[derive(Clone, Copy)]
2289    pub(super) enum MouseButton {
2290        Left,
2291        Right,
2292        Middle,
2293        /// Extra button 4 ("back").
2294        Back,
2295        /// Extra button 5 ("forward").
2296        Forward,
2297    }
2298
2299    // XBUTTON1/XBUTTON2 from WinUser.h — windows-sys puts them behind the
2300    // Win32_UI_WindowsAndMessaging feature; not worth enabling for two
2301    // integers (same treatment as the VK_* codes above).
2302    const XBUTTON1: i32 = 1;
2303    const XBUTTON2: i32 = 2;
2304
2305    pub(super) fn post_click(button: MouseButton) {
2306        let (down, up, data) = match button {
2307            MouseButton::Left => (MOUSEEVENTF_LEFTDOWN, MOUSEEVENTF_LEFTUP, 0),
2308            MouseButton::Right => (MOUSEEVENTF_RIGHTDOWN, MOUSEEVENTF_RIGHTUP, 0),
2309            MouseButton::Middle => (MOUSEEVENTF_MIDDLEDOWN, MOUSEEVENTF_MIDDLEUP, 0),
2310            // Extra buttons share the X flag pair; mouseData carries which one.
2311            MouseButton::Back => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON1),
2312            MouseButton::Forward => (MOUSEEVENTF_XDOWN, MOUSEEVENTF_XUP, XBUTTON2),
2313        };
2314        send_inputs(&[mouse_input(down, data), mouse_input(up, data)]);
2315    }
2316
2317    pub(super) fn post_key(vk: u16, modifiers: &[u16]) {
2318        let mut inputs = Vec::with_capacity(modifiers.len() * 2 + 2);
2319        for modifier in modifiers {
2320            inputs.push(key_input(*modifier, false));
2321        }
2322        inputs.push(key_input(vk, false));
2323        inputs.push(key_input(vk, true));
2324        for modifier in modifiers.iter().rev() {
2325            inputs.push(key_input(*modifier, true));
2326        }
2327        send_inputs(&inputs);
2328    }
2329
2330    pub(super) fn post_scroll(action: &Action) {
2331        let (flags, data) = match action {
2332            Action::ScrollUp => (MOUSEEVENTF_WHEEL, WHEEL_DELTA),
2333            Action::ScrollDown => (MOUSEEVENTF_WHEEL, -WHEEL_DELTA),
2334            Action::HorizontalScrollLeft => (MOUSEEVENTF_HWHEEL, -WHEEL_DELTA),
2335            Action::HorizontalScrollRight => (MOUSEEVENTF_HWHEEL, WHEEL_DELTA),
2336            _ => return,
2337        };
2338        send_inputs(&[mouse_input(flags, data)]);
2339    }
2340
2341    pub(super) fn post_horizontal_scroll(delta: i32) {
2342        if delta == 0 {
2343            return;
2344        }
2345        send_inputs(&[mouse_input(
2346            MOUSEEVENTF_HWHEEL,
2347            delta.saturating_mul(WHEEL_DELTA),
2348        )]);
2349    }
2350
2351    pub(super) fn post_custom_shortcut(combo: &KeyCombo) {
2352        if combo.key_code == 0 {
2353            tracing::warn!(
2354                chord = %combo.rendered_label(),
2355                "CustomShortcut with no key code; press ignored"
2356            );
2357            return;
2358        }
2359        let Some(vk) = super::mac_virtual_key_to_windows(combo.key_code) else {
2360            tracing::warn!(
2361                key_code = combo.key_code,
2362                chord = %combo.rendered_label(),
2363                "CustomShortcut key has no Windows mapping yet; press ignored"
2364            );
2365            return;
2366        };
2367
2368        let mut modifiers = Vec::new();
2369        if combo.modifiers & KeyCombo::MOD_CMD != 0 {
2370            modifiers.push(VK_CONTROL);
2371        }
2372        if combo.modifiers & KeyCombo::MOD_SHIFT != 0 {
2373            modifiers.push(VK_SHIFT);
2374        }
2375        if combo.modifiers & KeyCombo::MOD_CTRL != 0 && !modifiers.contains(&VK_CONTROL) {
2376            modifiers.push(VK_CONTROL);
2377        }
2378        if combo.modifiers & KeyCombo::MOD_OPTION != 0 {
2379            modifiers.push(VK_MENU);
2380        }
2381        post_key(vk, &modifiers);
2382    }
2383
2384    fn send_inputs(inputs: &[INPUT]) {
2385        let Ok(input_count) = u32::try_from(inputs.len()) else {
2386            tracing::warn!(
2387                requested = inputs.len(),
2388                "too many SendInput events requested"
2389            );
2390            return;
2391        };
2392        let Ok(input_size) = i32::try_from(size_of::<INPUT>()) else {
2393            tracing::warn!("INPUT size does not fit the Win32 SendInput contract");
2394            return;
2395        };
2396        let sent = unsafe { SendInput(input_count, inputs.as_ptr(), input_size) };
2397        if sent != input_count {
2398            tracing::warn!(
2399                requested = inputs.len(),
2400                sent,
2401                "SendInput accepted fewer events than requested"
2402            );
2403        }
2404    }
2405
2406    fn key_input(vk: u16, key_up: bool) -> INPUT {
2407        let mut flags = 0;
2408        if key_up {
2409            flags |= KEYEVENTF_KEYUP;
2410        }
2411        INPUT {
2412            r#type: INPUT_KEYBOARD,
2413            Anonymous: INPUT_0 {
2414                ki: KEYBDINPUT {
2415                    wVk: vk,
2416                    wScan: 0,
2417                    dwFlags: flags,
2418                    time: 0,
2419                    dwExtraInfo: 0,
2420                },
2421            },
2422        }
2423    }
2424
2425    fn mouse_input(flags: u32, data: i32) -> INPUT {
2426        INPUT {
2427            r#type: INPUT_MOUSE,
2428            Anonymous: INPUT_0 {
2429                mi: MOUSEINPUT {
2430                    dx: 0,
2431                    dy: 0,
2432                    mouseData: u32::from_ne_bytes(data.to_ne_bytes()),
2433                    dwFlags: flags,
2434                    time: 0,
2435                    dwExtraInfo: 0,
2436                },
2437            },
2438        }
2439    }
2440}
2441
2442#[cfg(test)]
2443#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
2444mod tests {
2445    use std::collections::BTreeMap;
2446
2447    use serde::{Deserialize, Serialize};
2448
2449    use super::*;
2450
2451    // ── Roundtrip wrapper: defined here so it precedes any `let` statements ──
2452
2453    /// Minimal TOML-serializable wrapper used by `roundtrip`.
2454    /// Defined at module scope to satisfy `clippy::items_after_statements`.
2455    #[derive(Serialize, Deserialize)]
2456    struct RoundtripWrapper {
2457        binding: BTreeMap<ButtonId, Action>,
2458    }
2459
2460    // ── Catalog tests ─────────────────────────────────────────────────────────
2461
2462    #[test]
2463    fn catalog_has_at_least_29_entries() {
2464        let catalog = Action::catalog();
2465        assert!(
2466            catalog.len() >= 29,
2467            "catalog has {} entries, need ≥ 29",
2468            catalog.len()
2469        );
2470    }
2471
2472    #[test]
2473    fn catalog_excludes_custom_shortcut() {
2474        let catalog = Action::catalog();
2475        for action in &catalog {
2476            assert!(
2477                !matches!(action, Action::CustomShortcut(_)),
2478                "catalog must not contain CustomShortcut"
2479            );
2480        }
2481    }
2482
2483    #[test]
2484    fn custom_shortcut_keycodes_map_across_categories() {
2485        // One representative per category, checked against independently-known
2486        // (kVK → Win32 VK) facts, so a systematic error (swapped digits,
2487        // off-by-one F-keys, a wrong OEM code) is caught without restating the
2488        // whole table.
2489        assert_eq!(mac_virtual_key_to_windows(0x00), Some(0x41)); // A → VK_A
2490        assert_eq!(mac_virtual_key_to_windows(0x12), Some(0x31)); // 1 → VK_1
2491        assert_eq!(mac_virtual_key_to_windows(0x7A), Some(0x70)); // F1 → VK_F1
2492        assert_eq!(mac_virtual_key_to_windows(0x7B), Some(0x25)); // LeftArrow → VK_LEFT
2493        assert_eq!(mac_virtual_key_to_windows(0x31), Some(0x20)); // Space → VK_SPACE
2494        assert_eq!(mac_virtual_key_to_windows(0x29), Some(0xBA)); // ; → VK_OEM_1
2495        assert_eq!(mac_virtual_key_to_windows(0x37), None); // Command is a modifier, not a key
2496    }
2497
2498    // ── Binding (merged model) serde routing ──────────────────────────────────
2499
2500    /// On-disk shape: a `ButtonId` → [`Binding`] map, as `DeviceConfig.bindings`
2501    /// serializes it.
2502    #[derive(Serialize, Deserialize)]
2503    struct BindingWrapper {
2504        bindings: BTreeMap<ButtonId, Binding>,
2505    }
2506
2507    fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
2508        let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
2509        toml::from_str::<BindingWrapper>(&toml)
2510            .expect("deserialize")
2511            .bindings
2512    }
2513
2514    #[test]
2515    fn binding_single_roundtrips_including_payload_variants() {
2516        let mut bindings = BTreeMap::new();
2517        bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
2518        bindings.insert(
2519            ButtonId::DpiToggle,
2520            Binding::Single(Action::SetDpiPreset(2)),
2521        );
2522        bindings.insert(
2523            ButtonId::Forward,
2524            Binding::Single(Action::CustomShortcut(KeyCombo {
2525                modifiers: KeyCombo::MOD_CMD,
2526                key_code: 0x23,
2527                display: "⌘P".into(),
2528            })),
2529        );
2530        let back = binding_roundtrip(bindings);
2531        assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
2532        assert_eq!(
2533            back[&ButtonId::DpiToggle],
2534            Binding::Single(Action::SetDpiPreset(2))
2535        );
2536        assert!(matches!(
2537            back[&ButtonId::Forward],
2538            Binding::Single(Action::CustomShortcut(_))
2539        ));
2540    }
2541
2542    #[test]
2543    fn binding_gesture_roundtrips() {
2544        let mut map = BTreeMap::new();
2545        map.insert(GestureDirection::Up, Action::Copy);
2546        map.insert(GestureDirection::Click, Action::Paste);
2547        let mut bindings = BTreeMap::new();
2548        bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
2549        let back = binding_roundtrip(bindings);
2550        assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
2551    }
2552
2553    /// The untagged-routing safety guard. A TOML table keyed by ANY
2554    /// [`GestureDirection`] name must deserialize as [`Binding::Gesture`], never
2555    /// [`Binding::Single`]. If a future [`Action`] payload variant is ever named
2556    /// `Up`/`Down`/`Left`/`Right`/`Click`, the table would parse as `Single`
2557    /// first and this test fails — catching the silent mis-route at CI time.
2558    #[test]
2559    fn binding_direction_keyed_table_routes_to_gesture() {
2560        for dir in GestureDirection::ALL {
2561            // `GestureDirection`'s serde key equals its `Display`/variant name.
2562            let toml = format!("bindings.GestureButton.{dir} = \"None\"");
2563            let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
2564            assert!(
2565                matches!(
2566                    parsed.bindings[&ButtonId::GestureButton],
2567                    Binding::Gesture(_)
2568                ),
2569                "a {dir}-keyed table must route to Gesture, not Single"
2570            );
2571        }
2572    }
2573
2574    /// The collision case: a payload [`Action`] also serializes as a single-key
2575    /// table, but untagged must keep it [`Binding::Single`] (it parses as a valid
2576    /// externally-tagged `Action` before the `Gesture` arm is tried).
2577    #[test]
2578    fn binding_payload_action_stays_single() {
2579        let toml = "bindings.DpiToggle.SetDpiPreset = 2";
2580        let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
2581        assert_eq!(
2582            parsed.bindings[&ButtonId::DpiToggle],
2583            Binding::Single(Action::SetDpiPreset(2))
2584        );
2585    }
2586
2587    // ── Gesture classification ────────────────────────────────────────────────
2588
2589    #[test]
2590    fn detect_swipe_below_threshold_keeps_accumulating() {
2591        // Too little travel to commit — caller keeps summing raw-XY.
2592        assert_eq!(detect_swipe(40, 5), None);
2593        assert_eq!(detect_swipe(0, 0), None);
2594    }
2595
2596    #[test]
2597    fn detect_swipe_commits_clean_direction() {
2598        assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
2599        assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
2600        assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
2601        assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
2602    }
2603
2604    #[test]
2605    fn detect_swipe_rejects_diagonal() {
2606        // Past the threshold but too diagonal (cross axis beyond the band).
2607        assert_eq!(detect_swipe(60, 60), None);
2608        assert_eq!(detect_swipe(-60, -60), None);
2609    }
2610
2611    #[test]
2612    fn detect_swipe_threshold_and_cross_band_boundaries() {
2613        // The threshold bound is inclusive (`< THRESHOLD` rejects), so exactly at
2614        // it commits and one below does not.
2615        assert_eq!(
2616            detect_swipe(GESTURE_SWIPE_THRESHOLD, 0),
2617            Some(GestureDirection::Right)
2618        );
2619        assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None);
2620
2621        // The cross-axis band is max(deadzone, 35% of dominant). For a large
2622        // dominant the 35% term wins (200 → 70): 69 commits, 71 is too diagonal.
2623        assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right));
2624        assert_eq!(detect_swipe(200, 71), None);
2625        // For a small dominant the 40-unit floor wins (100 → max(40, 35) = 40).
2626        assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right));
2627        assert_eq!(detect_swipe(100, 41), None);
2628    }
2629
2630    #[test]
2631    fn detect_swipe_does_not_panic_on_extreme_values() {
2632        // Saturated accumulator travel can reach the i32 bounds. `i32::MIN.abs()`
2633        // panics and `dominant * 35` overflows — both must be clamped, not crash.
2634        assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right));
2635        assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left));
2636        assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down));
2637        assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up));
2638        // A diagonal at the extremes is still rejected, without panicking.
2639        assert_eq!(detect_swipe(i32::MIN, i32::MIN), None);
2640    }
2641
2642    // ── SwipeAccumulator (the shared mid-swipe state machine) ─────────────────
2643
2644    #[test]
2645    fn accumulator_commits_a_direction_once_after_the_hold_gate() {
2646        let mut acc = SwipeAccumulator::default();
2647        acc.begin();
2648        acc.backdate_hold_for_test();
2649        // A clear rightward swipe commits exactly once, mid-motion.
2650        assert_eq!(
2651            acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2652            Some(GestureDirection::Right)
2653        );
2654        // Further travel in the same hold must not re-fire.
2655        assert_eq!(acc.accumulate(50, 0), None);
2656    }
2657
2658    #[test]
2659    fn accumulator_does_not_commit_before_the_hold_gate() {
2660        let mut acc = SwipeAccumulator::default();
2661        acc.begin(); // held_since = now, so the gate is not yet satisfied
2662        // A big delta arriving immediately (a quick click whose cursor drifted)
2663        // must not commit.
2664        assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2665        // Once held long enough, the next delta commits.
2666        acc.backdate_hold_for_test();
2667        assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some());
2668    }
2669
2670    #[test]
2671    fn accumulator_end_reports_click_only_when_no_swipe_fired() {
2672        // A hold with only tiny drift never commits → end() is a click.
2673        let mut acc = SwipeAccumulator::default();
2674        acc.begin();
2675        acc.backdate_hold_for_test();
2676        assert_eq!(acc.accumulate(2, -1), None);
2677        assert!(acc.end(), "a hold that never swiped is a click");
2678
2679        // A hold that committed a swipe → end() is not a click.
2680        acc.begin();
2681        acc.backdate_hold_for_test();
2682        assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some());
2683        assert!(!acc.end(), "a committed swipe must not also click");
2684    }
2685
2686    #[test]
2687    fn accumulator_ignores_motion_when_not_holding() {
2688        let mut acc = SwipeAccumulator::default();
2689        assert!(!acc.is_holding());
2690        // Travel outside a hold is dropped, never committing a stray swipe.
2691        assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
2692    }
2693
2694    #[test]
2695    fn accumulator_sums_sub_threshold_deltas_until_they_commit() {
2696        // The whole reason for an accumulator (vs. detect_swipe on one delta):
2697        // several deltas each too small to commit on their own must sum across
2698        // the hold until the running total crosses the threshold, then commit.
2699        let mut acc = SwipeAccumulator::default();
2700        acc.begin();
2701        acc.backdate_hold_for_test();
2702        // Just under half the threshold: one or two steps never reach it, three do.
2703        let step = GESTURE_SWIPE_THRESHOLD / 2 - 1;
2704        assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold");
2705        assert_eq!(acc.accumulate(step, 0), None, "two steps still under");
2706        assert_eq!(
2707            acc.accumulate(step, 0),
2708            Some(GestureDirection::Right),
2709            "the running sum finally crosses the threshold"
2710        );
2711    }
2712
2713    #[test]
2714    fn accumulator_saturates_instead_of_overflowing() {
2715        // The doc promises an arbitrarily long hold can't overflow. A perfect
2716        // diagonal never commits, so travel keeps summing; feed deltas that would
2717        // overflow both an i32 sum and a naive cross-band multiply — both must
2718        // saturate, not panic (debug builds panic on overflow).
2719        let mut acc = SwipeAccumulator::default();
2720        acc.begin();
2721        acc.backdate_hold_for_test();
2722        assert_eq!(
2723            acc.accumulate(i32::MAX, i32::MAX),
2724            None,
2725            "a diagonal never commits"
2726        );
2727        assert_eq!(
2728            acc.accumulate(i32::MAX, i32::MAX),
2729            None,
2730            "the saturating sum must not panic"
2731        );
2732        // A clean axis on a fresh hold still commits with a saturated magnitude.
2733        acc.begin();
2734        acc.backdate_hold_for_test();
2735        assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right));
2736    }
2737
2738    #[test]
2739    fn accumulator_begin_recovers_a_stale_hold() {
2740        // A missed release (e.g. focus loss between press and release) can leave
2741        // a dangling hold that already fired with travel in some direction. A
2742        // fresh begin() must wipe both the `fired` latch and the travel, so the
2743        // next press isn't poisoned by the old one.
2744        let mut acc = SwipeAccumulator::default();
2745        acc.begin();
2746        acc.backdate_hold_for_test();
2747        // Stale hold commits LEFT (negative dx) and latches `fired`.
2748        assert_eq!(
2749            acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0),
2750            Some(GestureDirection::Left)
2751        );
2752        // No end() — a dropped release, then a fresh press.
2753        acc.begin();
2754        acc.backdate_hold_for_test();
2755        // Had `fired` leaked this would be None; had the negative travel leaked it
2756        // would commit Left. Committing Right proves begin() reset both.
2757        assert_eq!(
2758            acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
2759            Some(GestureDirection::Right)
2760        );
2761    }
2762
2763    #[test]
2764    fn accumulator_end_without_a_hold_is_not_a_click() {
2765        // end() in isolation (no begin) must not claim a click — there was no
2766        // hold — so a stray release can't be read as a press.
2767        let mut acc = SwipeAccumulator::default();
2768        assert!(!acc.end(), "a release with no hold is not a click");
2769        // A redundant second release after a real hold already ended is inert too.
2770        acc.begin();
2771        assert!(acc.end(), "the held release is a click");
2772        assert!(!acc.end(), "the redundant second release is not a click");
2773    }
2774
2775    // ── TOML roundtrip ────────────────────────────────────────────────────────
2776
2777    /// Serialize then deserialize `action` through TOML, using a wrapper
2778    /// struct because TOML requires a top-level table.
2779    fn roundtrip(action: &Action) -> Action {
2780        let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
2781        map.insert(ButtonId::Back, action.clone());
2782        let w = RoundtripWrapper { binding: map };
2783        let s = toml::to_string(&w).expect("serialize");
2784        let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
2785        back.binding
2786            .into_values()
2787            .next()
2788            .expect("binding present after roundtrip")
2789    }
2790
2791    #[test]
2792    fn all_catalog_variants_roundtrip_toml() {
2793        for action in Action::catalog() {
2794            let back = roundtrip(&action);
2795            assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
2796        }
2797    }
2798
2799    #[test]
2800    fn custom_shortcut_roundtrips_toml() {
2801        let action = Action::CustomShortcut(KeyCombo {
2802            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2803            key_code: 0x23, // kVK_ANSI_P
2804            display: "⌘⇧P".into(),
2805        });
2806        assert_eq!(roundtrip(&action), action);
2807    }
2808
2809    #[test]
2810    fn key_combo_rendered_label_uses_display_when_set() {
2811        let combo = KeyCombo {
2812            modifiers: 0,
2813            key_code: 0,
2814            display: "preset".into(),
2815        };
2816        assert_eq!(combo.rendered_label(), "preset");
2817    }
2818
2819    #[test]
2820    fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
2821        let combo = KeyCombo {
2822            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
2823            key_code: 0x23, // P
2824            display: String::new(),
2825        };
2826        assert_eq!(combo.rendered_label(), "⇧⌘P");
2827    }
2828
2829    // ── Category tests ────────────────────────────────────────────────────────
2830
2831    #[test]
2832    fn category_editing_variants() {
2833        assert_eq!(Action::Copy.category(), Category::Editing);
2834        assert_eq!(Action::Undo.category(), Category::Editing);
2835        assert_eq!(Action::SelectAll.category(), Category::Editing);
2836        assert_eq!(Action::Find.category(), Category::Editing);
2837        assert_eq!(Action::Save.category(), Category::Editing);
2838        assert_eq!(Action::Cut.category(), Category::Editing);
2839        assert_eq!(Action::Redo.category(), Category::Editing);
2840        assert_eq!(Action::Paste.category(), Category::Editing);
2841    }
2842
2843    #[test]
2844    fn category_browser_variants() {
2845        assert_eq!(Action::BrowserBack.category(), Category::Browser);
2846        assert_eq!(Action::BrowserForward.category(), Category::Browser);
2847        assert_eq!(Action::NewTab.category(), Category::Browser);
2848        assert_eq!(Action::CloseTab.category(), Category::Browser);
2849        assert_eq!(Action::ReopenTab.category(), Category::Browser);
2850        assert_eq!(Action::NextTab.category(), Category::Browser);
2851        assert_eq!(Action::PrevTab.category(), Category::Browser);
2852        assert_eq!(Action::ReloadPage.category(), Category::Browser);
2853    }
2854
2855    #[test]
2856    fn category_media_variants() {
2857        assert_eq!(Action::PlayPause.category(), Category::Media);
2858        assert_eq!(Action::NextTrack.category(), Category::Media);
2859        assert_eq!(Action::PrevTrack.category(), Category::Media);
2860        assert_eq!(Action::VolumeUp.category(), Category::Media);
2861        assert_eq!(Action::VolumeDown.category(), Category::Media);
2862        assert_eq!(Action::MuteVolume.category(), Category::Media);
2863    }
2864
2865    #[test]
2866    fn category_mouse_variants() {
2867        assert_eq!(Action::LeftClick.category(), Category::Mouse);
2868        assert_eq!(Action::RightClick.category(), Category::Mouse);
2869        assert_eq!(Action::MiddleClick.category(), Category::Mouse);
2870    }
2871
2872    #[test]
2873    fn category_dpi_variants() {
2874        assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
2875        assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
2876    }
2877
2878    #[test]
2879    fn category_scroll_variants() {
2880        assert_eq!(Action::ScrollUp.category(), Category::Scroll);
2881        assert_eq!(Action::ScrollDown.category(), Category::Scroll);
2882        assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
2883        assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
2884    }
2885
2886    #[test]
2887    fn category_navigation_variants() {
2888        assert_eq!(Action::MissionControl.category(), Category::Navigation);
2889        assert_eq!(Action::AppExpose.category(), Category::Navigation);
2890        assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
2891        assert_eq!(Action::NextDesktop.category(), Category::Navigation);
2892        assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
2893        assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
2894    }
2895
2896    #[test]
2897    fn category_system_variants() {
2898        assert_eq!(Action::LockScreen.category(), Category::System);
2899        assert_eq!(Action::Screenshot.category(), Category::System);
2900    }
2901
2902    // ── Category label smoke test ─────────────────────────────────────────────
2903
2904    #[test]
2905    fn category_labels_are_nonempty() {
2906        let categories = [
2907            Category::Editing,
2908            Category::Browser,
2909            Category::Media,
2910            Category::Mouse,
2911            Category::Dpi,
2912            Category::Scroll,
2913            Category::Navigation,
2914            Category::System,
2915        ];
2916        for cat in categories {
2917            assert!(!cat.label().is_empty(), "label empty for {cat:?}");
2918        }
2919    }
2920
2921    // ── Default binding ───────────────────────────────────────────────────────
2922
2923    #[test]
2924    fn dpi_toggle_default_is_cycle_dpi_presets() {
2925        assert_eq!(
2926            default_binding(ButtonId::DpiToggle),
2927            Action::CycleDpiPresets
2928        );
2929    }
2930
2931    // ── modifiers_to_keycodes ─────────────────────────────────────────────────
2932
2933    #[cfg(target_os = "linux")]
2934    mod modifier_mapping {
2935        use evdev::KeyCode;
2936
2937        use crate::binding::{KeyCombo, linux::modifiers_to_keycodes};
2938
2939        #[test]
2940        fn mod_cmd_alone_maps_to_ctrl() {
2941            assert_eq!(
2942                modifiers_to_keycodes(KeyCombo::MOD_CMD),
2943                vec![KeyCode::KEY_LEFTCTRL]
2944            );
2945        }
2946
2947        #[test]
2948        fn mod_ctrl_alone_maps_to_ctrl() {
2949            assert_eq!(
2950                modifiers_to_keycodes(KeyCombo::MOD_CTRL),
2951                vec![KeyCode::KEY_LEFTCTRL]
2952            );
2953        }
2954
2955        #[test]
2956        fn mod_cmd_and_ctrl_together_produce_single_ctrl() {
2957            // Both bits set must not push KEY_LEFTCTRL twice.
2958            assert_eq!(
2959                modifiers_to_keycodes(KeyCombo::MOD_CMD | KeyCombo::MOD_CTRL),
2960                vec![KeyCode::KEY_LEFTCTRL]
2961            );
2962        }
2963
2964        #[test]
2965        fn all_modifiers_produce_canonical_order() {
2966            let mods = modifiers_to_keycodes(
2967                KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT | KeyCombo::MOD_OPTION,
2968            );
2969            assert_eq!(
2970                mods,
2971                vec![
2972                    KeyCode::KEY_LEFTCTRL,
2973                    KeyCode::KEY_LEFTSHIFT,
2974                    KeyCode::KEY_LEFTALT
2975                ]
2976            );
2977        }
2978
2979        #[test]
2980        fn no_modifiers_produces_empty_vec() {
2981            assert!(modifiers_to_keycodes(0).is_empty());
2982        }
2983    }
2984
2985    // ── macos_vk_to_linux ────────────────────────────────────────────────────
2986
2987    #[cfg(target_os = "linux")]
2988    mod vk_mapping {
2989        use evdev::KeyCode;
2990
2991        use crate::binding::linux::macos_vk_to_linux;
2992
2993        #[test]
2994        fn common_letters_map_correctly() {
2995            assert_eq!(macos_vk_to_linux(0x08), Some(KeyCode::KEY_C)); // kVK_ANSI_C
2996            assert_eq!(macos_vk_to_linux(0x09), Some(KeyCode::KEY_V)); // kVK_ANSI_V
2997            assert_eq!(macos_vk_to_linux(0x07), Some(KeyCode::KEY_X)); // kVK_ANSI_X
2998            assert_eq!(macos_vk_to_linux(0x00), Some(KeyCode::KEY_A)); // kVK_ANSI_A
2999            assert_eq!(macos_vk_to_linux(0x06), Some(KeyCode::KEY_Z)); // kVK_ANSI_Z
3000            assert_eq!(macos_vk_to_linux(0x0D), Some(KeyCode::KEY_W)); // kVK_ANSI_W
3001        }
3002
3003        #[test]
3004        fn digits_map_correctly() {
3005            assert_eq!(macos_vk_to_linux(0x12), Some(KeyCode::KEY_1)); // kVK_ANSI_1
3006            assert_eq!(macos_vk_to_linux(0x1D), Some(KeyCode::KEY_0)); // kVK_ANSI_0
3007        }
3008
3009        #[test]
3010        fn arrow_keys_map_correctly() {
3011            assert_eq!(macos_vk_to_linux(0x7B), Some(KeyCode::KEY_LEFT));
3012            assert_eq!(macos_vk_to_linux(0x7C), Some(KeyCode::KEY_RIGHT));
3013            assert_eq!(macos_vk_to_linux(0x7D), Some(KeyCode::KEY_DOWN));
3014            assert_eq!(macos_vk_to_linux(0x7E), Some(KeyCode::KEY_UP));
3015        }
3016
3017        #[test]
3018        fn function_keys_map_correctly() {
3019            assert_eq!(macos_vk_to_linux(0x7A), Some(KeyCode::KEY_F1)); // kVK_F1
3020            assert_eq!(macos_vk_to_linux(0x78), Some(KeyCode::KEY_F2)); // kVK_F2
3021            assert_eq!(macos_vk_to_linux(0x76), Some(KeyCode::KEY_F4)); // kVK_F4
3022            assert_eq!(macos_vk_to_linux(0x60), Some(KeyCode::KEY_F5)); // kVK_F5
3023            assert_eq!(macos_vk_to_linux(0x6F), Some(KeyCode::KEY_F12)); // kVK_F12
3024        }
3025
3026        #[test]
3027        fn nav_keys_map_correctly() {
3028            assert_eq!(macos_vk_to_linux(0x73), Some(KeyCode::KEY_HOME));
3029            assert_eq!(macos_vk_to_linux(0x77), Some(KeyCode::KEY_END));
3030            assert_eq!(macos_vk_to_linux(0x74), Some(KeyCode::KEY_PAGEUP));
3031            assert_eq!(macos_vk_to_linux(0x79), Some(KeyCode::KEY_PAGEDOWN));
3032            assert_eq!(macos_vk_to_linux(0x75), Some(KeyCode::KEY_DELETE));
3033        }
3034
3035        #[test]
3036        fn brackets_follow_ansi_layout() {
3037            // kVK_ANSI_LeftBracket=0x21 → KEY_LEFTBRACE, RightBracket=0x1E → KEY_RIGHTBRACE
3038            assert_eq!(macos_vk_to_linux(0x21), Some(KeyCode::KEY_LEFTBRACE));
3039            assert_eq!(macos_vk_to_linux(0x1E), Some(KeyCode::KEY_RIGHTBRACE));
3040        }
3041
3042        #[test]
3043        fn unmapped_code_returns_none() {
3044            assert_eq!(macos_vk_to_linux(0xFF), None);
3045            assert_eq!(macos_vk_to_linux(0x34), None); // gap in the kVK table
3046        }
3047    }
3048}