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;
11
12use serde::{Deserialize, Serialize};
13
14mod swipe;
15
16pub use swipe::{
17    GESTURE_HOLD_FOR_SWIPE, GESTURE_SWIPE_DEADZONE, GESTURE_SWIPE_THRESHOLD, SwipeAccumulator,
18    detect_swipe,
19};
20
21/// One of the user-rebindable hotspots on a Logi mouse. The order matches the
22/// physical layout from front to side; [`ButtonId::ALL`] is consumed by the
23/// default-binding generator and the popover trigger list.
24#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
25pub enum ButtonId {
26    /// The primary button. Rebindable in the config schema, but the OS hook
27    /// never suppresses it — see [`ButtonId::is_os_hook_button`].
28    LeftClick,
29    /// The secondary button. Like [`ButtonId::LeftClick`], it always passes
30    /// through the OS hook.
31    RightClick,
32    /// The wheel click — one of the three buttons the OS hook remaps.
33    MiddleClick,
34    /// The thumb-side "back" button (mouse button 4), remapped by the OS hook.
35    Back,
36    /// The thumb-side "forward" button (mouse button 5), remapped by the OS hook.
37    Forward,
38    /// The "ModeShift" button under the wheel — typically used for SmartShift /
39    /// DPI cycle. Named `DpiToggle` for historical reasons.
40    DpiToggle,
41    /// The horizontal thumb wheel's click. Kept in [`ButtonId::ALL`] so its
42    /// default still seeds and dispatches when the wheel is diverted, even
43    /// though the mouse model surfaces the two rotation directions instead of
44    /// the click (see `mouse_model::geometry`).
45    Thumbwheel,
46    /// Rotating the thumb wheel "up" (positive rotation). Bound, by default, to
47    /// continuous horizontal scroll; see the agent-core `watchers`-side dispatch.
48    ThumbwheelScrollUp,
49    /// Rotating the thumb wheel "down" (negative rotation).
50    ThumbwheelScrollDown,
51    /// The HID++ gesture button on MX-line devices. The press itself
52    /// fires the bound action; swipe directions are P1.5 territory.
53    GestureButton,
54}
55
56impl ButtonId {
57    /// Every rebindable button in declaration (physical front-to-side) order —
58    /// the iteration source for default-binding seeding and the popover
59    /// trigger list.
60    pub const ALL: [ButtonId; 10] = [
61        ButtonId::LeftClick,
62        ButtonId::RightClick,
63        ButtonId::MiddleClick,
64        ButtonId::Back,
65        ButtonId::Forward,
66        ButtonId::DpiToggle,
67        ButtonId::Thumbwheel,
68        ButtonId::ThumbwheelScrollUp,
69        ButtonId::ThumbwheelScrollDown,
70        ButtonId::GestureButton,
71    ];
72
73    /// Whether this button is one the OS hook (macOS `CGEventTap` / Linux evdev)
74    /// remaps: Middle, Back, or Forward. The primary L/R clicks always pass
75    /// through (suppressing them would brick the mouse), and the DPI / thumb /
76    /// dedicated gesture controls aren't visible to the OS hook at all (they're
77    /// captured over HID++). These are exactly the buttons that can become an
78    /// OS-hook gesture button, so the hook's remap gate and the gesture-owner
79    /// projection share this one definition.
80    #[must_use]
81    pub fn is_os_hook_button(self) -> bool {
82        matches!(
83            self,
84            ButtonId::MiddleClick | ButtonId::Back | ButtonId::Forward
85        )
86    }
87
88    /// Human-readable label for popovers and tooltips.
89    #[must_use]
90    pub fn label(self) -> &'static str {
91        match self {
92            ButtonId::LeftClick => "Left Click",
93            ButtonId::RightClick => "Right Click",
94            ButtonId::MiddleClick => "Middle Click",
95            ButtonId::Back => "Back",
96            ButtonId::Forward => "Forward",
97            ButtonId::DpiToggle => "DPI Toggle",
98            ButtonId::Thumbwheel => "Thumb Wheel",
99            ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
100            ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
101            ButtonId::GestureButton => "Gesture Button",
102        }
103    }
104}
105
106impl fmt::Display for ButtonId {
107    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
108        f.write_str(self.label())
109    }
110}
111
112/// One of the five sub-bindings on the gesture button: hold + swipe up/down/
113/// left/right or a plain click without movement. Logi ships these as
114/// independent assignments (`SLOT_NAME_GESTURE_*_BUTTON` in the
115/// `device_gesture_buttons_image` metadata block) — OpenLogi mirrors the
116/// same shape.
117///
118/// Variant identifiers are TOML-stable: renames are migration events.
119#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, Serialize, Deserialize)]
120pub enum GestureDirection {
121    /// Hold + swipe up (negative raw-XY `dy`).
122    Up,
123    /// Hold + swipe down (positive raw-XY `dy`).
124    Down,
125    /// Hold + swipe left (negative raw-XY `dx`).
126    Left,
127    /// Hold + swipe right (positive raw-XY `dx`).
128    Right,
129    /// A press-and-release that never committed a swipe — the gesture
130    /// button's plain-click slot.
131    Click,
132}
133
134impl GestureDirection {
135    /// All five direction slots, swipes first and [`Click`](Self::Click) last.
136    /// Iterated to seed or complete a full gesture map — see
137    /// [`Binding::fill_gesture_defaults`] and [`default_binding_for`].
138    pub const ALL: [GestureDirection; 5] = [
139        GestureDirection::Up,
140        GestureDirection::Down,
141        GestureDirection::Left,
142        GestureDirection::Right,
143        GestureDirection::Click,
144    ];
145
146    /// Human-readable label for popovers and tooltips.
147    #[must_use]
148    pub fn label(self) -> &'static str {
149        match self {
150            GestureDirection::Up => "Up",
151            GestureDirection::Down => "Down",
152            GestureDirection::Left => "Left",
153            GestureDirection::Right => "Right",
154            GestureDirection::Click => "Click",
155        }
156    }
157
158    /// Arrow glyph for compact list rendering.
159    #[must_use]
160    pub fn glyph(self) -> &'static str {
161        match self {
162            GestureDirection::Up => "↑",
163            GestureDirection::Down => "↓",
164            GestureDirection::Left => "←",
165            GestureDirection::Right => "→",
166            GestureDirection::Click => "·",
167        }
168    }
169}
170
171impl fmt::Display for GestureDirection {
172    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
173        f.write_str(self.label())
174    }
175}
176
177/// Grouping for popover section headers.
178///
179/// Used by [`Action::category`] and rendered as a small muted label above
180/// each group in the action picker.
181#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
182pub enum Category {
183    /// Cut, copy, paste, undo, redo, select-all, find, save.
184    Editing,
185    /// Browser navigation: tabs, page reload, back/forward.
186    Browser,
187    /// Playback and volume controls.
188    Media,
189    /// Physical mouse clicks.
190    Mouse,
191    /// DPI cycle and SmartShift.
192    Dpi,
193    /// Scroll direction shortcuts.
194    Scroll,
195    /// Window/app navigation: Mission Control, Launchpad, etc.
196    Navigation,
197    /// Lock screen, show desktop, system-level actions.
198    System,
199}
200
201impl Category {
202    /// Short label for popover section headers (already uppercase so callers
203    /// don't have to transform it).
204    #[must_use]
205    pub fn label(self) -> &'static str {
206        match self {
207            Category::Editing => "EDITING",
208            Category::Browser => "BROWSER",
209            Category::Media => "MEDIA",
210            Category::Mouse => "MOUSE",
211            Category::Dpi => "DPI",
212            Category::Scroll => "SCROLL",
213            Category::Navigation => "NAVIGATION",
214            Category::System => "SYSTEM",
215        }
216    }
217}
218
219/// What pressing a [`ButtonId`] should do.
220///
221/// Serialization uses serde's default external tagging: unit variants
222/// serialize as a bare string (`"BrowserBack"`) and the tuple variant
223/// serializes as a single-key table (`{ CustomShortcut = "my chord" }`).
224///
225/// **Stability contract:** existing variant *names* are frozen — they form the
226/// on-disk `config.toml` schema. New variants may be appended freely; removing
227/// or renaming a variant requires a `schema_version` bump and a migration.
228///
229/// This type is pure config data: OS-level event synthesis for each variant
230/// lives in the `openlogi-inject` crate (`openlogi_inject::execute`), keeping
231/// this crate platform- and IO-free.
232#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
233pub enum Action {
234    // ── System ───────────────────────────────────────────────────────────────
235    /// Suppress the input entirely — the button or wheel direction is captured
236    /// but no OS event is synthesised, so the physical input does nothing.
237    None,
238
239    // ── Mouse ────────────────────────────────────────────────────────────────
240    /// Primary mouse button.
241    LeftClick,
242    /// Secondary mouse button.
243    RightClick,
244    /// Middle mouse button (wheel click).
245    MiddleClick,
246    /// Mouse "back" side button (extra button 4). Synthesizes the real mouse
247    /// button event, which browsers and most apps interpret as "navigate back"
248    /// natively — unlike [`Action::BrowserBack`], which sends ⌘[ and is ignored
249    /// by many apps.
250    MouseBack,
251    /// Mouse "forward" side button (extra button 5). Native counterpart to
252    /// [`Action::MouseBack`]; see [`Action::BrowserForward`] for the ⌘] form.
253    MouseForward,
254
255    // ── Editing ──────────────────────────────────────────────────────────────
256    /// Copy the current selection (⌘C / Ctrl+C).
257    Copy,
258    /// Paste from the clipboard (⌘V / Ctrl+V).
259    Paste,
260    /// Cut the current selection (⌘X / Ctrl+X).
261    Cut,
262    /// Undo the last action (⌘Z / Ctrl+Z).
263    Undo,
264    /// Redo the last undone action (⌘⇧Z on macOS / Ctrl+Shift+Z on Linux).
265    ///
266    /// Note: Ctrl+Y is the dominant redo shortcut in LibreOffice and many GTK
267    /// apps. Ctrl+Shift+Z is used here because it mirrors the macOS convention
268    /// and works in GNOME text fields, browsers, and Electron apps. If Ctrl+Y
269    /// coverage is needed, a `CustomShortcut` binding is the escape hatch.
270    Redo,
271    /// Select all content (⌘A / Ctrl+A).
272    SelectAll,
273    /// Open the find / search bar (⌘F / Ctrl+F).
274    Find,
275    /// Save the current document (⌘S / Ctrl+S).
276    Save,
277
278    // ── Browser / Navigation ──────────────────────────────────────────────────
279    /// Navigate backward in browser history.
280    BrowserBack,
281    /// Navigate forward in browser history.
282    BrowserForward,
283    /// Open a new tab (⌘T / Ctrl+T).
284    NewTab,
285    /// Close the current tab (⌘W / Ctrl+W).
286    CloseTab,
287    /// Reopen the last closed tab (⌘⇧T / Ctrl+Shift+T).
288    ReopenTab,
289    /// Switch to the next tab (⌃⇥ / Ctrl+Tab).
290    NextTab,
291    /// Switch to the previous tab (⌃⇧⇥ / Ctrl+Shift+Tab).
292    PrevTab,
293    /// Reload the current page (⌘R / Ctrl+R).
294    ReloadPage,
295
296    // ── Navigation / Window ───────────────────────────────────────────────────
297    /// macOS Mission Control (⌃↑).
298    MissionControl,
299    /// macOS App Exposé — all windows for the current app (⌃↓).
300    AppExpose,
301    /// Switch to the previous desktop / Space.
302    PreviousDesktop,
303    /// Switch to the next desktop / Space.
304    NextDesktop,
305    /// Show the desktop (hide all windows).
306    ShowDesktop,
307    /// Open Launchpad.
308    LaunchpadShow,
309
310    // ── System ────────────────────────────────────────────────────────────────
311    /// Lock the screen (⌘⌃Q on macOS).
312    ///
313    /// On Linux, calls `org.freedesktop.login1.Manager.LockSession($XDG_SESSION_ID)`
314    /// on the system bus (current session only). Falls back to Super+L when
315    /// `$XDG_SESSION_ID` is unset or on non-systemd systems.
316    LockScreen,
317    /// Capture a screenshot.
318    Screenshot,
319    /// Capture a selected screen region to the clipboard.
320    ///
321    /// macOS uses Cmd+Shift+Ctrl+4; Windows uses Win+Shift+S. Linux delegates
322    /// to the desktop environment's screenshot handler via Print Screen.
323    CaptureRegion,
324
325    // ── Media ────────────────────────────────────────────────────────────────
326    /// Toggle media play/pause.
327    PlayPause,
328    /// Skip to the next track.
329    NextTrack,
330    /// Go back to the previous track.
331    PrevTrack,
332    /// Increase system volume.
333    VolumeUp,
334    /// Decrease system volume.
335    VolumeDown,
336    /// Toggle system mute.
337    MuteVolume,
338
339    // ── DPI ──────────────────────────────────────────────────────────────────
340    /// Step through the configured DPI preset list (P1.7).
341    CycleDpiPresets,
342    /// Jump to a specific zero-based preset in the device's DPI preset list.
343    /// Out-of-range indices clamp to the list length at fire time (P1.7).
344    SetDpiPreset(u8),
345    /// Toggle the HID++ SmartShift ratchet/free-spin wheel mode (P1.1).
346    ToggleSmartShift,
347
348    // ── Scroll ───────────────────────────────────────────────────────────────
349    /// Synthesise a vertical scroll-up tick.
350    ScrollUp,
351    /// Synthesise a vertical scroll-down tick.
352    ScrollDown,
353    /// Synthesise a horizontal scroll-left tick.
354    HorizontalScrollLeft,
355    /// Synthesise a horizontal scroll-right tick.
356    HorizontalScrollRight,
357
358    // ── Custom ───────────────────────────────────────────────────────────────
359    /// Replay an arbitrary recorded key chord (P1.3).
360    ///
361    /// Holds the structured chord data so `openlogi_inject::execute` can post the
362    /// real keystroke (macOS: CGEventPost with the encoded modifier flags).
363    /// The `display` field is used by [`Action::label`] so the popover
364    /// shows the user-friendly chord name.
365    CustomShortcut(KeyCombo),
366}
367
368/// A modifier + virtual-key keystroke captured by the P1.3 recorder UI or
369/// hand-authored in `config.toml`.
370///
371/// `modifiers` is a bitmask of [`KeyCombo::MOD_CMD`] etc. so the wire format
372/// is a compact integer, not a string. `key_code` is the macOS virtual key
373/// (`kVK_*`); on Linux, `openlogi-inject` maps it to an evdev `KeyCode` when it
374/// synthesizes the chord.
375///
376/// `display` is purely for rendering — e.g. `"⌘⇧P"`. Callers regenerate it
377/// from the captured chord; we keep it in the struct so older configs
378/// continue to render the same label without re-deriving on every load.
379#[derive(Clone, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
380pub struct KeyCombo {
381    /// Bitmask of [`Self::MOD_CMD`] etc.
382    pub modifiers: u8,
383    /// macOS virtual key code (`kVK_*`). 0 means "no key" — useful for
384    /// modifier-only placeholders that the recorder UI rejects. On Linux,
385    /// `openlogi-inject` translates this to an evdev `KeyCode`.
386    pub key_code: u16,
387    /// Pre-rendered chord label, e.g. `"⌘⇧P"`. Empty falls through to a
388    /// generated label at runtime.
389    #[serde(default)]
390    pub display: String,
391}
392
393impl KeyCombo {
394    /// Bit for the ⌘ Command modifier in [`Self::modifiers`].
395    pub const MOD_CMD: u8 = 1 << 0;
396    /// Bit for the ⇧ Shift modifier in [`Self::modifiers`].
397    pub const MOD_SHIFT: u8 = 1 << 1;
398    /// Bit for the ⌃ Control modifier in [`Self::modifiers`].
399    pub const MOD_CTRL: u8 = 1 << 2;
400    /// Bit for the ⌥ Option/Alt modifier in [`Self::modifiers`].
401    pub const MOD_OPTION: u8 = 1 << 3;
402
403    /// Build the human-readable label from the modifier bitmask + key code.
404    /// Falls back to `"⌘key 0xNN"` when the key code isn't one of the
405    /// commonly-recognised letters; the recorder UI usually overrides this
406    /// with its own derivation.
407    #[must_use]
408    pub fn rendered_label(&self) -> String {
409        if !self.display.is_empty() {
410            return self.display.clone();
411        }
412        let mut out = String::new();
413        if self.modifiers & Self::MOD_CTRL != 0 {
414            out.push('⌃');
415        }
416        if self.modifiers & Self::MOD_OPTION != 0 {
417            out.push('⌥');
418        }
419        if self.modifiers & Self::MOD_SHIFT != 0 {
420            out.push('⇧');
421        }
422        if self.modifiers & Self::MOD_CMD != 0 {
423            out.push('⌘');
424        }
425        match self.key_code {
426            0x00 => out.push('A'),
427            0x01 => out.push('S'),
428            0x02 => out.push('D'),
429            0x03 => out.push('F'),
430            0x06 => out.push('Z'),
431            0x07 => out.push('X'),
432            0x08 => out.push('C'),
433            0x09 => out.push('V'),
434            0x0B => out.push('B'),
435            0x0C => out.push('Q'),
436            0x0D => out.push('W'),
437            0x0E => out.push('E'),
438            0x0F => out.push('R'),
439            0x10 => out.push('Y'),
440            0x11 => out.push('T'),
441            0x20 => out.push('U'),
442            0x22 => out.push('I'),
443            0x1F => out.push('O'),
444            0x23 => out.push('P'),
445            _ => {
446                use std::fmt::Write as _;
447                let _ = write!(out, "key 0x{:02X}", self.key_code);
448            }
449        }
450        out
451    }
452}
453
454/// What a single rebindable [`ButtonId`] does: either one [`Action`], or — for a
455/// raw-XY-capable button placed in gesture mode — a per-[`GestureDirection`]
456/// map (hold + swipe up/down/left/right, or a plain click).
457///
458/// There has only ever been one binding map per device; a gesture binding is
459/// just a binding whose payload is a direction map instead of a single action.
460///
461/// # Serialization
462///
463/// `#[serde(untagged)]`: [`Single`](Binding::Single) serializes exactly as the
464/// bare [`Action`] did before (a string `"BrowserBack"`, or a single-key table
465/// for the payload variants), and [`Gesture`](Binding::Gesture) serializes as a
466/// table keyed by [`GestureDirection`] names (`Up`/`Down`/`Left`/`Right`/
467/// `Click`).
468///
469/// The two arms are disambiguated by the **zero overlap** between [`Action`]
470/// variant names and [`GestureDirection`] variant names — untagged tries
471/// `Single(Action)` first, and a table keyed by `Up` etc. cannot parse as an
472/// externally-tagged `Action`, so it falls through to `Gesture`. A payload
473/// action like `{ SetDpiPreset = 2 }` is a valid externally-tagged `Action`, so
474/// it stays `Single` and never reaches the `Gesture` arm. This invariant is the
475/// entire safety basis for untagged routing; the `binding_untagged_*` tests
476/// guard it (a future `Action` named `Up`/`Down`/`Left`/`Right`/`Click` would
477/// silently mis-route, and those tests would fail).
478#[derive(Clone, Debug, PartialEq, Eq, Serialize, Deserialize)]
479#[serde(untagged)]
480pub enum Binding {
481    /// One action, fired on press. The shape every non-gesture button uses.
482    Single(Action),
483    /// Per-direction sub-bindings for a button in gesture mode. Keyed by the
484    /// committed swipe direction, with [`GestureDirection::Click`] holding the
485    /// plain-click (no-swipe) action.
486    Gesture(BTreeMap<GestureDirection, Action>),
487}
488
489impl Binding {
490    /// The plain-click action for this binding: the [`Single`](Binding::Single)
491    /// action, or the [`Gesture`](Binding::Gesture) map's
492    /// [`Click`](GestureDirection::Click) entry. Falls back to [`Action::None`]
493    /// when a gesture binding has no explicit `Click`.
494    ///
495    /// Lets the click-dispatch path stay binding-shape-agnostic.
496    #[must_use]
497    pub fn click_action(&self) -> Action {
498        match self {
499            Binding::Single(action) => action.clone(),
500            Binding::Gesture(map) => map
501                .get(&GestureDirection::Click)
502                .cloned()
503                .unwrap_or(Action::None),
504        }
505    }
506
507    /// The action bound to `direction`, if this is a gesture binding.
508    /// [`Single`](Binding::Single) has no directions and returns `None`.
509    #[must_use]
510    pub fn direction_action(&self, direction: GestureDirection) -> Option<&Action> {
511        match self {
512            Binding::Single(_) => None,
513            Binding::Gesture(map) => map.get(&direction),
514        }
515    }
516
517    /// Whether this binding drives raw-XY swipe capture (the
518    /// [`Gesture`](Binding::Gesture) arm).
519    #[must_use]
520    pub fn is_gesture(&self) -> bool {
521        matches!(self, Binding::Gesture(_))
522    }
523
524    /// Promote a [`Single`](Binding::Single) binding in place to a
525    /// [`Gesture`](Binding::Gesture), keeping its action as the
526    /// [`GestureDirection::Click`] entry and leaving the swipe arms unbound.
527    /// A no-op when this is already a [`Gesture`](Binding::Gesture).
528    pub fn upgrade_to_gesture(&mut self) {
529        if let Binding::Single(action) = self {
530            let mut map = BTreeMap::new();
531            map.insert(GestureDirection::Click, action.clone());
532            *self = Binding::Gesture(map);
533        }
534    }
535
536    /// Fill any unbound directions of a [`Gesture`](Binding::Gesture) binding
537    /// with their canonical [`default_gesture_binding`], so a button promoted to
538    /// the gesture role always exposes the full five-direction set — rather than
539    /// leaving swipe arms the GUI renders as defaults but the runtime never
540    /// dispatches. A no-op on [`Single`](Binding::Single) and on directions
541    /// already bound (existing user choices are preserved).
542    pub fn fill_gesture_defaults(&mut self) {
543        if let Binding::Gesture(map) = self {
544            for dir in GestureDirection::ALL {
545                map.entry(dir)
546                    .or_insert_with(|| default_gesture_binding(dir));
547            }
548        }
549    }
550}
551
552impl From<Action> for Binding {
553    fn from(action: Action) -> Self {
554        Binding::Single(action)
555    }
556}
557
558impl Action {
559    /// Display label for the popover row.
560    ///
561    /// Returns `String` rather than `&str` so parameterized variants (e.g.
562    /// `SetDpiPreset(i)`, `CustomShortcut(s)`) can build a label that
563    /// includes their payload.
564    #[must_use]
565    pub fn label(&self) -> String {
566        match self {
567            Action::None => "Do Nothing".into(),
568            Action::LeftClick => "Left Click".into(),
569            Action::RightClick => "Right Click".into(),
570            Action::MiddleClick => "Middle Click".into(),
571            Action::MouseBack => "Back (Button 4)".into(),
572            Action::MouseForward => "Forward (Button 5)".into(),
573            Action::Copy => "Copy".into(),
574            Action::Paste => "Paste".into(),
575            Action::Cut => "Cut".into(),
576            Action::Undo => "Undo".into(),
577            Action::Redo => "Redo".into(),
578            Action::SelectAll => "Select All".into(),
579            Action::Find => "Find".into(),
580            Action::Save => "Save".into(),
581            Action::BrowserBack => "Browser Back".into(),
582            Action::BrowserForward => "Browser Forward".into(),
583            Action::NewTab => "New Tab".into(),
584            Action::CloseTab => "Close Tab".into(),
585            Action::ReopenTab => "Reopen Tab".into(),
586            Action::NextTab => "Next Tab".into(),
587            Action::PrevTab => "Previous Tab".into(),
588            Action::ReloadPage => "Reload Page".into(),
589            Action::MissionControl => "Mission Control".into(),
590            Action::AppExpose => "App Exposé".into(),
591            Action::PreviousDesktop => "Previous Desktop".into(),
592            Action::NextDesktop => "Next Desktop".into(),
593            Action::ShowDesktop => "Show Desktop".into(),
594            Action::LaunchpadShow => "Launchpad".into(),
595            Action::LockScreen => "Lock Screen".into(),
596            Action::Screenshot => "Screenshot".into(),
597            Action::CaptureRegion => "Capture Region".into(),
598            Action::PlayPause => "Play / Pause".into(),
599            Action::NextTrack => "Next Track".into(),
600            Action::PrevTrack => "Previous Track".into(),
601            Action::VolumeUp => "Volume Up".into(),
602            Action::VolumeDown => "Volume Down".into(),
603            Action::MuteVolume => "Mute".into(),
604            Action::CycleDpiPresets => "Cycle DPI Presets".into(),
605            Action::SetDpiPreset(i) => format!("DPI Preset {}", i + 1),
606            Action::ToggleSmartShift => "Toggle SmartShift".into(),
607            Action::ScrollUp => "Scroll Up".into(),
608            Action::ScrollDown => "Scroll Down".into(),
609            Action::HorizontalScrollLeft => "Scroll Left".into(),
610            Action::HorizontalScrollRight => "Scroll Right".into(),
611            Action::CustomShortcut(combo) => combo.rendered_label(),
612        }
613    }
614
615    /// Which [`Category`] this action belongs to, used for popover grouping.
616    #[must_use]
617    pub fn category(&self) -> Category {
618        match self {
619            Action::LeftClick
620            | Action::RightClick
621            | Action::MiddleClick
622            | Action::MouseBack
623            | Action::MouseForward => Category::Mouse,
624            // CustomShortcut is assigned to Editing so it doesn't need a
625            // separate arm (it's not in the picker catalog).
626            Action::Copy
627            | Action::Paste
628            | Action::Cut
629            | Action::Undo
630            | Action::Redo
631            | Action::SelectAll
632            | Action::Find
633            | Action::Save
634            | Action::CustomShortcut(_) => Category::Editing,
635            Action::BrowserBack
636            | Action::BrowserForward
637            | Action::NewTab
638            | Action::CloseTab
639            | Action::ReopenTab
640            | Action::NextTab
641            | Action::PrevTab
642            | Action::ReloadPage => Category::Browser,
643            Action::MissionControl
644            | Action::AppExpose
645            | Action::PreviousDesktop
646            | Action::NextDesktop
647            | Action::ShowDesktop
648            | Action::LaunchpadShow => Category::Navigation,
649            Action::None | Action::LockScreen | Action::Screenshot | Action::CaptureRegion => {
650                Category::System
651            }
652            Action::PlayPause
653            | Action::NextTrack
654            | Action::PrevTrack
655            | Action::VolumeUp
656            | Action::VolumeDown
657            | Action::MuteVolume => Category::Media,
658            Action::CycleDpiPresets | Action::SetDpiPreset(_) | Action::ToggleSmartShift => {
659                Category::Dpi
660            }
661            Action::ScrollUp
662            | Action::ScrollDown
663            | Action::HorizontalScrollLeft
664            | Action::HorizontalScrollRight => Category::Scroll,
665        }
666    }
667
668    /// All pickable actions in a deterministic order.
669    ///
670    /// [`Action::CustomShortcut`] is intentionally excluded — it is opened via
671    /// "Record shortcut…" (P1.3), not selected from the catalog.
672    #[must_use]
673    pub fn catalog() -> Vec<Action> {
674        vec![
675            // Mouse
676            Action::LeftClick,
677            Action::RightClick,
678            Action::MiddleClick,
679            Action::MouseBack,
680            Action::MouseForward,
681            // Editing
682            Action::Copy,
683            Action::Paste,
684            Action::Cut,
685            Action::Undo,
686            Action::Redo,
687            Action::SelectAll,
688            Action::Find,
689            Action::Save,
690            // Browser
691            Action::BrowserBack,
692            Action::BrowserForward,
693            Action::NewTab,
694            Action::CloseTab,
695            Action::ReopenTab,
696            Action::NextTab,
697            Action::PrevTab,
698            Action::ReloadPage,
699            // Navigation
700            Action::MissionControl,
701            Action::AppExpose,
702            Action::PreviousDesktop,
703            Action::NextDesktop,
704            Action::ShowDesktop,
705            Action::LaunchpadShow,
706            // System
707            Action::None,
708            Action::LockScreen,
709            Action::Screenshot,
710            Action::CaptureRegion,
711            // Media
712            Action::PlayPause,
713            Action::NextTrack,
714            Action::PrevTrack,
715            Action::VolumeUp,
716            Action::VolumeDown,
717            Action::MuteVolume,
718            // DPI
719            Action::CycleDpiPresets,
720            Action::ToggleSmartShift,
721            // Scroll
722            Action::ScrollUp,
723            Action::ScrollDown,
724            Action::HorizontalScrollLeft,
725            Action::HorizontalScrollRight,
726        ]
727    }
728}
729
730/// Sensible defaults for a fresh device so the panel isn't empty on first run.
731///
732/// Thumbwheel / GestureButton defaults match what Logi Options+ ships for
733/// MX-line devices: thumb wheel click → App Exposé, gesture button →
734/// Mission Control. The thumb wheel isn't captured yet; the dedicated gesture button is
735/// (per-direction, see [`default_gesture_binding`]). The bindings persist
736/// regardless so the user only configures once.
737///
738/// `GestureButton`'s entry here is vestigial: in the merged [`Binding`] model
739/// the gesture button defaults to [`Binding::Gesture`] (see
740/// [`default_binding_for`]), so this single-action value is never the source of
741/// truth for it. It is retained only so the per-button-`Action` callers (the
742/// hook map, scroll defaults, labels) stay total.
743#[must_use]
744pub fn default_binding(button: ButtonId) -> Action {
745    match button {
746        ButtonId::LeftClick => Action::LeftClick,
747        ButtonId::RightClick => Action::RightClick,
748        ButtonId::MiddleClick => Action::MiddleClick,
749        ButtonId::Back => Action::BrowserBack,
750        ButtonId::Forward => Action::BrowserForward,
751        ButtonId::DpiToggle => Action::CycleDpiPresets,
752        ButtonId::Thumbwheel => Action::AppExpose,
753        // The thumb wheel scrolls horizontally by default: rotating it produces
754        // continuous horizontal scroll, with "up" → right and "down" → left.
755        // The wheel watcher renders these two actions as smooth, sensitivity-
756        // scaled scrolling rather than the discrete per-press burst a button
757        // would get (see `watchers::gesture`).
758        ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollRight,
759        ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollLeft,
760        ButtonId::GestureButton => Action::MissionControl,
761    }
762}
763
764/// Per-direction defaults for the gesture button. These are captured live over
765/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
766/// defaults give the picker something sensible to show on first run.
767#[must_use]
768pub fn default_gesture_binding(direction: GestureDirection) -> Action {
769    match direction {
770        GestureDirection::Up => Action::MissionControl,
771        GestureDirection::Down => Action::ShowDesktop,
772        GestureDirection::Left => Action::PrevTab,
773        GestureDirection::Right => Action::NextTab,
774        GestureDirection::Click => Action::AppExpose,
775    }
776}
777
778/// The canonical default [`Binding`] for a fresh button in the merged model.
779///
780/// [`ButtonId::GestureButton`] defaults to [`Binding::Gesture`] populated from
781/// [`default_gesture_binding`] — preserving the existing per-direction swipe
782/// behavior — so the GUI mode toggle and the runtime agree it starts in gesture
783/// mode. Every other button defaults to [`Binding::Single`] of its
784/// [`default_binding`].
785///
786/// This is the seed when a button is first promoted to a gesture binding (see
787/// [`Config::set_gesture_direction`](crate::config::Config::set_gesture_direction)),
788/// so a freshly-customized gesture button always carries a full default
789/// direction map — including a [`GestureDirection::Click`] — rather than a sparse
790/// map whose click would project to a no-op [`Action::None`].
791#[must_use]
792pub fn default_binding_for(button: ButtonId) -> Binding {
793    match button {
794        ButtonId::GestureButton => Binding::Gesture(
795            GestureDirection::ALL
796                .into_iter()
797                .map(|d| (d, default_gesture_binding(d)))
798                .collect(),
799        ),
800        other => Binding::Single(default_binding(other)),
801    }
802}
803
804#[cfg(test)]
805#[allow(clippy::expect_used, reason = "expect/unwrap are idiomatic in tests")]
806mod tests {
807    use std::assert_matches;
808    use std::collections::BTreeMap;
809
810    use serde::{Deserialize, Serialize};
811
812    use super::*;
813
814    // ── Roundtrip wrapper: defined here so it precedes any `let` statements ──
815
816    /// Minimal TOML-serializable wrapper used by `roundtrip`.
817    /// Defined at module scope to satisfy `clippy::items_after_statements`.
818    #[derive(Serialize, Deserialize)]
819    struct RoundtripWrapper {
820        binding: BTreeMap<ButtonId, Action>,
821    }
822
823    // ── Catalog tests ─────────────────────────────────────────────────────────
824
825    #[test]
826    fn catalog_has_at_least_29_entries() {
827        let catalog = Action::catalog();
828        assert!(
829            catalog.len() >= 29,
830            "catalog has {} entries, need ≥ 29",
831            catalog.len()
832        );
833    }
834
835    #[test]
836    fn catalog_excludes_custom_shortcut() {
837        let catalog = Action::catalog();
838        for action in &catalog {
839            assert!(
840                !matches!(action, Action::CustomShortcut(_)),
841                "catalog must not contain CustomShortcut"
842            );
843        }
844    }
845
846    // ── Binding (merged model) serde routing ──────────────────────────────────
847
848    /// On-disk shape: a `ButtonId` → [`Binding`] map, as `DeviceConfig.bindings`
849    /// serializes it.
850    #[derive(Serialize, Deserialize)]
851    struct BindingWrapper {
852        bindings: BTreeMap<ButtonId, Binding>,
853    }
854
855    fn binding_roundtrip(bindings: BTreeMap<ButtonId, Binding>) -> BTreeMap<ButtonId, Binding> {
856        let toml = toml::to_string_pretty(&BindingWrapper { bindings }).expect("serialize");
857        toml::from_str::<BindingWrapper>(&toml)
858            .expect("deserialize")
859            .bindings
860    }
861
862    #[test]
863    fn binding_single_roundtrips_including_payload_variants() {
864        let mut bindings = BTreeMap::new();
865        bindings.insert(ButtonId::Back, Binding::Single(Action::BrowserBack));
866        bindings.insert(
867            ButtonId::DpiToggle,
868            Binding::Single(Action::SetDpiPreset(2)),
869        );
870        bindings.insert(
871            ButtonId::Forward,
872            Binding::Single(Action::CustomShortcut(KeyCombo {
873                modifiers: KeyCombo::MOD_CMD,
874                key_code: 0x23,
875                display: "⌘P".into(),
876            })),
877        );
878        let back = binding_roundtrip(bindings);
879        assert_eq!(back[&ButtonId::Back], Binding::Single(Action::BrowserBack));
880        assert_eq!(
881            back[&ButtonId::DpiToggle],
882            Binding::Single(Action::SetDpiPreset(2))
883        );
884        assert_matches!(
885            back[&ButtonId::Forward],
886            Binding::Single(Action::CustomShortcut(_))
887        );
888    }
889
890    #[test]
891    fn binding_gesture_roundtrips() {
892        let mut map = BTreeMap::new();
893        map.insert(GestureDirection::Up, Action::Copy);
894        map.insert(GestureDirection::Click, Action::Paste);
895        let mut bindings = BTreeMap::new();
896        bindings.insert(ButtonId::GestureButton, Binding::Gesture(map.clone()));
897        let back = binding_roundtrip(bindings);
898        assert_eq!(back[&ButtonId::GestureButton], Binding::Gesture(map));
899    }
900
901    /// The untagged-routing safety guard. A TOML table keyed by ANY
902    /// [`GestureDirection`] name must deserialize as [`Binding::Gesture`], never
903    /// [`Binding::Single`]. If a future [`Action`] payload variant is ever named
904    /// `Up`/`Down`/`Left`/`Right`/`Click`, the table would parse as `Single`
905    /// first and this test fails — catching the silent mis-route at CI time.
906    #[test]
907    fn binding_direction_keyed_table_routes_to_gesture() {
908        for dir in GestureDirection::ALL {
909            // `GestureDirection`'s serde key equals its `Display`/variant name.
910            let toml = format!("bindings.GestureButton.{dir} = \"None\"");
911            let parsed = toml::from_str::<BindingWrapper>(&toml).expect("deserialize");
912            assert!(
913                matches!(
914                    parsed.bindings[&ButtonId::GestureButton],
915                    Binding::Gesture(_)
916                ),
917                "a {dir}-keyed table must route to Gesture, not Single"
918            );
919        }
920    }
921
922    /// The collision case: a payload [`Action`] also serializes as a single-key
923    /// table, but untagged must keep it [`Binding::Single`] (it parses as a valid
924    /// externally-tagged `Action` before the `Gesture` arm is tried).
925    #[test]
926    fn binding_payload_action_stays_single() {
927        let toml = "bindings.DpiToggle.SetDpiPreset = 2";
928        let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
929        assert_eq!(
930            parsed.bindings[&ButtonId::DpiToggle],
931            Binding::Single(Action::SetDpiPreset(2))
932        );
933    }
934
935    #[test]
936    fn binding_capture_region_roundtrips_as_single_string() {
937        let toml = "bindings.Back = \"CaptureRegion\"";
938        let parsed = toml::from_str::<BindingWrapper>(toml).expect("deserialize");
939        assert_eq!(
940            parsed.bindings[&ButtonId::Back],
941            Binding::Single(Action::CaptureRegion)
942        );
943
944        let back = binding_roundtrip(parsed.bindings);
945        assert_eq!(
946            back[&ButtonId::Back],
947            Binding::Single(Action::CaptureRegion)
948        );
949        assert_eq!(Action::CaptureRegion.label(), "Capture Region");
950        assert_eq!(Action::CaptureRegion.category(), Category::System);
951        assert!(Action::catalog().contains(&Action::CaptureRegion));
952    }
953
954    // ── TOML roundtrip ────────────────────────────────────────────────────────
955
956    /// Serialize then deserialize `action` through TOML, using a wrapper
957    /// struct because TOML requires a top-level table.
958    fn roundtrip(action: &Action) -> Action {
959        let mut map: BTreeMap<ButtonId, Action> = BTreeMap::new();
960        map.insert(ButtonId::Back, action.clone());
961        let w = RoundtripWrapper { binding: map };
962        let s = toml::to_string(&w).expect("serialize");
963        let back: RoundtripWrapper = toml::from_str(&s).expect("deserialize");
964        back.binding
965            .into_values()
966            .next()
967            .expect("binding present after roundtrip")
968    }
969
970    #[test]
971    fn all_catalog_variants_roundtrip_toml() {
972        for action in Action::catalog() {
973            let back = roundtrip(&action);
974            assert_eq!(action, back, "TOML roundtrip failed for {action:?}");
975        }
976    }
977
978    #[test]
979    fn custom_shortcut_roundtrips_toml() {
980        let action = Action::CustomShortcut(KeyCombo {
981            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
982            key_code: 0x23, // kVK_ANSI_P
983            display: "⌘⇧P".into(),
984        });
985        assert_eq!(roundtrip(&action), action);
986    }
987
988    #[test]
989    fn key_combo_rendered_label_uses_display_when_set() {
990        let combo = KeyCombo {
991            modifiers: 0,
992            key_code: 0,
993            display: "preset".into(),
994        };
995        assert_eq!(combo.rendered_label(), "preset");
996    }
997
998    #[test]
999    fn key_combo_rendered_label_falls_back_to_modifiers_plus_key() {
1000        let combo = KeyCombo {
1001            modifiers: KeyCombo::MOD_CMD | KeyCombo::MOD_SHIFT,
1002            key_code: 0x23, // P
1003            display: String::new(),
1004        };
1005        assert_eq!(combo.rendered_label(), "⇧⌘P");
1006    }
1007
1008    // ── Category tests ────────────────────────────────────────────────────────
1009
1010    #[test]
1011    fn category_editing_variants() {
1012        assert_eq!(Action::Copy.category(), Category::Editing);
1013        assert_eq!(Action::Undo.category(), Category::Editing);
1014        assert_eq!(Action::SelectAll.category(), Category::Editing);
1015        assert_eq!(Action::Find.category(), Category::Editing);
1016        assert_eq!(Action::Save.category(), Category::Editing);
1017        assert_eq!(Action::Cut.category(), Category::Editing);
1018        assert_eq!(Action::Redo.category(), Category::Editing);
1019        assert_eq!(Action::Paste.category(), Category::Editing);
1020    }
1021
1022    #[test]
1023    fn category_browser_variants() {
1024        assert_eq!(Action::BrowserBack.category(), Category::Browser);
1025        assert_eq!(Action::BrowserForward.category(), Category::Browser);
1026        assert_eq!(Action::NewTab.category(), Category::Browser);
1027        assert_eq!(Action::CloseTab.category(), Category::Browser);
1028        assert_eq!(Action::ReopenTab.category(), Category::Browser);
1029        assert_eq!(Action::NextTab.category(), Category::Browser);
1030        assert_eq!(Action::PrevTab.category(), Category::Browser);
1031        assert_eq!(Action::ReloadPage.category(), Category::Browser);
1032    }
1033
1034    #[test]
1035    fn category_media_variants() {
1036        assert_eq!(Action::PlayPause.category(), Category::Media);
1037        assert_eq!(Action::NextTrack.category(), Category::Media);
1038        assert_eq!(Action::PrevTrack.category(), Category::Media);
1039        assert_eq!(Action::VolumeUp.category(), Category::Media);
1040        assert_eq!(Action::VolumeDown.category(), Category::Media);
1041        assert_eq!(Action::MuteVolume.category(), Category::Media);
1042    }
1043
1044    #[test]
1045    fn category_mouse_variants() {
1046        assert_eq!(Action::LeftClick.category(), Category::Mouse);
1047        assert_eq!(Action::RightClick.category(), Category::Mouse);
1048        assert_eq!(Action::MiddleClick.category(), Category::Mouse);
1049    }
1050
1051    #[test]
1052    fn category_dpi_variants() {
1053        assert_eq!(Action::CycleDpiPresets.category(), Category::Dpi);
1054        assert_eq!(Action::ToggleSmartShift.category(), Category::Dpi);
1055    }
1056
1057    #[test]
1058    fn category_scroll_variants() {
1059        assert_eq!(Action::ScrollUp.category(), Category::Scroll);
1060        assert_eq!(Action::ScrollDown.category(), Category::Scroll);
1061        assert_eq!(Action::HorizontalScrollLeft.category(), Category::Scroll);
1062        assert_eq!(Action::HorizontalScrollRight.category(), Category::Scroll);
1063    }
1064
1065    #[test]
1066    fn category_navigation_variants() {
1067        assert_eq!(Action::MissionControl.category(), Category::Navigation);
1068        assert_eq!(Action::AppExpose.category(), Category::Navigation);
1069        assert_eq!(Action::PreviousDesktop.category(), Category::Navigation);
1070        assert_eq!(Action::NextDesktop.category(), Category::Navigation);
1071        assert_eq!(Action::ShowDesktop.category(), Category::Navigation);
1072        assert_eq!(Action::LaunchpadShow.category(), Category::Navigation);
1073    }
1074
1075    #[test]
1076    fn category_system_variants() {
1077        assert_eq!(Action::LockScreen.category(), Category::System);
1078        assert_eq!(Action::Screenshot.category(), Category::System);
1079    }
1080
1081    // ── Category label smoke test ─────────────────────────────────────────────
1082
1083    #[test]
1084    fn category_labels_are_nonempty() {
1085        let categories = [
1086            Category::Editing,
1087            Category::Browser,
1088            Category::Media,
1089            Category::Mouse,
1090            Category::Dpi,
1091            Category::Scroll,
1092            Category::Navigation,
1093            Category::System,
1094        ];
1095        for cat in categories {
1096            assert!(!cat.label().is_empty(), "label empty for {cat:?}");
1097        }
1098    }
1099
1100    // ── Default binding ───────────────────────────────────────────────────────
1101
1102    #[test]
1103    fn dpi_toggle_default_is_cycle_dpi_presets() {
1104        assert_eq!(
1105            default_binding(ButtonId::DpiToggle),
1106            Action::CycleDpiPresets
1107        );
1108    }
1109}