Skip to main content

openlogi_hid/
gesture.rs

1//! Live control capture for one device: divert the MX thumb gesture button, the
2//! DPI/ModeShift button, and the thumb wheel over HID++ and turn their events
3//! into [`CapturedInput`] the GUI can dispatch.
4//!
5//! [`run_capture_session`] holds a single HID++ channel open for one device,
6//! enables diversion on whichever of those controls it exposes, registers one
7//! message listener, and restores every control's default mapping on shutdown.
8//! Using one channel matters: a second channel to the same device would split
9//! its input-report stream, so all captured controls share this session.
10//!
11//! The session is transport-only — it has no opinion on what an input *does*.
12//! The GUI maps each [`CapturedInput`] to the user's bound action and dispatches
13//! it, mirroring how the CGEventTap hook handles the side buttons. The thumb
14//! wheel is special: diverting it stops native horizontal scroll, so the GUI
15//! re-synthesises scroll from the [`CapturedInput::Scroll`] deltas — the wheel
16//! is therefore only diverted when its click is actually bound.
17
18use std::sync::{Arc, Mutex, PoisonError, RwLock};
19
20use hidpp::{channel::HidppChannel, device::Device, protocol::v20};
21use openlogi_core::binding::{ButtonId, GestureDirection, SwipeAccumulator};
22use serde::{Deserialize, Serialize};
23use thiserror::Error;
24use tokio::sync::{mpsc, oneshot};
25use tracing::{debug, info, warn};
26
27use crate::reprog_controls::{self, RawControlEvent, ReprogControlsV4};
28use crate::route::{DeviceRoute, open_route_channel};
29use crate::thumbwheel::{self, Thumbwheel};
30use crate::write::SharedChannel;
31
32/// Shared slot holding the active capture session's open channel, so DPI /
33/// SmartShift writes can reuse it instead of opening a fresh one. `None`
34/// whenever no session is connected.
35pub type CaptureChannel = Arc<RwLock<Option<SharedChannel>>>;
36
37/// One input captured from the active device.
38#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
39pub enum CapturedInput {
40    /// A completed gesture-button swipe.
41    Gesture(GestureDirection),
42    /// A diverted button was pressed — the DPI/ModeShift button
43    /// ([`ButtonId::DpiToggle`]) or the thumb-wheel single tap
44    /// ([`ButtonId::Thumbwheel`]).
45    ButtonPressed(ButtonId),
46    /// Thumb-wheel rotation to re-synthesise as horizontal scroll, in the
47    /// wheel's `diverted_res` increments. Emitted only while the wheel is
48    /// diverted to capture its click.
49    Scroll(i16),
50}
51
52/// Why a capture session could not start (or had to stop).
53#[derive(Debug, Error)]
54pub enum GestureError {
55    /// HID transport-level failure while enumerating or opening the device.
56    #[error("HID transport error")]
57    Hid(#[from] async_hid::HidError),
58    /// No connected device matched the capture route.
59    #[error("no connected device matched the capture route")]
60    DeviceNotFound,
61    /// The device at the target index did not answer HID++.
62    #[error("device at index {0:#04x} did not respond to HID++")]
63    DeviceUnreachable(u8),
64    /// A HID++ feature call returned an error; inner string carries context.
65    #[error("HID++ protocol error: {0}")]
66    Hidpp(String),
67}
68
69/// Movement + button state accumulated across messages. Lives behind a `Mutex`
70/// because the channel's read thread invokes the listener by shared reference.
71#[derive(Default)]
72struct CaptureAccum {
73    /// Mid-swipe state for the diverted thumb-pad gesture button (raw-XY).
74    swipe: SwipeAccumulator,
75    /// Whether any DPI/ModeShift control was held in the last event — for
76    /// rising-edge press detection.
77    dpi_down: bool,
78}
79
80/// Capture the gesture button, DPI/ModeShift button, and (when
81/// `capture_thumbwheel`) the thumb wheel on `route` until `shutdown` resolves,
82/// forwarding each event to `sink`.
83///
84/// The gesture button (raw-XY) is diverted only when `divert_gesture_button` —
85/// i.e. it is the device's gesture owner. When the user moves the gesture role
86/// to an OS-hook button or turns gestures off, the thumb pad is left undiverted
87/// so it keeps its native behavior instead of being captured-and-swallowed. The
88/// DPI/ModeShift capture and the channel-reuse slot are independent of this.
89///
90/// Opens and holds one HID++ channel, diverts whichever of those controls the
91/// device exposes, and listens. Returns once `shutdown` fires (or its sender is
92/// dropped), after restoring every diverted control. Setup errors are returned;
93/// failures to restore on the way out are logged, not propagated.
94pub async fn run_capture_session(
95    route: DeviceRoute,
96    capture_thumbwheel: bool,
97    divert_gesture_button: bool,
98    sink: mpsc::UnboundedSender<CapturedInput>,
99    shutdown: oneshot::Receiver<()>,
100    channel_slot: CaptureChannel,
101) -> Result<(), GestureError> {
102    let chan = open_route_channel(&route)
103        .await?
104        .ok_or(GestureError::DeviceNotFound)?;
105    let device_index = route.device_index();
106    let armed = arm_controls(
107        &chan,
108        device_index,
109        capture_thumbwheel,
110        divert_gesture_button,
111    )
112    .await?;
113
114    // Publish this device's open channel so DPI/SmartShift writes reuse it
115    // instead of opening their own. Cleared on the way out.
116    if let Ok(mut slot) = channel_slot.write() {
117        *slot = Some(SharedChannel::new(Arc::clone(&chan), route.clone()));
118    }
119
120    let accum = Arc::new(Mutex::new(CaptureAccum::default()));
121    let reprog_index = armed.reprog.as_ref().map(|(_, idx)| *idx);
122    let thumb_index = armed.thumb.as_ref().map(|(_, idx)| *idx);
123    let dpi_set = armed.dpi_cids.clone();
124    let hdl = chan.add_msg_listener({
125        let accum = Arc::clone(&accum);
126        let sink = sink.clone();
127        move |raw, matched| {
128            if matched {
129                return;
130            }
131            let msg = v20::Message::from(raw);
132            if let Some(idx) = reprog_index
133                && let Some(event) = reprog_controls::decode_event(&msg, device_index, idx)
134            {
135                // Recover the guard even if a prior holder panicked — the
136                // critical section is panic-free, so the data is consistent.
137                let mut acc = accum.lock().unwrap_or_else(PoisonError::into_inner);
138                handle_reprog(&mut acc, event, &dpi_set, &sink);
139                return;
140            }
141            if let Some(idx) = thumb_index
142                && let Some(event) = thumbwheel::decode_event(&msg, device_index, idx)
143            {
144                if event.single_tap {
145                    let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::Thumbwheel));
146                }
147                if event.rotation != 0 {
148                    let _ = sink.send(CapturedInput::Scroll(event.rotation));
149                }
150            }
151        }
152    });
153
154    info!(
155        index = device_index,
156        gesture = armed.gesture_diverted,
157        dpi_buttons = armed.dpi_cids.len(),
158        thumbwheel = armed.thumb.is_some(),
159        "control capture active"
160    );
161    let _ = shutdown.await;
162
163    chan.remove_msg_listener(hdl);
164    if let Ok(mut slot) = channel_slot.write() {
165        *slot = None;
166    }
167    armed.disarm().await;
168    debug!(index = device_index, "control capture stopped");
169    Ok(())
170}
171
172/// The set of controls a session has diverted, kept so they can be handed back
173/// to the firmware on teardown.
174struct ArmedControls {
175    /// `0x1b04` accessor + feature index, present when the device exposes it.
176    reprog: Option<(ReprogControlsV4, u8)>,
177    /// Whether the gesture button is diverted with raw-XY reporting.
178    gesture_diverted: bool,
179    /// DPI/ModeShift CIDs diverted as plain buttons.
180    dpi_cids: Vec<u16>,
181    /// `0x2150` accessor + feature index, present when the thumb wheel is
182    /// diverted.
183    thumb: Option<(Thumbwheel, u8)>,
184}
185
186impl ArmedControls {
187    /// Restore every diverted control. Failures are logged, not propagated.
188    async fn disarm(&self) {
189        if let Some((rc, _)) = self.reprog.as_ref() {
190            if self.gesture_diverted {
191                let r = rc
192                    .set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, false, false)
193                    .await;
194                restore(r, "gesture button");
195            }
196            for &cid in &self.dpi_cids {
197                restore(rc.set_cid_reporting(cid, false, false).await, "DPI button");
198            }
199        }
200        if let Some((tw, _)) = self.thumb.as_ref() {
201            restore(tw.set_reporting(false, false).await, "thumb wheel");
202        }
203    }
204}
205
206/// Resolve features off the device's root and divert the controls we capture:
207/// the gesture button (raw-XY) and DPI/ModeShift buttons over `0x1b04`, and —
208/// when `capture_thumbwheel` and the wheel reports a single tap — the thumb
209/// wheel over `0x2150`. The root-feature lookup mirrors `write::open_feature`,
210/// since hidpp 0.2's registry doesn't carry the features OpenLogi reimplements.
211async fn arm_controls(
212    chan: &Arc<HidppChannel>,
213    slot: u8,
214    capture_thumbwheel: bool,
215    divert_gesture_button: bool,
216) -> Result<ArmedControls, GestureError> {
217    let device = Device::new(Arc::clone(chan), slot)
218        .await
219        .map_err(|_| GestureError::DeviceUnreachable(slot))?;
220
221    let mut reprog: Option<(ReprogControlsV4, u8)> = None;
222    let mut gesture_diverted = false;
223    let mut dpi_cids: Vec<u16> = Vec::new();
224    if let Some(info) = device
225        .root()
226        .get_feature(reprog_controls::FEATURE_ID)
227        .await
228        .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
229    {
230        let rc = ReprogControlsV4::new(Arc::clone(chan), slot, info.index);
231        let controls = enumerate_controls(&rc).await?;
232
233        // Only divert the gesture button when it owns the gesture role; otherwise
234        // leave it native (a non-owner thumb pad must not be captured-and-dropped).
235        if divert_gesture_button
236            && controls
237                .iter()
238                .any(|c| c.cid == reprog_controls::GESTURE_BUTTON_CID && c.supports_raw_xy())
239        {
240            rc.set_cid_reporting(reprog_controls::GESTURE_BUTTON_CID, true, true)
241                .await
242                .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
243            gesture_diverted = true;
244        }
245        for &cid in &reprog_controls::DPI_MODE_SHIFT_CIDS {
246            if controls.iter().any(|c| c.cid == cid && c.is_divertable()) {
247                rc.set_cid_reporting(cid, true, false)
248                    .await
249                    .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
250                dpi_cids.push(cid);
251            }
252        }
253        reprog = Some((rc, info.index));
254    }
255
256    let mut thumb: Option<(Thumbwheel, u8)> = None;
257    if capture_thumbwheel
258        && let Some(info) = device
259            .root()
260            .get_feature(thumbwheel::FEATURE_ID)
261            .await
262            .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?
263    {
264        let tw = Thumbwheel::new(Arc::clone(chan), slot, info.index);
265        // Consume the getInfo error here, before the next await: Hidpp20Error
266        // isn't Send, so holding it across an await would make this future
267        // (spawned on tokio) non-Send.
268        let supports_single_tap = match tw.get_info().await {
269            Ok(twinfo) => twinfo.supports_single_tap,
270            Err(e) => {
271                warn!(error = ?e, "thumb wheel getInfo failed");
272                false
273            }
274        };
275        if supports_single_tap {
276            tw.set_reporting(true, false)
277                .await
278                .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
279            thumb = Some((tw, info.index));
280        } else {
281            debug!("thumb wheel reports no single tap — click not capturable");
282        }
283    }
284
285    if !gesture_diverted && dpi_cids.is_empty() && thumb.is_none() {
286        debug!(slot, "no capturable controls — idle session");
287    }
288    Ok(ArmedControls {
289        reprog,
290        gesture_diverted,
291        dpi_cids,
292        thumb,
293    })
294}
295
296/// Log (don't propagate) a failure to hand a control back to the firmware.
297fn restore<E: std::fmt::Display>(result: Result<(), E>, what: &str) {
298    if let Err(e) = result {
299        warn!(error = %e, control = what, "failed to restore control mapping on shutdown");
300    }
301}
302
303/// Read the device's full reprogrammable-control table in one pass, so we can
304/// test several CIDs without rescanning per control.
305async fn enumerate_controls(
306    rc: &ReprogControlsV4,
307) -> Result<Vec<reprog_controls::CtrlIdInfo>, GestureError> {
308    let count = rc
309        .get_count()
310        .await
311        .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?;
312    let mut controls = Vec::with_capacity(usize::from(count));
313    for index in 0..count {
314        controls.push(
315            rc.get_ctrl_id_info(index)
316                .await
317                .map_err(|e| GestureError::Hidpp(format!("{e:?}")))?,
318        );
319    }
320    Ok(controls)
321}
322
323/// Update `acc` and emit on a decoded `0x1b04` event: commit a gesture swipe the
324/// instant it crosses the threshold (mid-swipe, like Options+) rather than on
325/// release, and emit a [`ButtonId::DpiToggle`] press on the rising edge of any
326/// diverted DPI/ModeShift control.
327fn handle_reprog(
328    acc: &mut CaptureAccum,
329    event: RawControlEvent,
330    dpi_cids: &[u16],
331    sink: &mpsc::UnboundedSender<CapturedInput>,
332) {
333    match event {
334        RawControlEvent::DivertedButtons(cids) => {
335            let gesture_held = cids.contains(&reprog_controls::GESTURE_BUTTON_CID);
336            if gesture_held && !acc.swipe.is_holding() {
337                acc.swipe.begin();
338            } else if !gesture_held && acc.swipe.is_holding() {
339                // A press that never committed a direction is a plain click.
340                if acc.swipe.end() {
341                    debug!("gesture click");
342                    let _ = sink.send(CapturedInput::Gesture(GestureDirection::Click));
343                }
344            }
345
346            let dpi_down = dpi_cids.iter().any(|cid| cids.contains(cid));
347            if dpi_down && !acc.dpi_down {
348                let _ = sink.send(CapturedInput::ButtonPressed(ButtonId::DpiToggle));
349            }
350            acc.dpi_down = dpi_down;
351        }
352        RawControlEvent::RawXy { dx, dy } => {
353            // Commit the instant a clean direction emerges (mid-swipe, once per
354            // hold); the accumulator gates on hold duration internally and drops
355            // travel that arrives outside a hold.
356            if let Some(direction) = acc.swipe.accumulate(i32::from(dx), i32::from(dy)) {
357                debug!(?direction, "gesture committed");
358                let _ = sink.send(CapturedInput::Gesture(direction));
359            }
360        }
361    }
362}
363#[cfg(test)]
364mod tests {
365    use super::*;
366
367    fn press() -> RawControlEvent {
368        RawControlEvent::DivertedButtons([reprog_controls::GESTURE_BUTTON_CID, 0, 0, 0])
369    }
370
371    fn release() -> RawControlEvent {
372        RawControlEvent::DivertedButtons([0, 0, 0, 0])
373    }
374
375    #[test]
376    fn quick_tap_is_a_click_even_while_the_cursor_moves() {
377        let (tx, mut rx) = mpsc::unbounded_channel();
378        let mut acc = CaptureAccum::default();
379
380        handle_reprog(&mut acc, press(), &[], &tx);
381        handle_reprog(
382            &mut acc,
383            RawControlEvent::RawXy { dx: 120, dy: 5 },
384            &[],
385            &tx,
386        );
387        handle_reprog(&mut acc, release(), &[], &tx);
388
389        assert_eq!(
390            rx.try_recv(),
391            Ok(CapturedInput::Gesture(GestureDirection::Click))
392        );
393        assert!(
394            rx.try_recv().is_err(),
395            "a quick tap emits exactly one click"
396        );
397    }
398
399    #[test]
400    fn a_held_gesture_commits_a_swipe_and_does_not_also_click() {
401        let (tx, mut rx) = mpsc::unbounded_channel();
402        let mut acc = CaptureAccum::default();
403
404        handle_reprog(&mut acc, press(), &[], &tx);
405        // Pretend the button has been held well past the swipe gate.
406        acc.swipe.backdate_hold_for_test();
407        handle_reprog(
408            &mut acc,
409            RawControlEvent::RawXy { dx: 120, dy: 5 },
410            &[],
411            &tx,
412        );
413
414        assert_eq!(
415            rx.try_recv(),
416            Ok(CapturedInput::Gesture(GestureDirection::Right))
417        );
418
419        handle_reprog(&mut acc, release(), &[], &tx);
420        assert!(
421            rx.try_recv().is_err(),
422            "a committed swipe must not also click on release"
423        );
424    }
425
426    #[test]
427    fn a_held_dpi_button_presses_once_on_the_rising_edge() {
428        let (tx, mut rx) = mpsc::unbounded_channel();
429        let mut acc = CaptureAccum::default();
430        let dpi = reprog_controls::DPI_MODE_SHIFT_CIDS[0];
431        let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]);
432
433        handle_reprog(&mut acc, down, &[dpi], &tx);
434        handle_reprog(&mut acc, down, &[dpi], &tx);
435
436        assert_eq!(
437            rx.try_recv(),
438            Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle))
439        );
440        assert!(rx.try_recv().is_err(), "a held DPI button presses once");
441    }
442
443    #[test]
444    fn a_dpi_button_re_presses_after_a_release() {
445        // Rising-edge detection must re-arm: press → release → press is two
446        // distinct presses. The release (a frame without the CID) is what resets
447        // the edge; without it a re-press would be swallowed as "still held".
448        let (tx, mut rx) = mpsc::unbounded_channel();
449        let mut acc = CaptureAccum::default();
450        let dpi = reprog_controls::DPI_MODE_SHIFT_CIDS[0];
451        let down = RawControlEvent::DivertedButtons([dpi, 0, 0, 0]);
452        let up = RawControlEvent::DivertedButtons([0, 0, 0, 0]);
453
454        handle_reprog(&mut acc, down, &[dpi], &tx);
455        handle_reprog(&mut acc, up, &[dpi], &tx);
456        handle_reprog(&mut acc, down, &[dpi], &tx);
457
458        assert_eq!(
459            rx.try_recv(),
460            Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle))
461        );
462        assert_eq!(
463            rx.try_recv(),
464            Ok(CapturedInput::ButtonPressed(ButtonId::DpiToggle)),
465            "a release re-arms the rising edge"
466        );
467        assert!(rx.try_recv().is_err());
468    }
469}