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}