openlogi_core/binding/button.rs
1//! Rebindable mouse/keyboard button identifiers.
2
3use std::borrow::Cow;
4use std::fmt;
5use std::str::FromStr;
6
7use serde::{Deserialize, Deserializer, Serialize, Serializer, de};
8use thiserror::Error;
9
10use super::control::Cid;
11
12/// One of the user-rebindable controls on a Logi device: a mouse hotspot, or
13/// a keyboard key addressed by its HID++ `0x1b04` control ID.
14///
15/// The mouse variants are declared in physical front-to-side order;
16/// [`ButtonId::ALL`] is consumed by the default-binding generator and the
17/// popover trigger list. Keyboard keys are [`ButtonId::Control`] and stay out
18/// of that list: they seed no default and are never diverted until bound.
19///
20/// # Serialization
21///
22/// Persisted as a string — the variant name for a mouse button
23/// (`"MiddleClick"`), the catalog name for a known control
24/// (`"KeyScreenCapture"`) or `"control:0x0141"` for any other — so a
25/// `ButtonId` can key a TOML table. See [`FromStr`](Self::from_str).
26#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)]
27pub enum ButtonId {
28 /// The primary button. Rebindable in the config schema, but the OS hook
29 /// never suppresses it — see [`ButtonId::is_os_hook_button`].
30 LeftClick,
31 /// The secondary button. Like [`ButtonId::LeftClick`], it always passes
32 /// through the OS hook.
33 RightClick,
34 /// The wheel click — one of the three buttons the OS hook remaps.
35 MiddleClick,
36 /// The thumb-side "back" button (mouse button 4), remapped by the OS hook.
37 Back,
38 /// The thumb-side "forward" button (mouse button 5), remapped by the OS hook.
39 Forward,
40 /// The "ModeShift" button under the wheel — typically used for SmartShift /
41 /// DPI cycle. Named `DpiToggle` for historical reasons.
42 DpiToggle,
43 /// The horizontal thumb wheel's click. Kept in [`ButtonId::ALL`] so its
44 /// default still seeds and dispatches when the wheel is diverted, even
45 /// though the mouse model surfaces one paired rotation control instead of
46 /// the click (see `mouse_model::geometry`).
47 Thumbwheel,
48 /// Rotating the thumb wheel "up" (positive rotation). Bound, by default, to
49 /// continuous horizontal scroll; see the agent-core `watchers`-side dispatch.
50 ThumbwheelScrollUp,
51 /// Rotating the thumb wheel "down" (negative rotation).
52 ThumbwheelScrollDown,
53 /// The HID++ gesture button on MX-line devices. The press itself
54 /// fires the bound action; swipe directions are P1.5 territory.
55 GestureButton,
56 /// The MX Master 4 Haptic Sense Panel — the touch-sensitive thumb rest
57 /// (Logi metadata slot `ASSIGNMENT_NAME_SHOW_RADIAL_MENU`, HID++ CID
58 /// `0x01a0`). A separate physical control from [`ButtonId::GestureButton`];
59 /// captured over HID++ like it, and eligible as the gesture owner.
60 HapticPanel,
61 /// Tilting the main wheel left — `0x1b04` CID `0x005b` ("Left Scroll"),
62 /// Logi metadata slot `SLOT_NAME_LEFT_SCROLL_BUTTON`. A distinct control
63 /// from the thumb wheel: it is a plain divertable button, not a rotation,
64 /// and it lives on the main wheel of mice like the MX Anywhere 2S.
65 WheelTiltLeft,
66 /// Tilting the main wheel right — `0x1b04` CID `0x005d` ("Right Scroll"),
67 /// Logi metadata slot `SLOT_NAME_RIGHT_SCROLL_BUTTON`. Counterpart to
68 /// [`ButtonId::WheelTiltLeft`].
69 WheelTiltRight,
70 /// A keyboard key, by the HID++ `0x1b04` control it reports as. Diverted
71 /// over HID++ while bound (the OS hook never sees these keys); left on its
72 /// native firmware function otherwise.
73 ///
74 /// Declared last so the derived ordering keeps every mouse button ahead of
75 /// the keys; among keys the order is by control ID, so a GUI that wants
76 /// physical F-row order sorts by catalog position instead.
77 Control(Cid),
78}
79
80impl ButtonId {
81 /// Every rebindable mouse button in declaration (physical front-to-side)
82 /// order — the iteration source for default-binding seeding and the
83 /// popover trigger list. Keyboard controls are deliberately absent: an
84 /// unbound key is never diverted, so there is nothing to seed.
85 pub const ALL: [ButtonId; 13] = [
86 ButtonId::LeftClick,
87 ButtonId::RightClick,
88 ButtonId::MiddleClick,
89 ButtonId::WheelTiltLeft,
90 ButtonId::WheelTiltRight,
91 ButtonId::Back,
92 ButtonId::Forward,
93 ButtonId::DpiToggle,
94 ButtonId::Thumbwheel,
95 ButtonId::ThumbwheelScrollUp,
96 ButtonId::ThumbwheelScrollDown,
97 ButtonId::GestureButton,
98 ButtonId::HapticPanel,
99 ];
100
101 /// The keyboard key bound to `0x1b04` control `cid`.
102 #[must_use]
103 pub const fn control(cid: u16) -> Self {
104 Self::Control(Cid::new(cid))
105 }
106
107 /// The `0x1b04` control this button diverts as, for a keyboard key.
108 #[must_use]
109 pub const fn cid(self) -> Option<Cid> {
110 match self {
111 Self::Control(cid) => Some(cid),
112 _ => None,
113 }
114 }
115
116 /// Whether this button is one the OS hook (macOS `CGEventTap` / Linux evdev)
117 /// remaps: Middle, Back, or Forward. The primary L/R clicks always pass
118 /// through (suppressing them would brick the mouse), and the DPI / thumb /
119 /// dedicated gesture controls aren't visible to the OS hook at all (they're
120 /// captured over HID++). Stored gesture bindings also project through this
121 /// set so Middle Click configurations written by v0.8.0 keep working;
122 /// [`Self::supports_gesture_mode`] separately controls which buttons may be
123 /// newly promoted by the UI.
124 #[must_use]
125 pub fn is_os_hook_button(self) -> bool {
126 matches!(
127 self,
128 ButtonId::MiddleClick | ButtonId::Back | ButtonId::Forward
129 )
130 }
131
132 /// Whether this button may use the OS hook's hold-and-swipe gesture path.
133 /// Back and Forward are the only eligible controls: Middle Click belongs
134 /// to the main wheel, whose controls intentionally remain single-action.
135 #[must_use]
136 pub fn is_os_hook_gesture_source(self) -> bool {
137 matches!(self, ButtonId::Back | ButtonId::Forward)
138 }
139
140 /// Whether this button is a HID++ gesture source — a control that is
141 /// captured over HID++ raw-XY diversion (never the OS hook) and can
142 /// therefore own the gesture role with swipe directions: DPI/ModeShift,
143 /// the dedicated gesture button, or the MX Master 4 haptic panel. The
144 /// capture layer maps each to its control ID and checks the device's
145 /// advertised raw-XY capability before arming it.
146 #[must_use]
147 pub fn is_hidpp_gesture_source(self) -> bool {
148 matches!(
149 self,
150 ButtonId::DpiToggle | ButtonId::GestureButton | ButtonId::HapticPanel
151 )
152 }
153
154 /// Whether OpenLogi offers gesture mode for this logical control. Wheel
155 /// controls, the primary clicks and keyboard keys stay single-action by
156 /// product policy; device-specific HID++ capability checks may further
157 /// narrow this set at capture time.
158 #[must_use]
159 pub fn supports_gesture_mode(self) -> bool {
160 self.is_os_hook_gesture_source() || self.is_hidpp_gesture_source()
161 }
162
163 /// The persisted name of every mouse button. Both directions of the
164 /// string form read this one table; `unit_name` is a match without a
165 /// wildcard over the unit variants, so a new variant that is missing here
166 /// fails to compile rather than failing to parse.
167 const UNIT_NAMES: [(ButtonId, &'static str); 13] = [
168 (ButtonId::LeftClick, "LeftClick"),
169 (ButtonId::RightClick, "RightClick"),
170 (ButtonId::MiddleClick, "MiddleClick"),
171 (ButtonId::Back, "Back"),
172 (ButtonId::Forward, "Forward"),
173 (ButtonId::DpiToggle, "DpiToggle"),
174 (ButtonId::Thumbwheel, "Thumbwheel"),
175 (ButtonId::ThumbwheelScrollUp, "ThumbwheelScrollUp"),
176 (ButtonId::ThumbwheelScrollDown, "ThumbwheelScrollDown"),
177 (ButtonId::GestureButton, "GestureButton"),
178 (ButtonId::HapticPanel, "HapticPanel"),
179 (ButtonId::WheelTiltLeft, "WheelTiltLeft"),
180 (ButtonId::WheelTiltRight, "WheelTiltRight"),
181 ];
182
183 /// The name a mouse button is persisted under. Keyboard controls have no
184 /// fixed name here; see [`Cid::config_name`].
185 const fn unit_name(self) -> Option<&'static str> {
186 Some(match self {
187 ButtonId::LeftClick => Self::UNIT_NAMES[0].1,
188 ButtonId::RightClick => Self::UNIT_NAMES[1].1,
189 ButtonId::MiddleClick => Self::UNIT_NAMES[2].1,
190 ButtonId::Back => Self::UNIT_NAMES[3].1,
191 ButtonId::Forward => Self::UNIT_NAMES[4].1,
192 ButtonId::DpiToggle => Self::UNIT_NAMES[5].1,
193 ButtonId::Thumbwheel => Self::UNIT_NAMES[6].1,
194 ButtonId::ThumbwheelScrollUp => Self::UNIT_NAMES[7].1,
195 ButtonId::ThumbwheelScrollDown => Self::UNIT_NAMES[8].1,
196 ButtonId::GestureButton => Self::UNIT_NAMES[9].1,
197 ButtonId::HapticPanel => Self::UNIT_NAMES[10].1,
198 ButtonId::WheelTiltLeft => Self::UNIT_NAMES[11].1,
199 ButtonId::WheelTiltRight => Self::UNIT_NAMES[12].1,
200 ButtonId::Control(_) => return None,
201 })
202 }
203
204 /// The name this button is written with in `config.toml`.
205 #[must_use]
206 pub fn config_name(self) -> Cow<'static, str> {
207 match self {
208 ButtonId::Control(cid) => cid.config_name(),
209 other => Cow::Borrowed(other.unit_name().unwrap_or_default()),
210 }
211 }
212
213 /// Human-readable label for popovers and tooltips.
214 #[must_use]
215 pub fn label(self) -> Cow<'static, str> {
216 Cow::Borrowed(match self {
217 ButtonId::LeftClick => "Left Click",
218 ButtonId::RightClick => "Right Click",
219 ButtonId::MiddleClick => "Middle Click",
220 ButtonId::WheelTiltLeft => "Tilt Left",
221 ButtonId::WheelTiltRight => "Tilt Right",
222 ButtonId::Back => "Back",
223 ButtonId::Forward => "Forward",
224 ButtonId::DpiToggle => "DPI Toggle",
225 ButtonId::Thumbwheel => "Thumb Wheel",
226 ButtonId::ThumbwheelScrollUp => "Thumb Wheel Up",
227 ButtonId::ThumbwheelScrollDown => "Thumb Wheel Down",
228 ButtonId::GestureButton => "Gesture Button",
229 ButtonId::HapticPanel => "Haptic Panel",
230 ButtonId::Control(cid) => return cid.label(),
231 })
232 }
233
234 /// Stable catalog key for the localized button label.
235 #[must_use]
236 pub fn translation_key(self) -> &'static str {
237 match self {
238 ButtonId::LeftClick => "actions.left_click",
239 ButtonId::RightClick => "actions.right_click",
240 ButtonId::MiddleClick => "actions.middle_click",
241 ButtonId::WheelTiltLeft => "actions.tilt_left",
242 ButtonId::WheelTiltRight => "actions.tilt_right",
243 ButtonId::Back => "actions.back",
244 ButtonId::Forward => "actions.forward",
245 ButtonId::DpiToggle => "actions.dpi_toggle",
246 ButtonId::Thumbwheel => "pointer.thumb_wheel",
247 ButtonId::ThumbwheelScrollUp => "pointer.thumb_wheel_up",
248 ButtonId::ThumbwheelScrollDown => "pointer.thumb_wheel_down",
249 ButtonId::GestureButton => "actions.gesture_button",
250 ButtonId::HapticPanel => "actions.haptic_panel",
251 ButtonId::Control(cid) => cid.translation_key(),
252 }
253 }
254}
255
256impl fmt::Display for ButtonId {
257 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
258 f.write_str(&self.label())
259 }
260}
261
262/// A persisted button name that names no button.
263#[derive(Debug, Clone, PartialEq, Eq, Error)]
264#[error("unknown button `{0}`")]
265pub struct ParseButtonIdError(pub String);
266
267impl FromStr for ButtonId {
268 type Err = ParseButtonIdError;
269
270 /// The inverse of [`ButtonId::config_name`]: a mouse button's variant
271 /// name, a known control's catalog name, or `control:0x…`.
272 fn from_str(name: &str) -> Result<Self, Self::Err> {
273 ButtonId::UNIT_NAMES
274 .into_iter()
275 .find_map(|(button, unit_name)| (unit_name == name).then_some(button))
276 .or_else(|| Cid::from_config_name(name).map(ButtonId::Control))
277 .ok_or_else(|| ParseButtonIdError(name.to_owned()))
278 }
279}
280
281impl Serialize for ButtonId {
282 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
283 serializer.serialize_str(&self.config_name())
284 }
285}
286
287impl<'de> Deserialize<'de> for ButtonId {
288 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
289 String::deserialize(deserializer)?
290 .parse()
291 .map_err(de::Error::custom)
292 }
293}