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