use std::time::Instant;
use super::GestureDirection;
pub const GESTURE_SWIPE_THRESHOLD: i32 = 50;
pub const GESTURE_SWIPE_DEADZONE: i32 = 40;
pub const GESTURE_HOLD_FOR_SWIPE: std::time::Duration = std::time::Duration::from_millis(160);
#[must_use]
pub fn detect_swipe(dx: i32, dy: i32) -> Option<GestureDirection> {
let (abs_x, abs_y) = (dx.saturating_abs(), dy.saturating_abs());
let dominant = abs_x.max(abs_y);
if dominant < GESTURE_SWIPE_THRESHOLD {
return None;
}
let cross_limit = GESTURE_SWIPE_DEADZONE.max(dominant.saturating_mul(35) / 100);
if abs_x > abs_y {
if abs_y > cross_limit {
return None;
}
Some(if dx > 0 {
GestureDirection::Right
} else {
GestureDirection::Left
})
} else {
if abs_x > cross_limit {
return None;
}
Some(if dy > 0 {
GestureDirection::Down
} else {
GestureDirection::Up
})
}
}
#[derive(Debug, Default)]
pub struct SwipeAccumulator {
held_since: Option<Instant>,
dx: i32,
dy: i32,
fired: bool,
}
impl SwipeAccumulator {
pub fn begin(&mut self) {
self.held_since = Some(Instant::now());
self.dx = 0;
self.dy = 0;
self.fired = false;
}
#[must_use]
pub fn is_holding(&self) -> bool {
self.held_since.is_some()
}
pub fn accumulate(&mut self, dx: i32, dy: i32) -> Option<GestureDirection> {
if self.fired || self.held_since.is_none() {
return None;
}
self.dx = self.dx.saturating_add(dx);
self.dy = self.dy.saturating_add(dy);
let held_long_enough = self
.held_since
.is_some_and(|t| t.elapsed() >= GESTURE_HOLD_FOR_SWIPE);
if held_long_enough && let Some(dir) = detect_swipe(self.dx, self.dy) {
self.fired = true;
return Some(dir);
}
None
}
pub fn end(&mut self) -> bool {
let was_click = self.held_since.is_some() && !self.fired;
self.held_since = None;
was_click
}
#[doc(hidden)]
pub fn backdate_hold_for_test(&mut self) {
if self.held_since.is_some() {
self.held_since = Instant::now().checked_sub(GESTURE_HOLD_FOR_SWIPE * 2);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_swipe_below_threshold_keeps_accumulating() {
assert_eq!(detect_swipe(40, 5), None);
assert_eq!(detect_swipe(0, 0), None);
}
#[test]
fn detect_swipe_commits_clean_direction() {
assert_eq!(detect_swipe(120, 5), Some(GestureDirection::Right));
assert_eq!(detect_swipe(-120, 5), Some(GestureDirection::Left));
assert_eq!(detect_swipe(5, 120), Some(GestureDirection::Down));
assert_eq!(detect_swipe(5, -120), Some(GestureDirection::Up));
}
#[test]
fn detect_swipe_rejects_diagonal() {
assert_eq!(detect_swipe(60, 60), None);
assert_eq!(detect_swipe(-60, -60), None);
}
#[test]
fn detect_swipe_threshold_and_cross_band_boundaries() {
assert_eq!(
detect_swipe(GESTURE_SWIPE_THRESHOLD, 0),
Some(GestureDirection::Right)
);
assert_eq!(detect_swipe(GESTURE_SWIPE_THRESHOLD - 1, 0), None);
assert_eq!(detect_swipe(200, 69), Some(GestureDirection::Right));
assert_eq!(detect_swipe(200, 71), None);
assert_eq!(detect_swipe(100, 39), Some(GestureDirection::Right));
assert_eq!(detect_swipe(100, 41), None);
}
#[test]
fn detect_swipe_does_not_panic_on_extreme_values() {
assert_eq!(detect_swipe(i32::MAX, 0), Some(GestureDirection::Right));
assert_eq!(detect_swipe(i32::MIN, 0), Some(GestureDirection::Left));
assert_eq!(detect_swipe(0, i32::MAX), Some(GestureDirection::Down));
assert_eq!(detect_swipe(0, i32::MIN), Some(GestureDirection::Up));
assert_eq!(detect_swipe(i32::MIN, i32::MIN), None);
}
#[test]
fn accumulator_commits_a_direction_once_after_the_hold_gate() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(
acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
Some(GestureDirection::Right)
);
assert_eq!(acc.accumulate(50, 0), None);
}
#[test]
fn accumulator_does_not_commit_before_the_hold_gate() {
let mut acc = SwipeAccumulator::default();
acc.begin(); assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
acc.backdate_hold_for_test();
assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0).is_some());
}
#[test]
fn accumulator_end_reports_click_only_when_no_swipe_fired() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(acc.accumulate(2, -1), None);
assert!(acc.end(), "a hold that never swiped is a click");
acc.begin();
acc.backdate_hold_for_test();
assert!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0).is_some());
assert!(!acc.end(), "a committed swipe must not also click");
}
#[test]
fn accumulator_ignores_motion_when_not_holding() {
let mut acc = SwipeAccumulator::default();
assert!(!acc.is_holding());
assert_eq!(acc.accumulate(GESTURE_SWIPE_THRESHOLD + 100, 0), None);
}
#[test]
fn accumulator_sums_sub_threshold_deltas_until_they_commit() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
let step = GESTURE_SWIPE_THRESHOLD / 2 - 1;
assert_eq!(acc.accumulate(step, 0), None, "one step is sub-threshold");
assert_eq!(acc.accumulate(step, 0), None, "two steps still under");
assert_eq!(
acc.accumulate(step, 0),
Some(GestureDirection::Right),
"the running sum finally crosses the threshold"
);
}
#[test]
fn accumulator_saturates_instead_of_overflowing() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(
acc.accumulate(i32::MAX, i32::MAX),
None,
"a diagonal never commits"
);
assert_eq!(
acc.accumulate(i32::MAX, i32::MAX),
None,
"the saturating sum must not panic"
);
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(acc.accumulate(i32::MAX, 0), Some(GestureDirection::Right));
}
#[test]
fn accumulator_begin_recovers_a_stale_hold() {
let mut acc = SwipeAccumulator::default();
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(
acc.accumulate(-(GESTURE_SWIPE_THRESHOLD + 10), 0),
Some(GestureDirection::Left)
);
acc.begin();
acc.backdate_hold_for_test();
assert_eq!(
acc.accumulate(GESTURE_SWIPE_THRESHOLD + 10, 0),
Some(GestureDirection::Right)
);
}
#[test]
fn accumulator_end_without_a_hold_is_not_a_click() {
let mut acc = SwipeAccumulator::default();
assert!(!acc.end(), "a release with no hold is not a click");
acc.begin();
assert!(acc.end(), "the held release is a click");
assert!(!acc.end(), "the redundant second release is not a click");
}
}