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 the agent-core `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/// This type is pure config data: OS-level event synthesis for each variant
348/// lives in the `openlogi-inject` crate (`openlogi_inject::execute`), keeping
349/// this crate platform- and IO-free.
350#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
351pub enum Action {
352    // ── System ───────────────────────────────────────────────────────────────
353    /// Suppress the input entirely — the button or wheel direction is captured
354    /// but no OS event is synthesised, so the physical input does nothing.
355    None,
356
357    // ── Mouse ────────────────────────────────────────────────────────────────
358    /// Primary mouse button.
359    LeftClick,
360    /// Secondary mouse button.
361    RightClick,
362    /// Middle mouse button (wheel click).
363    MiddleClick,
364    /// Mouse "back" side button (extra button 4). Synthesizes the real mouse
365    /// button event, which browsers and most apps interpret as "navigate back"
366    /// natively — unlike [`Action::BrowserBack`], which sends ⌘[ and is ignored
367    /// by many apps.
368    MouseBack,
369    /// Mouse "forward" side button (extra button 5). Native counterpart to
370    /// [`Action::MouseBack`]; see [`Action::BrowserForward`] for the ⌘] form.
371    MouseForward,
372
373    // ── Editing ──────────────────────────────────────────────────────────────
374    /// Copy the current selection (⌘C / Ctrl+C).
375    Copy,
376    /// Paste from the clipboard (⌘V / Ctrl+V).
377    Paste,
378    /// Cut the current selection (⌘X / Ctrl+X).
379    Cut,
380    /// Undo the last action (⌘Z / Ctrl+Z).
381    Undo,
382    /// Redo the last undone action (⌘⇧Z on macOS / Ctrl+Shift+Z on Linux).
383    ///
384    /// Note: Ctrl+Y is the dominant redo shortcut in LibreOffice and many GTK
385    /// apps. Ctrl+Shift+Z is used here because it mirrors the macOS convention
386    /// and works in GNOME text fields, browsers, and Electron apps. If Ctrl+Y
387    /// coverage is needed, a `CustomShortcut` binding is the escape hatch.
388    Redo,
389    /// Select all content (⌘A / Ctrl+A).
390    SelectAll,
391    /// Open the find / search bar (⌘F / Ctrl+F).
392    Find,
393    /// Save the current document (⌘S / Ctrl+S).
394    Save,
395
396    // ── Browser / Navigation ──────────────────────────────────────────────────
397    /// Navigate backward in browser history.
398    BrowserBack,
399    /// Navigate forward in browser history.
400    BrowserForward,
401    /// Open a new tab (⌘T / Ctrl+T).
402    NewTab,
403    /// Close the current tab (⌘W / Ctrl+W).
404    CloseTab,
405    /// Reopen the last closed tab (⌘⇧T / Ctrl+Shift+T).
406    ReopenTab,
407    /// Switch to the next tab (⌃⇥ / Ctrl+Tab).
408    NextTab,
409    /// Switch to the previous tab (⌃⇧⇥ / Ctrl+Shift+Tab).
410    PrevTab,
411    /// Reload the current page (⌘R / Ctrl+R).
412    ReloadPage,
413
414    // ── Navigation / Window ───────────────────────────────────────────────────
415    /// macOS Mission Control (⌃↑).
416    MissionControl,
417    /// macOS App Exposé — all windows for the current app (⌃↓).
418    AppExpose,
419    /// Switch to the previous desktop / Space.
420    PreviousDesktop,
421    /// Switch to the next desktop / Space.
422    NextDesktop,
423    /// Show the desktop (hide all windows).
424    ShowDesktop,
425    /// Open Launchpad.
426    LaunchpadShow,
427
428    // ── System ────────────────────────────────────────────────────────────────
429    /// Lock the screen (⌘⌃Q on macOS).
430    ///
431    /// On Linux, calls `org.freedesktop.login1.Manager.LockSession($XDG_SESSION_ID)`
432    /// on the system bus (current session only). Falls back to Super+L when
433    /// `$XDG_SESSION_ID` is unset or on non-systemd systems.
434    LockScreen,
435    /// Capture a screenshot.
436    Screenshot,
437    /// Capture a selected screen region to the clipboard.
438    ///
439    /// macOS uses Cmd+Shift+Ctrl+4; Windows uses Win+Shift+S. Linux delegates
440    /// to the desktop environment's screenshot handler via Print Screen.
441    CaptureRegion,
442
443    // ── Media ────────────────────────────────────────────────────────────────
444    /// Toggle media play/pause.
445    PlayPause,
446    /// Skip to the next track.
447    NextTrack,
448    /// Go back to the previous track.
449    PrevTrack,
450    /// Increase system volume.
451    VolumeUp,
452    /// Decrease system volume.
453    VolumeDown,
454    /// Toggle system mute.
455    MuteVolume,
456
457    // ── DPI ──────────────────────────────────────────────────────────────────
458    /// Step through the configured DPI preset list (P1.7).
459    CycleDpiPresets,
460    /// Jump to a specific zero-based preset in the device's DPI preset list.
461    /// Out-of-range indices clamp to the list length at fire time (P1.7).
462    SetDpiPreset(u8),
463    /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
464    ToggleSmartShift,
465
466    // ── Scroll ───────────────────────────────────────────────────────────────
467    /// Synthesise a vertical scroll-up tick.
468    ScrollUp,
469    /// Synthesise a vertical scroll-down tick.
470    ScrollDown,
471    /// Synthesise a horizontal scroll-left tick.
472    HorizontalScrollLeft,
473    /// Synthesise a horizontal scroll-right tick.
474    HorizontalScrollRight,
475
476    // ── Custom ───────────────────────────────────────────────────────────────
477    /// Replay an arbitrary recorded key chord (P1.3).
478    ///
479    /// Holds the structured chord data so `openlogi_inject::execute` can post the
480    /// real keystroke (macOS: CGEventPost with the encoded modifier flags).
481    /// The `display` field is used by [`Action::label`] so the popover
482    /// shows the user-friendly chord name.
483    CustomShortcut(KeyCombo),
484}
485
486/// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or
487/// hand-authored in `config.toml`.
488///
489/// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format
490/// is a compact integer, not a string. `key_code` is the macOS virtual key
491/// (`kVK_*`); on Linux, `openlogi-inject` maps it to an evdev `KeyCode` when it
492/// synthesizes the chord.
493///
494/// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it
495/// from the captured chord; we keep it in the struct so older configs
496/// continue to render the same label without re-deriving on every load.
497#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
498pub struct KeyCombo {
499    /// Bitmask of [`Self::MOD_CMD`] etc.
500    pub modifiers: u8,
501    /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for
502    /// modifier-only placeholders that the recorder UI rejects. On Linux,
503    /// `openlogi-inject` translates this to an evdev `KeyCode`.
504    pub key_code: u16,
505    /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a
506    /// generated label at runtime.
507    #[serde(default)]
508    pub display: String,
509}
510
511impl KeyCombo {
512    pub const MOD_CMD: u8 = 1 << 0;
513    pub const MOD_SHIFT: u8 = 1 << 1;
514    pub const MOD_CTRL: u8 = 1 << 2;
515    pub const MOD_OPTION: u8 = 1 << 3;
516
517    /// Build the human-readable label from the modifier bitmask + key code.
518    /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the
519    /// commonly-recognised letters; the recorder UI usually overrides this
520    /// with its own derivation.
521    #[must_use]
522    pub fn rendered_label(&self) -> String {
523        if !self.display.is_empty() {
524            return self.display.clone();
525        }
526        let mut out = String::new();
527        if self.modifiers & Self::MOD_CTRL != 0 {
528            out.push('⌃');
529        }
530        if self.modifiers & Self::MOD_OPTION != 0 {
531            out.push('⌥');
532        }
533        if self.modifiers & Self::MOD_SHIFT != 0 {
534            out.push('⇧');
535        }
536        if self.modifiers & Self::MOD_CMD != 0 {
537            out.push('⌘');
538        }
539        match self.key_code {
540            0x00 => out.push('A'),
541            0x01 => out.push('S'),
542            0x02 => out.push('D'),
543            0x03 => out.push('F'),
544            0x06 => out.push('Z'),
545            0x07 => out.push('X'),
546            0x08 => out.push('C'),
547            0x09 => out.push('V'),
548            0x0B => out.push('B'),
549            0x0C => out.push('Q'),
550            0x0D => out.push('W'),
551            0x0E => out.push('E'),
552            0x0F => out.push('R'),
553            0x10 => out.push('Y'),
554            0x11 => out.push('T'),
555            0x20 => out.push('U'),
556            0x22 => out.push('I'),
557            0x1F => out.push('O'),
558            0x23 => out.push('P'),
559            _ => {
560                use std::fmt::Write as _;
561                let _ = write!(out, "key 0x{:02X}", self.key_code);
562            }
563        }
564        out
565    }
566}
567
568/// What a single rebindable [`ButtonId`] does: either one [`Action`], or — for a
569/// raw-XY-capable button placed in gesture mode — a per-[`GestureDirection`]
570/// map (hold + swipe up/down/left/right, or a plain click).
571///
572/// There has only ever been one binding map per device; a gesture binding is
573/// just a binding whose payload is a direction map instead of a single action.
574///
575/// # Serialization
576///
577/// `#[serde(untagged)]`: [`Single`](Binding::Single) serializes exactly as the
578/// bare [`Action`] did before (a string `"BrowserBack"`, or a single-key table
579/// for the payload variants), and [`Gesture`](Binding::Gesture) serializes as a
580/// table keyed by [`GestureDirection`] names (`Up`/`Down`/`Left`/`Right`/
581/// `Click`).
582///
583/// The two arms are disambiguated by the **zero overlap** between [`Action`]
584/// variant names and [`GestureDirection`] variant names — untagged tries
585/// `Single(Action)` first, and a table keyed by `Up` etc. cannot parse as an
586/// externally-tagged `Action`, so it falls through to `Gesture`. A payload
587/// action like `{ SetDpiPreset = 2 }` is a valid externally-tagged `Action`, so
588/// it stays `Single` and never reaches the `Gesture` arm. This invariant is the
589/// entire safety basis for untagged routing; the `binding_untagged_*` tests
590/// guard it (a future `Action` named `Up`/`Down`/`Left`/`Right`/`Click` would
591/// silently mis-route, and those tests would fail).
592#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
593#[serde(untagged)]
594pub enum Binding {
595    /// One action, fired on press. The shape every non-gesture button uses.
596    Single(Action),
597    /// Per-direction sub-bindings for a button in gesture mode. Keyed by the
598    /// committed swipe direction, with [`GestureDirection::Click`] holding the
599    /// plain-click (no-swipe) action.
600    Gesture(BTreeMap<GestureDirection, Action>),
601}
602
603impl Binding {
604    /// The plain-click action for this binding: the [`Single`](Binding::Single)
605    /// action, or the [`Gesture`](Binding::Gesture) map's
606    /// [`Click`](GestureDirection::Click) entry. Falls back to [`Action::None`]
607    /// when a gesture binding has no explicit `Click`.
608    ///
609    /// Lets the click-dispatch path stay binding-shape-agnostic.
610    #[must_use]
611    pub fn click_action(&self) -> Action {
612        match self {
613            Binding::Single(action) => action.clone(),
614            Binding::Gesture(map) => map
615                .get(&GestureDirection::Click)
616                .cloned()
617                .unwrap_or(Action::None),
618        }
619    }
620
621    /// The action bound to `direction`, if this is a gesture binding.
622    /// [`Single`](Binding::Single) has no directions and returns `None`.
623    #[must_use]
624    pub fn direction_action(&self, direction: GestureDirection) -> Option<&Action> {
625        match self {
626            Binding::Single(_) => None,
627            Binding::Gesture(map) => map.get(&direction),
628        }
629    }
630
631    /// Whether this binding drives raw-XY swipe capture (the
632    /// [`Gesture`](Binding::Gesture) arm).
633    #[must_use]
634    pub fn is_gesture(&self) -> bool {
635        matches!(self, Binding::Gesture(_))
636    }
637
638    /// Promote a [`Single`](Binding::Single) binding in place to a
639    /// [`Gesture`](Binding::Gesture), keeping its action as the
640    /// [`GestureDirection::Click`] entry and leaving the swipe arms unbound.
641    /// A no-op when this is already a [`Gesture`](Binding::Gesture).
642    pub fn upgrade_to_gesture(&mut self) {
643        if let Binding::Single(action) = self {
644            let mut map = BTreeMap::new();
645            map.insert(GestureDirection::Click, action.clone());
646            *self = Binding::Gesture(map);
647        }
648    }
649
650    /// Fill any unbound directions of a [`Gesture`](Binding::Gesture) binding
651    /// with their canonical [`default_gesture_binding`], so a button promoted to
652    /// the gesture role always exposes the full five-direction set — rather than
653    /// leaving swipe arms the GUI renders as defaults but the runtime never
654    /// dispatches. A no-op on [`Single`](Binding::Single) and on directions
655    /// already bound (existing user choices are preserved).
656    pub fn fill_gesture_defaults(&mut self) {
657        if let Binding::Gesture(map) = self {
658            for dir in GestureDirection::ALL {
659                map.entry(dir)
660                    .or_insert_with(|| default_gesture_binding(dir));
661            }
662        }
663    }
664}
665
666impl From<Action> for Binding {
667    fn from(action: Action) -> Self {
668        Binding::Single(action)
669    }
670}
671
672impl Action {
673    /// Display label for the popover row.
674    ///
675    /// Returns `String` rather than `&str` so parameterized variants (e.g.
676    /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
677    /// includes their payload.
678    #[must_use]
679    pub fn label(&self) -> String {
680        match self {
681            Action::None => "Do Nothing".into(),
682            Action::LeftClick => "Left Click".into(),
683            Action::RightClick => "Right Click".into(),
684            Action::MiddleClick => "Middle Click".into(),
685            Action::MouseBack => "Back (Button 4)".into(),
686            Action::MouseForward => "Forward (Button 5)".into(),
687            Action::Copy => "Copy".into(),
688            Action::Paste => "Paste".into(),
689            Action::Cut => "Cut".into(),
690            Action::Undo => "Undo".into(),
691            Action::Redo => "Redo".into(),
692            Action::SelectAll => "Select All".into(),
693            Action::Find => "Find".into(),
694            Action::Save => "Save".into(),
695            Action::BrowserBack => "Browser Back".into(),
696            Action::BrowserForward => "Browser Forward".into(),
697            Action::NewTab => "New Tab".into(),
698            Action::CloseTab => "Close Tab".into(),
699            Action::ReopenTab => "Reopen Tab".into(),
700            Action::NextTab => "Next Tab".into(),
701            Action::PrevTab => "Previous Tab".into(),
702            Action::ReloadPage => "Reload Page".into(),
703            Action::MissionControl => "Mission Control".into(),
704            Action::AppExpose => "App Exposé".into(),
705            Action::PreviousDesktop => "Previous Desktop".into(),
706            Action::NextDesktop => "Next Desktop".into(),
707            Action::ShowDesktop => "Show Desktop".into(),
708            Action::LaunchpadShow => "Launchpad".into(),
709            Action::LockScreen => "Lock Screen".into(),
710            Action::Screenshot => "Screenshot".into(),
711            Action::CaptureRegion => "Capture Region".into(),
712            Action::PlayPause => "Play / Pause".into(),
713            Action::NextTrack => "Next Track".into(),
714            Action::PrevTrack => "Previous Track".into(),
715            Action::VolumeUp => "Volume Up".into(),
716            Action::VolumeDown => "Volume Down".into(),
717            Action::MuteVolume => "Mute".into(),
718            Action::CycleDpiPresets => "Cycle DPI Presets".into(),
719            Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
720            Action::ToggleSmartShift => "Toggle SmartShift".into(),
721            Action::ScrollUp => "Scroll Up".into(),
722            Action::ScrollDown => "Scroll Down".into(),
723            Action::HorizontalScrollLeft => "Scroll Left".into(),
724            Action::HorizontalScrollRight => "Scroll Right".into(),
725            Action::CustomShortcut(combo) => combo.rendered_label(),
726        }
727    }
728
729    /// Which [`Category`] this action belongs to, used for popover grouping.
730    #[must_use]
731    pub fn category(&self) -> Category {
732        match self {
733            Action::LeftClick
734            | Action::RightClick
735            | Action::MiddleClick
736            | Action::MouseBack
737            | Action::MouseForward => Category::Mouse,
738            // CustomShortcut is assigned to Editing so it doesn't need a
739            // separate arm (it's not in the picker catalog).
740            Action::Copy
741            | Action::Paste
742            | Action::Cut
743            | Action::Undo
744            | Action::Redo
745            | Action::SelectAll
746            | Action::Find
747            | Action::Save
748            | Action::CustomShortcut(_) => Category::Editing,
749            Action::BrowserBack
750            | Action::BrowserForward
751            | Action::NewTab
752            | Action::CloseTab
753            | Action::ReopenTab
754            | Action::NextTab
755            | Action::PrevTab
756            | Action::ReloadPage => Category::Browser,
757            Action::MissionControl
758            | Action::AppExpose
759            | Action::PreviousDesktop
760            | Action::NextDesktop
761            | Action::ShowDesktop
762            | Action::LaunchpadShow => Category::Navigation,
763            Action::None | Action::LockScreen | Action::Screenshot | Action::CaptureRegion => {
764                Category::System
765            }
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            Action::CaptureRegion,
825            // Media
826            Action::PlayPause,
827            Action::NextTrack,
828            Action::PrevTrack,
829            Action::VolumeUp,
830            Action::VolumeDown,
831            Action::MuteVolume,
832            // DPI
833            Action::CycleDpiPresets,
834            Action::ToggleSmartShift,
835            // Scroll
836            Action::ScrollUp,
837            Action::ScrollDown,
838            Action::HorizontalScrollLeft,
839            Action::HorizontalScrollRight,
840        ]
841    }
842}
843
844/// Sensible defaults for a fresh device so the panel isn't empty on first run.
845///
846/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
847/// MX-line devices: thumb wheel click → App Exposé, gesture button →
848/// Mission Control. The thumb wheel isn't captured yet; the gesture button is
849/// (per-direction, see [`default_gesture_binding`]). The bindings persist
850/// regardless so the user only configures once.
851///
852/// `GestureButton`'s entry here is vestigial: in the merged [`Binding`] model
853/// the gesture button defaults to [`Binding::Gesture`] (see
854/// [`default_binding_for`]), so this single-action value is never the source of
855/// truth for it. It is retained only so the per-button-`Action` callers (the
856/// hook map, scroll defaults, labels) stay total.
857#[must_use]
858pub fn default_binding(button: ButtonId) -> Action {
859    match button {
860        ButtonId::LeftClick => Action::LeftClick,
861        ButtonId::RightClick => Action::RightClick,
862        ButtonId::MiddleClick => Action::MiddleClick,
863        ButtonId::Back => Action::BrowserBack,
864        ButtonId::Forward => Action::BrowserForward,
865        ButtonId::DpiToggle => Action::CycleDpiPresets,
866        ButtonId::Thumbwheel => Action::AppExpose,
867        // The thumb wheel scrolls horizontally by default: rotating it produces
868        // continuous horizontal scroll, with "up" → right and "down" → left.
869        // The wheel watcher renders these two actions as smooth, sensitivity-
870        // scaled scrolling rather than the discrete per-press burst a button
871        // would get (see `watchers::gesture`).
872        ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
873        ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
874        ButtonId::GestureButton => Action::MissionControl,
875    }
876}
877
878/// Per-direction defaults for the gesture button. These are captured live over
879/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
880/// defaults give the picker something sensible to show on first run.
881#[must_use]
882pub fn default_gesture_binding(direction: GestureDirection) -> Action {
883    match direction {
884        GestureDirection::Up => Action::MissionControl,
885        GestureDirection::Down => Action::ShowDesktop,
886        GestureDirection::Left => Action::PrevTab,
887        GestureDirection::Right => Action::NextTab,
888        GestureDirection::Click => Action::AppExpose,
889    }
890}
891
892/// The canonical default [`Binding`] for a fresh button in the merged model.
893///
894/// [`ButtonId::GestureButton`] defaults to [`Binding::Gesture`] populated from
895/// [`default_gesture_binding`] — preserving the existing per-direction swipe
896/// behavior — so the GUI mode toggle and the runtime agree it starts in gesture
897/// mode. Every other button defaults to [`Binding::Single`] of its
898/// [`default_binding`].
899///
900/// This is the seed when a button is first promoted to a gesture binding (see
901/// [`Config::set_gesture_direction`](crate::config::Config::set_gesture_direction)),
902/// so a freshly-customized gesture button always carries a full default
903/// direction map — including a [`GestureDirection::Click`] — rather than a sparse
904/// map whose click would project to a no-op [`Action::None`].
905#[must_use]
906pub fn default_binding_for(button: ButtonId) -> Binding {
907    match button {
908        ButtonId::GestureButton => Binding::Gesture(
909            GestureDirection::ALL
910                .into_iter()
911                .map(|d| (d, default_gesture_binding(d)))
912                .collect(),
913        ),
914        other => Binding::Single(default_binding(other)),
915    }
916}
917
918#[cfg(test)]
919#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
920mod tests {
921    use std::collections::BTreeMap;
922
923    use serde::{Deserialize, Serialize};
924
925    use super::*;
926
927    // ── Roundtrip wrapper: defined here so it precedes any `let` statements ──
928
929    /// Minimal TOML-serializable wrapper used by `roundtrip`.
930    /// Defined at module scope to satisfy `clippy::items_after_statements`.
931    #[derive(Serialize, Deserialize)]
932    struct RoundtripWrapper {
933        binding: BTreeMap<ButtonId, Action>,
934    }
935
936    // ── Catalog tests ─────────────────────────────────────────────────────────
937
938    #[test]
939    fn catalog_has_at_least_29_entries() {
940        let catalog = Action::catalog();
941        assert!(
942            catalog.len() >= 29,
943            "catalog has {} entries, need ≥ 29",
944            catalog.len()
945        );
946    }
947
948    #[test]
949    fn catalog_excludes_custom_shortcut() {
950        let catalog = Action::catalog();
951        for action in &catalog {
952            assert!(
953                !matches!(action, Action::CustomShortcut(_)),
954                "catalog must not contain CustomShortcut"
955            );
956        }
957    }
958
959    // ── Binding (merged model) serde routing ──────────────────────────────────
960
961    /// On-disk shape: a `ButtonId` → [`Binding`] map, as `DeviceConfig.bindings`
962    /// serializes it.
963    #[derive(Serialize, Deserialize)]
964    struct BindingWrapper {
965        bindings: BTreeMap<ButtonId, Binding>,
966    }
967
968    fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
969        let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
970        toml::from_str::<BindingWrapper>(&toml)
971            .expect("deserialize")
972            .bindings
973    }
974
975    #[test]
976    fn binding_single_roundtrips_including_payload_variants() {
977        let mut bindings = BTreeMap::new();
978        bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
979        bindings.insert(
980            ButtonId::DpiToggle,
981            Binding::Single(Action::SetDpiPreset(2)),
982        );
983        bindings.insert(
984            ButtonId::Forward,
985            Binding::Single(Action::CustomShortcut(KeyCombo {
986                modifiers: KeyCombo::MOD_CMD,
987                key_code: 0x23,
988                display: "⌘P".into(),
989            })),
990        );
991        let back = binding_roundtrip(bindings);
992        assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
993        assert_eq!(
994            back[&ButtonId::DpiToggle],
995            Binding::Single(Action::SetDpiPreset(2))
996        );
997        assert!(matches!(
998            back[&ButtonId::Forward],
999            Binding::Single(Action::CustomShortcut(_))
1000        ));
1001    }
1002
1003    #[test]
1004    fn binding_gesture_roundtrips() {
1005        let mut map = BTreeMap::new();
1006        map.insert(GestureDirection::Up, Action::Copy);
1007        map.insert(GestureDirection::Click, Action::Paste);
1008        let mut bindings = BTreeMap::new();
1009        bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
1010        let back = binding_roundtrip(bindings);
1011        assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
1012    }
1013
1014    /// The untagged-routing safety guard. A TOML table keyed by ANY
1015    /// [`GestureDirection`] name must deserialize as [`Binding::Gesture`], never
1016    /// [`Binding::Single`]. If a future [`Action`] payload variant is ever named
1017    /// `Up`/`Down`/`Left`/`Right`/`Click`, the table would parse as `Single`
1018    /// first and this test fails — catching the silent mis-route at CI time.
1019    #[test]
1020    fn binding_direction_keyed_table_routes_to_gesture() {
1021        for dir in GestureDirection::ALL {
1022            // `GestureDirection`'s serde key equals its `Display`/variant name.
1023            let toml = format!("bindings.GestureButton.{dir} = \"None\"");
1024            let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
1025            assert!(
1026                matches!(
1027                    parsed.bindings[&ButtonId::GestureButton],
1028                    Binding::Gesture(_)
1029                ),
1030                "a {dir}-keyed table must route to Gesture, not Single"
1031            );
1032        }
1033    }
1034
1035    /// The collision case: a payload [`Action`] also serializes as a single-key
1036    /// table, but untagged must keep it [`Binding::Single`] (it parses as a valid
1037    /// externally-tagged `Action` before the `Gesture` arm is tried).
1038    #[test]
1039    fn binding_payload_action_stays_single() {
1040        let toml = "bindings.DpiToggle.SetDpiPreset = 2";
1041        let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
1042        assert_eq!(
1043            parsed.bindings[&ButtonId::DpiToggle],
1044            Binding::Single(Action::SetDpiPreset(2))
1045        );
1046    }
1047
1048    #[test]
1049    fn binding_capture_region_roundtrips_as_single_string() {
1050        let toml = "bindings.Back = \"CaptureRegion\"";
1051        let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
1052        assert_eq!(
1053            parsed.bindings[&ButtonId::Back],
1054            Binding::Single(Action::CaptureRegion)
1055        );
1056
1057        let back = binding_roundtrip(parsed.bindings);
1058        assert_eq!(
1059            back[&ButtonId::Back],
1060            Binding::Single(Action::CaptureRegion)
1061        );
1062        assert_eq!(Action::CaptureRegion.label(), "Capture Region");
1063        assert_eq!(Action::CaptureRegion.category(), Category::System);
1064        assert!(Action::catalog().contains(&Action::CaptureRegion));
1065    }
1066
1067    // ── Gesture classification ────────────────────────────────────────────────
1068
1069    #[test]
1070    fn detect_swipe_below_threshold_keeps_accumulating() {
1071        // Too little travel to commit — caller keeps summing raw-XY.
1072        assert_eq!(detect_swipe(40, 5), None);
1073        assert_eq!(detect_swipe(0, 0), None);
1074    }
1075
1076    #[test]
1077    fn detect_swipe_commits_clean_direction() {
1078        assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
1079        assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
1080        assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
1081        assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
1082    }
1083
1084    #[test]
1085    fn detect_swipe_rejects_diagonal() {
1086        // Past the threshold but too diagonal (cross axis beyond the band).
1087        assert_eq!(detect_swipe(60, 60), None);
1088        assert_eq!(detect_swipe(-60, -60), None);
1089    }
1090
1091    #[test]
1092    fn detect_swipe_threshold_and_cross_band_boundaries() {
1093        // The threshold bound is inclusive (`< THRESHOLD` rejects), so exactly at
1094        // it commits and one below does not.
1095        assert_eq!(
1096            detect_swipe(GESTURE_SWIPE_THRESHOLD, 0),
1097            Some(GestureDirection::Right)
1098        );
1099        assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None);
1100
1101        // The cross-axis band is max(deadzone, 35% of dominant). For a large
1102        // dominant the 35% term wins (200 → 70): 69 commits, 71 is too diagonal.
1103        assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right));
1104        assert_eq!(detect_swipe(200, 71), None);
1105        // For a small dominant the 40-unit floor wins (100 → max(40, 35) = 40).
1106        assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right));
1107        assert_eq!(detect_swipe(100, 41), None);
1108    }
1109
1110    #[test]
1111    fn detect_swipe_does_not_panic_on_extreme_values() {
1112        // Saturated accumulator travel can reach the i32 bounds. `i32::MIN.abs()`
1113        // panics and `dominant * 35` overflows — both must be clamped, not crash.
1114        assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right));
1115        assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left));
1116        assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down));
1117        assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up));
1118        // A diagonal at the extremes is still rejected, without panicking.
1119        assert_eq!(detect_swipe(i32::MIN, i32::MIN), None);
1120    }
1121
1122    // ── SwipeAccumulator (the shared mid-swipe state machine) ─────────────────
1123
1124    #[test]
1125    fn accumulator_commits_a_direction_once_after_the_hold_gate() {
1126        let mut acc = SwipeAccumulator::default();
1127        acc.begin();
1128        acc.backdate_hold_for_test();
1129        // A clear rightward swipe commits exactly once, mid-motion.
1130        assert_eq!(
1131            acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
1132            Some(GestureDirection::Right)
1133        );
1134        // Further travel in the same hold must not re-fire.
1135        assert_eq!(acc.accumulate(50, 0), None);
1136    }
1137
1138    #[test]
1139    fn accumulator_does_not_commit_before_the_hold_gate() {
1140        let mut acc = SwipeAccumulator::default();
1141        acc.begin(); // held_since = now, so the gate is not yet satisfied
1142        // A big delta arriving immediately (a quick click whose cursor drifted)
1143        // must not commit.
1144        assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
1145        // Once held long enough, the next delta commits.
1146        acc.backdate_hold_for_test();
1147        assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some());
1148    }
1149
1150    #[test]
1151    fn accumulator_end_reports_click_only_when_no_swipe_fired() {
1152        // A hold with only tiny drift never commits → end() is a click.
1153        let mut acc = SwipeAccumulator::default();
1154        acc.begin();
1155        acc.backdate_hold_for_test();
1156        assert_eq!(acc.accumulate(2, -1), None);
1157        assert!(acc.end(), "a hold that never swiped is a click");
1158
1159        // A hold that committed a swipe → end() is not a click.
1160        acc.begin();
1161        acc.backdate_hold_for_test();
1162        assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some());
1163        assert!(!acc.end(), "a committed swipe must not also click");
1164    }
1165
1166    #[test]
1167    fn accumulator_ignores_motion_when_not_holding() {
1168        let mut acc = SwipeAccumulator::default();
1169        assert!(!acc.is_holding());
1170        // Travel outside a hold is dropped, never committing a stray swipe.
1171        assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
1172    }
1173
1174    #[test]
1175    fn accumulator_sums_sub_threshold_deltas_until_they_commit() {
1176        // The whole reason for an accumulator (vs. detect_swipe on one delta):
1177        // several deltas each too small to commit on their own must sum across
1178        // the hold until the running total crosses the threshold, then commit.
1179        let mut acc = SwipeAccumulator::default();
1180        acc.begin();
1181        acc.backdate_hold_for_test();
1182        // Just under half the threshold: one or two steps never reach it, three do.
1183        let step = GESTURE_SWIPE_THRESHOLD / 2 - 1;
1184        assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold");
1185        assert_eq!(acc.accumulate(step, 0), None, "two steps still under");
1186        assert_eq!(
1187            acc.accumulate(step, 0),
1188            Some(GestureDirection::Right),
1189            "the running sum finally crosses the threshold"
1190        );
1191    }
1192
1193    #[test]
1194    fn accumulator_saturates_instead_of_overflowing() {
1195        // The doc promises an arbitrarily long hold can't overflow. A perfect
1196        // diagonal never commits, so travel keeps summing; feed deltas that would
1197        // overflow both an i32 sum and a naive cross-band multiply — both must
1198        // saturate, not panic (debug builds panic on overflow).
1199        let mut acc = SwipeAccumulator::default();
1200        acc.begin();
1201        acc.backdate_hold_for_test();
1202        assert_eq!(
1203            acc.accumulate(i32::MAX, i32::MAX),
1204            None,
1205            "a diagonal never commits"
1206        );
1207        assert_eq!(
1208            acc.accumulate(i32::MAX, i32::MAX),
1209            None,
1210            "the saturating sum must not panic"
1211        );
1212        // A clean axis on a fresh hold still commits with a saturated magnitude.
1213        acc.begin();
1214        acc.backdate_hold_for_test();
1215        assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right));
1216    }
1217
1218    #[test]
1219    fn accumulator_begin_recovers_a_stale_hold() {
1220        // A missed release (e.g. focus loss between press and release) can leave
1221        // a dangling hold that already fired with travel in some direction. A
1222        // fresh begin() must wipe both the `fired` latch and the travel, so the
1223        // next press isn't poisoned by the old one.
1224        let mut acc = SwipeAccumulator::default();
1225        acc.begin();
1226        acc.backdate_hold_for_test();
1227        // Stale hold commits LEFT (negative dx) and latches `fired`.
1228        assert_eq!(
1229            acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0),
1230            Some(GestureDirection::Left)
1231        );
1232        // No end() — a dropped release, then a fresh press.
1233        acc.begin();
1234        acc.backdate_hold_for_test();
1235        // Had `fired` leaked this would be None; had the negative travel leaked it
1236        // would commit Left. Committing Right proves begin() reset both.
1237        assert_eq!(
1238            acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
1239            Some(GestureDirection::Right)
1240        );
1241    }
1242
1243    #[test]
1244    fn accumulator_end_without_a_hold_is_not_a_click() {
1245        // end() in isolation (no begin) must not claim a click — there was no
1246        // hold — so a stray release can't be read as a press.
1247        let mut acc = SwipeAccumulator::default();
1248        assert!(!acc.end(), "a release with no hold is not a click");
1249        // A redundant second release after a real hold already ended is inert too.
1250        acc.begin();
1251        assert!(acc.end(), "the held release is a click");
1252        assert!(!acc.end(), "the redundant second release is not a click");
1253    }
1254
1255    // ── TOML roundtrip ────────────────────────────────────────────────────────
1256
1257    /// Serialize then deserialize `action` through TOML, using a wrapper
1258    /// struct because TOML requires a top-level table.
1259    fn roundtrip(action: &Action) -> Action {
1260        let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
1261        map.insert(ButtonId::Back, action.clone());
1262        let w = RoundtripWrapper { binding: map };
1263        let s = toml::to_string(&w).expect("serialize");
1264        let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
1265        back.binding
1266            .into_values()
1267            .next()
1268            .expect("binding present after roundtrip")
1269    }
1270
1271    #[test]
1272    fn all_catalog_variants_roundtrip_toml() {
1273        for action in Action::catalog() {
1274            let back = roundtrip(&action);
1275            assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
1276        }
1277    }
1278
1279    #[test]
1280    fn custom_shortcut_roundtrips_toml() {
1281        let action = Action::CustomShortcut(KeyCombo {
1282            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1283            key_code: 0x23, // kVK_ANSI_P
1284            display: "⌘⇧P".into(),
1285        });
1286        assert_eq!(roundtrip(&action), action);
1287    }
1288
1289    #[test]
1290    fn key_combo_rendered_label_uses_display_when_set() {
1291        let combo = KeyCombo {
1292            modifiers: 0,
1293            key_code: 0,
1294            display: "preset".into(),
1295        };
1296        assert_eq!(combo.rendered_label(), "preset");
1297    }
1298
1299    #[test]
1300    fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
1301        let combo = KeyCombo {
1302            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1303            key_code: 0x23, // P
1304            display: String::new(),
1305        };
1306        assert_eq!(combo.rendered_label(), "⇧⌘P");
1307    }
1308
1309    // ── Category tests ────────────────────────────────────────────────────────
1310
1311    #[test]
1312    fn category_editing_variants() {
1313        assert_eq!(Action::Copy.category(), Category::Editing);
1314        assert_eq!(Action::Undo.category(), Category::Editing);
1315        assert_eq!(Action::SelectAll.category(), Category::Editing);
1316        assert_eq!(Action::Find.category(), Category::Editing);
1317        assert_eq!(Action::Save.category(), Category::Editing);
1318        assert_eq!(Action::Cut.category(), Category::Editing);
1319        assert_eq!(Action::Redo.category(), Category::Editing);
1320        assert_eq!(Action::Paste.category(), Category::Editing);
1321    }
1322
1323    #[test]
1324    fn category_browser_variants() {
1325        assert_eq!(Action::BrowserBack.category(), Category::Browser);
1326        assert_eq!(Action::BrowserForward.category(), Category::Browser);
1327        assert_eq!(Action::NewTab.category(), Category::Browser);
1328        assert_eq!(Action::CloseTab.category(), Category::Browser);
1329        assert_eq!(Action::ReopenTab.category(), Category::Browser);
1330        assert_eq!(Action::NextTab.category(), Category::Browser);
1331        assert_eq!(Action::PrevTab.category(), Category::Browser);
1332        assert_eq!(Action::ReloadPage.category(), Category::Browser);
1333    }
1334
1335    #[test]
1336    fn category_media_variants() {
1337        assert_eq!(Action::PlayPause.category(), Category::Media);
1338        assert_eq!(Action::NextTrack.category(), Category::Media);
1339        assert_eq!(Action::PrevTrack.category(), Category::Media);
1340        assert_eq!(Action::VolumeUp.category(), Category::Media);
1341        assert_eq!(Action::VolumeDown.category(), Category::Media);
1342        assert_eq!(Action::MuteVolume.category(), Category::Media);
1343    }
1344
1345    #[test]
1346    fn category_mouse_variants() {
1347        assert_eq!(Action::LeftClick.category(), Category::Mouse);
1348        assert_eq!(Action::RightClick.category(), Category::Mouse);
1349        assert_eq!(Action::MiddleClick.category(), Category::Mouse);
1350    }
1351
1352    #[test]
1353    fn category_dpi_variants() {
1354        assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
1355        assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
1356    }
1357
1358    #[test]
1359    fn category_scroll_variants() {
1360        assert_eq!(Action::ScrollUp.category(), Category::Scroll);
1361        assert_eq!(Action::ScrollDown.category(), Category::Scroll);
1362        assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
1363        assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
1364    }
1365
1366    #[test]
1367    fn category_navigation_variants() {
1368        assert_eq!(Action::MissionControl.category(), Category::Navigation);
1369        assert_eq!(Action::AppExpose.category(), Category::Navigation);
1370        assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
1371        assert_eq!(Action::NextDesktop.category(), Category::Navigation);
1372        assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
1373        assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1374    }
1375
1376    #[test]
1377    fn category_system_variants() {
1378        assert_eq!(Action::LockScreen.category(), Category::System);
1379        assert_eq!(Action::Screenshot.category(), Category::System);
1380    }
1381
1382    // ── Category label smoke test ─────────────────────────────────────────────
1383
1384    #[test]
1385    fn category_labels_are_nonempty() {
1386        let categories = [
1387            Category::Editing,
1388            Category::Browser,
1389            Category::Media,
1390            Category::Mouse,
1391            Category::Dpi,
1392            Category::Scroll,
1393            Category::Navigation,
1394            Category::System,
1395        ];
1396        for cat in categories {
1397            assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1398        }
1399    }
1400
1401    // ── Default binding ───────────────────────────────────────────────────────
1402
1403    #[test]
1404    fn dpi_toggle_default_is_cycle_dpi_presets() {
1405        assert_eq!(
1406            default_binding(ButtonId::DpiToggle),
1407            Action::CycleDpiPresets
1408        );
1409    }
1410}