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;