Skip to main content

openlogi_device/session/
gesture.rs

1//! Live control capture for one device: divert the device's gesture sources
2//! (DPI/ModeShift, the MX dedicated gesture button and/or the MX Master 4
3//! haptic panel), and the thumb wheel over HID++ and turn their events
4//! into [`CapturedInput`] the GUI can dispatch.
5//!
6//! [`run_capture_session`] runs on the HID++ channel inventory already holds
7//! open for one device, enables diversion on whichever of those controls it
8//! exposes, registers one message listener, and restores every control's
9//! default mapping on shutdown. Using that one channel matters: a second
10//! channel to the same device would split its input-report stream, so all
11//! captured controls share this session. The channel lifecycle itself is
12//! `session::capture`'s, shared with the keyboard session; this module owns
13//! what is armed (`arm`) and what its reports mean (`accum`).
14//!
15//! The session is transport-only — it has no opinion on what an input *does*.
16//! The GUI maps each [`CapturedInput`] to the user's bound action and dispatches
17//! it, mirroring how the CGEventTap hook handles the side buttons. The thumb
18//! wheel is special: diverting it stops native horizontal scroll, so the GUI
19//! re-synthesises scroll from the [`CapturedInput::Scroll`] deltas — the wheel
20//! is therefore only diverted when the user's thumbwheel config leaves its
21//! defaults (click bound, rotation rebound, or sensitivity changed).
22
23mod accum;
24mod arm;
25
26use std::sync::{Arc, Mutex, PoisonError};
27
28use hidpp::protocol::v20;
29use openlogi_core::binding::{ButtonId, GestureDirection};
30use tokio::sync::mpsc;
31use tracing::info;
32
33use crate::SharedChannel;
34use crate::channel::route::DeviceRoute;
35
36pub use super::capture::CaptureHost;
37use super::capture::{ArmedCapture, Liveness, run_capture};
38use accum::CaptureAccum;
39pub(crate) use arm::enumerate_controls;
40use arm::{ArmedControls, ArmedThumbwheel, arm_controls};
41
42pub use super::capture_restore::{
43    CaptureChannelSlot, CaptureError, CaptureSessionFailure, CaptureSessionOutcome,
44    PendingCaptureRestore,
45};
46use crate::reprog_controls::{self, ReprogControlsV4};
47use crate::thumbwheel::{self, WheelResolution};
48
49/// One input captured from the active device.
50#[derive(Debug, Clone, Copy, PartialEq, Eq)]
51pub enum CapturedInput {
52    /// A completed swipe (or tap click) from a diverted gesture source,
53    /// tagged with the source control so dispatch resolves it against that
54    /// button's own direction map.
55    Gesture(ButtonId, GestureDirection),
56    /// A diverted button's physical down edge.
57    ButtonDown(ButtonId),
58    /// Thumb-wheel rotation to re-synthesise on the configured scroll axis.
59    /// Emitted while the wheel is diverted (click bound, rotation rebound, or
60    /// sensitivity changed).
61    Scroll {
62        /// Rotation in the wheel's diverted increments. Positive is always
63        /// physical forward/up: arming normalises the model-specific polarity
64        /// reported by `0x2150 default_dir`.
65        increments: i16,
66        /// What one revolution measures in each mode, so the dispatcher can
67        /// scale those increments back to the wheel's native scroll amount
68        /// instead of scrolling by however finely this wheel happens to
69        /// report.
70        resolution: WheelResolution,
71    },
72    /// The un-inverted polarity learned while arming a thumb wheel. This is a
73    /// one-time session fact rather than user input; the agent records it for
74    /// native horizontal-wheel events that the Windows hook cannot attribute
75    /// to a device.
76    ThumbwheelDirection {
77        /// Whether a positive native delta is physical forward/up.
78        positive_is_forward: bool,
79    },
80    /// A diverted button's physical up edge.
81    ButtonUp(ButtonId),
82    /// An instantaneous firmware-reported tap with no observable hold
83    /// duration, such as the thumb-wheel touch sensor.
84    ButtonPulse(ButtonId),
85}
86
87/// HID++-divertable standard buttons: the `0x1b04` control ID and the
88/// [`ButtonId`] its press dispatches as. A button is diverted per device only
89/// when its binding leaves the default, so an unbound button keeps its native
90/// HID behavior (no re-synthesis needed). The Haptic Sense Panel is a gesture
91/// source ([`GESTURE_SOURCE_BUTTONS`]), not a member of this table.
92///
93/// The two wheel-tilt CIDs are the classic "Left/Right Scroll" controls that
94/// MX-line mice with a tilting main wheel (MX Anywhere 2S and friends) expose
95/// as divertable — the same mechanism Options+ uses to rebind a tilt. Arming
96/// only ever diverts what a device's own `getCtrlIdInfo` reports, so listing
97/// them here is inert on a mouse whose wheel does not tilt.
98pub const DIVERTABLE_STANDARD_BUTTONS: [(u16, ButtonId); 9] = {
99    // Destructured rather than indexed: a family that gains a CID stops
100    // compiling here instead of silently staying out of the table.
101    let [
102        back,
103        back_multiplatform,
104        back_multiplatform_alt,
105        back_generic,
106    ] = reprog_controls::BACK_CIDS;
107    let [forward, forward_multiplatform] = reprog_controls::FORWARD_CIDS;
108    [
109        (0x0052, ButtonId::MiddleClick),
110        (back, ButtonId::Back),
111        (back_multiplatform, ButtonId::Back),
112        (back_multiplatform_alt, ButtonId::Back),
113        (back_generic, ButtonId::Back),
114        (forward, ButtonId::Forward),
115        (forward_multiplatform, ButtonId::Forward),
116        (0x005b, ButtonId::WheelTiltLeft),
117        (0x005d, ButtonId::WheelTiltRight),
118    ]
119};
120
121/// HID++ gesture sources: the `0x1b04` control ID and the [`ButtonId`] it
122/// delivers — the dedicated gesture button on most MX mice, and the Haptic
123/// Sense Panel on MX Master 4 (two distinct physical controls). Each source in
124/// gesture mode is diverted with raw-XY; one with a non-default single binding
125/// instead is plain-diverted like a standard button.
126pub const GESTURE_SOURCE_BUTTONS: [(u16, ButtonId); 2] = [
127    (reprog_controls::GESTURE_BUTTON_CID, ButtonId::GestureButton),
128    (reprog_controls::HAPTIC_PANEL_CID, ButtonId::HapticPanel),
129];
130
131/// Which of one device's controls a capture session should divert.
132#[derive(Debug, Clone, Default, PartialEq, Eq)]
133pub struct CaptureSpec {
134    /// Divert the thumb wheel over `0x2150` (rotation rebind / sensitivity /
135    /// click bound).
136    pub capture_thumbwheel: bool,
137    /// Gesture-source CIDs ([`GESTURE_SOURCE_BUTTONS`] members) to divert
138    /// with raw-XY — one per source in gesture mode; empty when no HID++
139    /// control gestures.
140    pub divert_gesture_sources: Vec<u16>,
141    /// Standard-button CIDs requested as raw-XY gesture sources. A control is
142    /// armed only when its HID++ capability flags advertise raw-XY support.
143    pub divert_gesture_buttons: Vec<(u16, ButtonId)>,
144    /// Buttons to divert as plain presses (no raw-XY): the
145    /// [`DIVERTABLE_STANDARD_BUTTONS`] and non-gesturing
146    /// [`GESTURE_SOURCE_BUTTONS`] whose binding leaves the default.
147    pub divert_buttons: Vec<(u16, ButtonId)>,
148}
149
150/// Capture the controls selected by `spec` on `route` until `host.shutdown`
151/// resolves, forwarding each event to `host.sink`.
152///
153/// Each gesture source in `spec.divert_gesture_sources` is diverted with
154/// raw-XY. A source not in gesture mode keeps its native behavior — unless a
155/// non-default single binding puts it in `spec.divert_buttons`, in which case
156/// it is diverted as a plain button (the OS hook never sees a gesture-source
157/// CID, so this is the binding's only delivery path). The DPI/ModeShift
158/// capture and the channel-reuse slot are independent of this.
159///
160/// Runs on the inventory-owned channel `host.registry` currently publishes
161/// for `route`: sharing that connection avoids splitting HID++ replies and
162/// input reports across two readers, and a registry miss
163/// ([`CaptureError::DeviceNotFound`]) is retried by the caller after a later
164/// inventory publication. Diverts whichever of those controls the device
165/// exposes, and listens. Returns once `host.shutdown` fires (or its sender is
166/// dropped). A normal stop restores every diverted control before returning;
167/// transport replacement or loss may return
168/// [`CaptureSessionOutcome::RestorePending`] for the caller to retry on the
169/// current inventory channel.
170pub async fn run_capture_session(
171    route: DeviceRoute,
172    spec: CaptureSpec,
173    host: CaptureHost<'_>,
174) -> Result<CaptureSessionOutcome, CaptureSessionFailure> {
175    let shared = host.channel_for(&route)?;
176    let armed = arm_controls(&shared, &spec, host.registry).await?;
177    if let Some(direction) = armed.thumbwheel_direction() {
178        let _ = host.sink.send(direction);
179    }
180    Ok(run_capture(shared, GestureCapture::new(armed), host).await)
181}
182
183/// Gesture capture as [`run_capture`] drives it: the armed controls, and the
184/// accumulator their reports feed.
185struct GestureCapture {
186    armed: ArmedControls,
187    /// Behind a `Mutex` because the channel's read thread invokes the report
188    /// handler by shared reference.
189    accum: Arc<Mutex<CaptureAccum>>,
190}
191
192impl GestureCapture {
193    fn new(armed: ArmedControls) -> Self {
194        Self {
195            armed,
196            accum: Arc::default(),
197        }
198    }
199}
200
201impl ArmedCapture for GestureCapture {
202    const NAME: &'static str = "control";
203    const LIVENESS: Liveness = Liveness::Watched;
204
205    fn log_active(&self, device_index: u8, wake_rearm: bool) {
206        let armed = &self.armed;
207        info!(
208            index = device_index,
209            gesture_sources = armed.gesture_cids.len(),
210            gesture_buttons = armed.gesture_button_cids.len(),
211            dpi_buttons = armed.dpi_cids.len(),
212            buttons = armed.button_cids.len(),
213            thumbwheel = armed.thumb.is_some(),
214            wake_rearm,
215            "control capture active"
216        );
217    }
218
219    fn report_handler(
220        &self,
221        device_index: u8,
222        sink: mpsc::UnboundedSender<CapturedInput>,
223    ) -> impl Fn(&v20::Message) + Send + Sync + 'static {
224        let armed = &self.armed;
225        let accum = Arc::clone(&self.accum);
226        let reprog_index = armed.reprog.as_ref().map(ReprogControlsV4::feature_index);
227        let gesture_cids = armed.gesture_cids.clone();
228        let gesture_button_set = armed.gesture_button_cids.clone();
229        let thumb_index = armed
230            .thumb
231            .as_ref()
232            .map(|thumb| thumb.wheel.feature_index());
233        let thumb_resolution = armed
234            .thumb
235            .as_ref()
236            .map_or(WheelResolution::UNKNOWN, ArmedThumbwheel::resolution);
237        let dpi_set = armed.dpi_cids.clone();
238        let button_set = armed.button_cids.clone();
239        move |msg| {
240            if let Some(idx) = reprog_index
241                && let Some(event) = reprog_controls::decode_event(msg, device_index, idx)
242            {
243                // Recover the guard even if a prior holder panicked — the
244                // critical section is panic-free, so the data is consistent.
245                let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner);
246                acc.on_event(
247                    event,
248                    &gesture_cids,
249                    &dpi_set,
250                    &gesture_button_set,
251                    &button_set,
252                    &sink,
253                );
254                return;
255            }
256            if let Some(idx) = thumb_index
257                && let Some(event) = thumbwheel::decode_event(msg, device_index, idx)
258                && let Some(input) = thumbwheel_input(event, thumb_resolution)
259            {
260                let _ = sink.send(input);
261            }
262        }
263    }
264
265    fn reset_input_state(&self) {
266        *self.accum.lock().unwrap_or_else(PoisonError::into_inner) = CaptureAccum::default();
267    }
268
269    async fn rearm(&self) {
270        self.armed.rearm().await;
271    }
272
273    fn into_pending(self, retired: &SharedChannel) -> Option<PendingCaptureRestore> {
274        self.armed.into_pending(retired)
275    }
276}
277
278/// The single input one diverted thumb-wheel report stands for, if any.
279///
280/// A report is a roll *or* a tap, never both, and `0x2150` says which: the
281/// wheel's touch sensor sets `single_tap` for the finger that turned the
282/// wheel, so every report from `Start` through `Stop` carries a tap bit that
283/// belongs to the roll rather than to the user. `Stop` is the one that needs
284/// the status field — it is the release, so it reports no rotation of its own
285/// and is otherwise indistinguishable from a tap on a settled wheel.
286///
287/// A report's own rotation is checked alongside the status rather than
288/// through it: both are direct statements that this report is part of a roll,
289/// and taking either keeps the roll recognised on a wheel whose firmware
290/// leaves byte 4 at zero.
291fn thumbwheel_input(
292    event: thumbwheel::ThumbwheelEvent,
293    resolution: WheelResolution,
294) -> Option<CapturedInput> {
295    if event.rotation != 0 {
296        return Some(CapturedInput::Scroll {
297            increments: event.rotation,
298            resolution,
299        });
300    }
301    if event.rotation_status.is_rolling() {
302        return None;
303    }
304    event
305        .single_tap
306        .then_some(CapturedInput::ButtonPulse(ButtonId::Thumbwheel))
307}
308
309#[cfg(test)]
310mod tests;