openlogi_core/binding/swipe.rs
1//! The swipe-gesture runtime machinery: travel thresholds, the
2//! [`detect_swipe`] classifier, and the [`SwipeAccumulator`] state machine
3//! shared by both gesture-capture paths. This is input processing, distinct
4//! from the `Action` vocabulary the parent [`binding`](super) module defines.
5
6use std::time::Instant;
7
8use super::GestureDirection;
9
10/// Minimum dominant-axis travel (raw-XY units) before a held gesture commits to
11/// a direction. Tuned to match Logitech Options+'s responsiveness.
12pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
13/// Maximum cross-axis travel allowed at the threshold, so only a reasonably
14/// straight swipe commits. Grows with the dominant axis (`max(deadzone, 35%)`).
15pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
16/// Minimum time a gesture button must be held before its travel can commit to a
17/// swipe. Distinguishes a deliberate hold-and-swipe from a quick click whose
18/// cursor happened to be moving. Shared by both gesture paths (the HID++ thumb
19/// pad and the OS-hook Middle/Back/Forward).
20pub const GESTURE_HOLD_FOR_SWIPE: std::time::Duration = std::time::Duration::from_millis(160);
21
22/// Classify the *running* raw-XY travel of a held gesture button into a
23/// directional swipe, the instant it commits — or `None` while it's still too
24/// short or too diagonal.
25///
26/// The dominant axis must pass [`GESTURE_SWIPE_THRESHOLD`] while the cross axis
27/// stays within `max(`[`GESTURE_SWIPE_DEADZONE`]`, 35% of dominant)`. Callers
28/// fire the bound action the moment this returns `Some` — mid-swipe, like
29/// Options+ — rather than waiting for the button release; a press that never
30/// commits a direction is treated as [`GestureDirection::Click`] on release.
31///
32/// Coordinates follow the device's raw-XY convention (`+x` = right, `+y` =
33/// down), so an upward swipe (negative `dy`) maps to [`GestureDirection::Up`].
34#[must_use]
35pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
36 // Saturating throughout: a [`SwipeAccumulator`] hold that never commits (a
37 // sustained diagonal) keeps summing travel, so `dx`/`dy` can reach the i32
38 // bounds. `i32::MIN.abs()` would panic and a plain `dominant * 35` would
39 // overflow — and a panic in the input-hook callback is exactly the freeze
40 // hazard we must never hit. The clamp is inert in the normal range.
41 let (abs_x, abs_y) = (dx.saturating_abs(), dy.saturating_abs());
42 let dominant = abs_x.max(abs_y);
43 if dominant < GESTURE_SWIPE_THRESHOLD {
44 return None;
45 }
46 let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant.saturating_mul(35) / 100);
47 if abs_x > abs_y {
48 if abs_y > cross_limit {
49 return None;
50 }
51 Some(if dx > 0 {
52 GestureDirection::Right
53 } else {
54 GestureDirection::Left
55 })
56 } else {
57 if abs_x > cross_limit {
58 return None;
59 }
60 Some(if dy > 0 {
61 GestureDirection::Down
62 } else {
63 GestureDirection::Up
64 })
65 }
66}
67
68/// The mid-swipe state machine shared by both gesture-capture paths: the HID++
69/// dedicated gesture button (`openlogi-hid`'s `0x1b04` raw-XY divert) and the OS-hook
70/// Middle/Back/Forward buttons (`openlogi-agent-core`'s CGEventTap). A gesture
71/// button's hold accumulates travel; the instant the dominant axis commits a
72/// direction — after the button has been held [`GESTURE_HOLD_FOR_SWIPE`], so a
73/// quick click whose cursor drifted doesn't count — [`Self::accumulate`] returns
74/// that direction exactly once, like Logitech Options+. A hold that never
75/// commits is a plain click, reported by [`Self::end`].
76///
77/// The two paths differ only in *what identifies the held control* (a
78/// [`ButtonId`](super::ButtonId) for the OS hook, a diverted CID for the HID++ gesture control), so each owns
79/// that and embeds this for the shared travel logic. Keeping the logic in one
80/// place is deliberate: the two copies it replaced had already drifted apart
81/// (one resolved a swipe only on release), which mis-fired the click.
82#[derive(Debug, Default)]
83pub struct SwipeAccumulator {
84 /// When the current hold began, or `None` when not holding. Gates a
85 /// deliberate swipe against a quick click whose cursor happened to move.
86 held_since: Option<Instant>,
87 /// Accumulated raw-XY travel since the hold began (saturating, so an
88 /// arbitrarily long hold can never overflow).
89 dx: i32,
90 dy: i32,
91 /// Set once a direction has committed this hold, so it fires exactly once
92 /// and the release isn't then also read as a click.
93 fired: bool,
94}
95
96impl SwipeAccumulator {
97 /// Begin a fresh hold, resetting the travel accumulator and commit state.
98 pub fn begin(&mut self) {
99 self.held_since = Some(Instant::now());
100 self.dx = 0;
101 self.dy = 0;
102 self.fired = false;
103 }
104
105 /// Whether a hold is in progress (between [`Self::begin`] and [`Self::end`]),
106 /// so callers can do rising/falling-edge detection without a second flag.
107 #[must_use]
108 pub fn is_holding(&self) -> bool {
109 self.held_since.is_some()
110 }
111
112 /// Feed a pointer-move / raw-XY delta into the current hold. Returns
113 /// `Some(direction)` exactly once per hold — the instant travel commits, and
114 /// only after the hold passes [`GESTURE_HOLD_FOR_SWIPE`] — and `None` while
115 /// still too short, already committed, or not holding.
116 pub fn accumulate(&mut self, dx: i32, dy: i32) -> Option<GestureDirection> {
117 if self.fired || self.held_since.is_none() {
118 return None;
119 }
120 self.dx = self.dx.saturating_add(dx);
121 self.dy = self.dy.saturating_add(dy);
122 let held_long_enough = self
123 .held_since
124 .is_some_and(|t| t.elapsed() >= GESTURE_HOLD_FOR_SWIPE);
125 if held_long_enough && let Some(dir) = detect_swipe(self.dx, self.dy) {
126 self.fired = true;
127 return Some(dir);
128 }
129 None
130 }
131
132 /// End the current hold. Returns `true` when an in-progress hold ended
133 /// without committing a swipe — the caller should fire the plain `Click`
134 /// action — and `false` when a swipe already fired mid-motion, or when there
135 /// was no hold to end (a stray release reports no click).
136 pub fn end(&mut self) -> bool {
137 let was_click = self.held_since.is_some() && !self.fired;
138 self.held_since = None;
139 was_click
140 }
141
142 /// Test-only seam: backdate the current hold so its [`GESTURE_HOLD_FOR_SWIPE`]
143 /// gate is already satisfied, letting a test exercise a committed swipe
144 /// without sleeping. Real code never calls this — [`Self::begin`] records the
145 /// true start instant. A no-op when not currently holding.
146 #[doc(hidden)]
147 pub fn backdate_hold_for_test(&mut self) {
148 if self.held_since.is_some() {
149 self.held_since = Instant::now().checked_sub(GESTURE_HOLD_FOR_SWIPE * 2);
150 }
151 }
152}
153
154#[cfg(test)]
155mod tests {
156 use super::*;
157
158 // ── Gesture classification ────────────────────────────────────────────────
159
160 #[test]
161 fn detect_swipe_below_threshold_keeps_accumulating() {
162 // Too little travel to commit — caller keeps summing raw-XY.
163 assert_eq!(detect_swipe(40, 5), None);
164 assert_eq!(detect_swipe(0, 0), None);
165 }
166
167 #[test]
168 fn detect_swipe_commits_clean_direction() {
169 assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
170 assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
171 assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
172 assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
173 }
174
175 #[test]
176 fn detect_swipe_rejects_diagonal() {
177 // Past the threshold but too diagonal (cross axis beyond the band).
178 assert_eq!(detect_swipe(60, 60), None);
179 assert_eq!(detect_swipe(-60, -60), None);
180 }
181
182 #[test]
183 fn detect_swipe_threshold_and_cross_band_boundaries() {
184 // The threshold bound is inclusive (`< THRESHOLD` rejects), so exactly at
185 // it commits and one below does not.
186 assert_eq!(
187 detect_swipe(GESTURE_SWIPE_THRESHOLD, 0),
188 Some(GestureDirection::Right)
189 );
190 assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None);
191
192 // The cross-axis band is max(deadzone, 35% of dominant). For a large
193 // dominant the 35% term wins (200 → 70): 69 commits, 71 is too diagonal.
194 assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right));
195 assert_eq!(detect_swipe(200, 71), None);
196 // For a small dominant the 40-unit floor wins (100 → max(40, 35) = 40).
197 assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right));
198 assert_eq!(detect_swipe(100, 41), None);
199 }
200
201 #[test]
202 fn detect_swipe_does_not_panic_on_extreme_values() {
203 // Saturated accumulator travel can reach the i32 bounds. `i32::MIN.abs()`
204 // panics and `dominant * 35` overflows — both must be clamped, not crash.
205 assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right));
206 assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left));
207 assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down));
208 assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up));
209 // A diagonal at the extremes is still rejected, without panicking.
210 assert_eq!(detect_swipe(i32::MIN, i32::MIN), None);
211 }
212
213 // ── SwipeAccumulator (the shared mid-swipe state machine) ─────────────────
214
215 #[test]
216 fn accumulator_commits_a_direction_once_after_the_hold_gate() {
217 let mut acc = SwipeAccumulator::default();
218 acc.begin();
219 acc.backdate_hold_for_test();
220 // A clear rightward swipe commits exactly once, mid-motion.
221 assert_eq!(
222 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
223 Some(GestureDirection::Right)
224 );
225 // Further travel in the same hold must not re-fire.
226 assert_eq!(acc.accumulate(50, 0), None);
227 }
228
229 #[test]
230 fn accumulator_does_not_commit_before_the_hold_gate() {
231 let mut acc = SwipeAccumulator::default();
232 acc.begin(); // held_since = now, so the gate is not yet satisfied
233 // A big delta arriving immediately (a quick click whose cursor drifted)
234 // must not commit.
235 assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
236 // Once held long enough, the next delta commits.
237 acc.backdate_hold_for_test();
238 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some());
239 }
240
241 #[test]
242 fn accumulator_end_reports_click_only_when_no_swipe_fired() {
243 // A hold with only tiny drift never commits → end() is a click.
244 let mut acc = SwipeAccumulator::default();
245 acc.begin();
246 acc.backdate_hold_for_test();
247 assert_eq!(acc.accumulate(2, -1), None);
248 assert!(acc.end(), "a hold that never swiped is a click");
249
250 // A hold that committed a swipe → end() is not a click.
251 acc.begin();
252 acc.backdate_hold_for_test();
253 assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some());
254 assert!(!acc.end(), "a committed swipe must not also click");
255 }
256
257 #[test]
258 fn accumulator_ignores_motion_when_not_holding() {
259 let mut acc = SwipeAccumulator::default();
260 assert!(!acc.is_holding());
261 // Travel outside a hold is dropped, never committing a stray swipe.
262 assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
263 }
264
265 #[test]
266 fn accumulator_sums_sub_threshold_deltas_until_they_commit() {
267 // The whole reason for an accumulator (vs. detect_swipe on one delta):
268 // several deltas each too small to commit on their own must sum across
269 // the hold until the running total crosses the threshold, then commit.
270 let mut acc = SwipeAccumulator::default();
271 acc.begin();
272 acc.backdate_hold_for_test();
273 // Just under half the threshold: one or two steps never reach it, three do.
274 let step = GESTURE_SWIPE_THRESHOLD / 2 - 1;
275 assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold");
276 assert_eq!(acc.accumulate(step, 0), None, "two steps still under");
277 assert_eq!(
278 acc.accumulate(step, 0),
279 Some(GestureDirection::Right),
280 "the running sum finally crosses the threshold"
281 );
282 }
283
284 #[test]
285 fn accumulator_saturates_instead_of_overflowing() {
286 // The doc promises an arbitrarily long hold can't overflow. A perfect
287 // diagonal never commits, so travel keeps summing; feed deltas that would
288 // overflow both an i32 sum and a naive cross-band multiply — both must
289 // saturate, not panic (debug builds panic on overflow).
290 let mut acc = SwipeAccumulator::default();
291 acc.begin();
292 acc.backdate_hold_for_test();
293 assert_eq!(
294 acc.accumulate(i32::MAX, i32::MAX),
295 None,
296 "a diagonal never commits"
297 );
298 assert_eq!(
299 acc.accumulate(i32::MAX, i32::MAX),
300 None,
301 "the saturating sum must not panic"
302 );
303 // A clean axis on a fresh hold still commits with a saturated magnitude.
304 acc.begin();
305 acc.backdate_hold_for_test();
306 assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right));
307 }
308
309 #[test]
310 fn accumulator_begin_recovers_a_stale_hold() {
311 // A missed release (e.g. focus loss between press and release) can leave
312 // a dangling hold that already fired with travel in some direction. A
313 // fresh begin() must wipe both the `fired` latch and the travel, so the
314 // next press isn't poisoned by the old one.
315 let mut acc = SwipeAccumulator::default();
316 acc.begin();
317 acc.backdate_hold_for_test();
318 // Stale hold commits LEFT (negative dx) and latches `fired`.
319 assert_eq!(
320 acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0),
321 Some(GestureDirection::Left)
322 );
323 // No end() — a dropped release, then a fresh press.
324 acc.begin();
325 acc.backdate_hold_for_test();
326 // Had `fired` leaked this would be None; had the negative travel leaked it
327 // would commit Left. Committing Right proves begin() reset both.
328 assert_eq!(
329 acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
330 Some(GestureDirection::Right)
331 );
332 }
333
334 #[test]
335 fn accumulator_end_without_a_hold_is_not_a_click() {
336 // end() in isolation (no begin) must not claim a click — there was no
337 // hold — so a stray release can't be read as a press.
338 let mut acc = SwipeAccumulator::default();
339 assert!(!acc.end(), "a release with no hold is not a click");
340 // A redundant second release after a real hold already ended is inert too.
341 acc.begin();
342 assert!(acc.end(), "the held release is a click");
343 assert!(!acc.end(), "the redundant second release is not a click");
344 }
345}