Skip to main content

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}