openlogi_core/binding/defaults.rs
1//! Default bindings for a fresh device / gesture map.
2
3use super::action::Action;
4use super::button::ButtonId;
5use super::gesture::GestureDirection;
6use super::value::Binding;
7
8/// Sensible defaults for a fresh device so the panel isn't empty on first run.
9///
10/// `GestureButton` matches what Logi Options+ ships for MX-line devices:
11/// gesture button → Mission Control, captured per-direction (see
12/// [`default_gesture_binding`]).
13///
14/// `GestureButton`'s entry here is vestigial: in the merged [`Binding`] model
15/// the gesture button defaults to [`Binding::Gesture`] (see
16/// [`default_binding_for`]), so this single-action value is never the source of
17/// truth for it. It is retained only so the per-button-`Action` callers (the
18/// hook map, scroll defaults, labels) stay total.
19///
20/// [`ButtonId::Thumbwheel`] — the wheel's capacitive tap — is deliberately
21/// inert. The mouse model surfaces the wheel as one paired rotation control,
22/// so the tap has no GUI hotspot to discover or clear it, while the firmware
23/// reports a tap from incidental thumb contact (including mid-roll, from the
24/// ridges alone). Seeding it with a real action therefore fired that action
25/// for users who only changed the rotation bindings or the sensitivity — the
26/// two settings that divert the wheel over `0x2150` in the first place. A tap
27/// bound explicitly in the config still dispatches; only the seed is inert.
28#[must_use]
29pub fn default_binding(button: ButtonId) -> Action {
30 match button {
31 ButtonId::LeftClick => Action::LeftClick,
32 ButtonId::RightClick => Action::RightClick,
33 ButtonId::MiddleClick => Action::MiddleClick,
34 // The main wheel's tilt scrolls horizontally in firmware. Seeding each
35 // side with the matching scroll action is what keeps a tilt the user
36 // never touched native: the capture plan diverts a control only when
37 // its binding leaves this default (see `capture_plan`), so an untouched
38 // tilt is never diverted and its firmware scroll is untouched.
39 #[expect(
40 clippy::match_same_arms,
41 reason = "the tilt and the thumb wheel are separate physical controls that happen to \
42 scroll the same way; merging their arms would tie two independent defaults \
43 together"
44 )]
45 ButtonId::WheelTiltLeft => Action::HorizontalScrollLeft,
46 #[expect(
47 clippy::match_same_arms,
48 reason = "see the left tilt above — same control pair, mirrored direction"
49 )]
50 ButtonId::WheelTiltRight => Action::HorizontalScrollRight,
51 // Preserve native side-button events unless explicitly rebound.
52 // BrowserBack/BrowserForward are dispatched navigation actions: using
53 // them as seeds would make the capture plan skip their HID++ diversion.
54 ButtonId::Back => Action::MouseBack,
55 ButtonId::Forward => Action::MouseForward,
56 ButtonId::DpiToggle => Action::CycleDpiPresets,
57 #[expect(
58 clippy::match_same_arms,
59 reason = "the tap is inert because its captured events are noise (see above), \
60 not because the control stays native like the keyboard arm below"
61 )]
62 ButtonId::Thumbwheel => Action::None,
63 // The thumb wheel scrolls horizontally in firmware: "up" (forward)
64 // scrolls left and "down" scrolls right. The device capture boundary
65 // normalises model-specific `0x2150 default_dir` polarity before these
66 // bindings are resolved, so the same physical direction reaches the
67 // same default on every model. The wheel watcher renders these actions
68 // as smooth, sensitivity-scaled scrolling rather than the discrete
69 // per-press burst a button would get (see `watchers::gesture`).
70 ButtonId::ThumbwheelScrollUp => Action::HorizontalScrollLeft,
71 ButtonId::ThumbwheelScrollDown => Action::HorizontalScrollRight,
72 ButtonId::GestureButton => Action::MissionControl,
73 ButtonId::HapticPanel => Action::ShowActionsRing,
74 // Keyboard keys stay on their native firmware function until the user
75 // explicitly binds them; an unbound key is never diverted, so a
76 // `None` default keeps the projection total without capturing anything.
77 ButtonId::Control(_) => Action::None,
78 }
79}
80
81/// Per-direction defaults for the gesture button. These are captured live over
82/// HID++ `0x1b04` (raw-XY diversion) and dispatched like any other binding; the
83/// defaults give the picker something sensible to show on first run.
84#[must_use]
85pub fn default_gesture_binding(direction: GestureDirection) -> Action {
86 match direction {
87 GestureDirection::Up => Action::MissionControl,
88 GestureDirection::Down => Action::ShowDesktop,
89 GestureDirection::Left => Action::PrevTab,
90 GestureDirection::Right => Action::NextTab,
91 GestureDirection::Click => Action::AppExpose,
92 }
93}
94
95/// The canonical default [`Binding`] for a fresh button in the merged model.
96///
97/// [`ButtonId::GestureButton`] defaults to [`Binding::Gesture`] populated from
98/// [`default_gesture_binding`] — preserving the existing per-direction swipe
99/// behavior — so the GUI mode toggle and the runtime agree it starts in gesture
100/// mode. Every other button defaults to [`Binding::Single`] of its
101/// [`default_binding`].
102///
103/// This is the seed when a button is first promoted to a gesture binding (see
104/// [`Config::set_gesture_direction`](crate::config::Config::set_gesture_direction)),
105/// so a freshly-customized gesture button always carries a full default
106/// direction map — including a [`GestureDirection::Click`] — rather than a sparse
107/// map whose click would project to a no-op [`Action::None`].
108#[must_use]
109pub fn default_binding_for(button: ButtonId) -> Binding {
110 match button {
111 ButtonId::GestureButton => Binding::Gesture(
112 GestureDirection::ALL
113 .into_iter()
114 .map(|d| (d, default_gesture_binding(d)))
115 .collect(),
116 ),
117 other => Binding::Single(default_binding(other)),
118 }
119}