Skip to main content

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}