pub const MOVE_DELTA_PX: i32 = 2;
pub const TAP_DUR_MS: i64 = 400;
pub const TAP_DISPLACEMENT_PX: f64 = 12.0;
pub const SWIPE_DUR_MS: i64 = 250;
pub const SWIPE_DISPLACEMENT_PX: f64 = 24.0;
pub const MAX_STROKE_SAMPLES: usize = 512;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum GestureKind {
Tap,
LongPress,
Swipe,
Drag,
}
impl GestureKind {
pub fn as_str(self) -> &'static str {
match self {
GestureKind::Tap => "tap",
GestureKind::LongPress => "long_press",
GestureKind::Swipe => "swipe",
GestureKind::Drag => "drag",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Direction {
Up,
Down,
Left,
Right,
}
impl Direction {
pub fn as_str(self) -> &'static str {
match self {
Direction::Up => "up",
Direction::Down => "down",
Direction::Left => "left",
Direction::Right => "right",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct TouchMove {
pub x_lcd: i32,
pub y_lcd: i32,
pub timestamp_ms: i64,
}
#[derive(Debug, Clone, PartialEq)]
pub struct TouchGesture {
pub kind: GestureKind,
pub start_x: i32,
pub start_y: i32,
pub end_x: i32,
pub end_y: i32,
pub start_t_ms: i64,
pub end_t_ms: i64,
pub duration_ms: i64,
pub direction: Option<Direction>,
pub velocity_px_per_s: f64,
pub samples: Vec<TouchMove>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum StrokeStep {
Move(TouchMove),
Gesture(TouchGesture),
}
#[derive(Debug, Default)]
pub struct StrokeFsm {
down_at_ms: i64,
down_x_lcd: i32,
down_y_lcd: i32,
last_x_lcd: i32,
last_y_lcd: i32,
samples: std::collections::VecDeque<TouchMove>,
pen_down: bool,
}
impl StrokeFsm {
pub fn new() -> Self {
Self::default()
}
pub fn pen_down(&self) -> bool {
self.pen_down
}
pub fn open_stroke(&mut self, now_ms: i64) {
self.down_at_ms = now_ms;
self.samples.clear();
self.down_x_lcd = 0;
self.down_y_lcd = 0;
self.last_x_lcd = 0;
self.last_y_lcd = 0;
self.pen_down = true;
}
pub fn record_move(&mut self, x_lcd: i32, y_lcd: i32, now_ms: i64) -> Option<TouchMove> {
if self.samples.is_empty() {
self.down_x_lcd = x_lcd;
self.down_y_lcd = y_lcd;
self.last_x_lcd = x_lcd;
self.last_y_lcd = y_lcd;
let m = TouchMove {
x_lcd,
y_lcd,
timestamp_ms: now_ms,
};
self.samples.push_back(m);
return Some(m);
}
let dx = (x_lcd - self.last_x_lcd).abs();
let dy = (y_lcd - self.last_y_lcd).abs();
if dx < MOVE_DELTA_PX && dy < MOVE_DELTA_PX {
return None;
}
self.last_x_lcd = x_lcd;
self.last_y_lcd = y_lcd;
let m = TouchMove {
x_lcd,
y_lcd,
timestamp_ms: now_ms,
};
if self.samples.len() >= MAX_STROKE_SAMPLES {
self.samples.remove(1);
}
self.samples.push_back(m);
Some(m)
}
pub fn close_stroke(&mut self, now_ms: i64) -> Option<TouchGesture> {
self.pen_down = false;
let last = self.samples.back().copied()?;
let end_x_lcd = last.x_lcd;
let end_y_lcd = last.y_lcd;
let duration_ms = (now_ms - self.down_at_ms).max(0);
let dx_total = end_x_lcd - self.down_x_lcd;
let dy_total = end_y_lcd - self.down_y_lcd;
let displacement = ((dx_total * dx_total + dy_total * dy_total) as f64).sqrt();
let velocity = if duration_ms > 0 {
displacement * 1000.0 / duration_ms as f64
} else {
0.0
};
let kind = classify(duration_ms, displacement);
let direction = match kind {
GestureKind::Swipe | GestureKind::Drag => Some(direction_for(dx_total, dy_total)),
_ => None,
};
let gesture = TouchGesture {
kind,
start_x: self.down_x_lcd,
start_y: self.down_y_lcd,
end_x: end_x_lcd,
end_y: end_y_lcd,
start_t_ms: self.down_at_ms,
end_t_ms: now_ms,
duration_ms,
direction,
velocity_px_per_s: velocity,
samples: Vec::from(std::mem::take(&mut self.samples)),
};
Some(gesture)
}
}
pub fn classify(duration_ms: i64, displacement: f64) -> GestureKind {
if displacement < TAP_DISPLACEMENT_PX {
return if duration_ms >= TAP_DUR_MS {
GestureKind::LongPress
} else {
GestureKind::Tap
};
}
if duration_ms < SWIPE_DUR_MS && displacement >= SWIPE_DISPLACEMENT_PX {
return GestureKind::Swipe;
}
GestureKind::Drag
}
pub fn direction_for(dx: i32, dy: i32) -> Direction {
if dx.abs() >= dy.abs() {
if dx >= 0 {
Direction::Right
} else {
Direction::Left
}
} else if dy >= 0 {
Direction::Down
} else {
Direction::Up
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::affine::Affine;
fn run_stroke(
open_ms: i64,
samples: &[(i32, i32, i64)],
close_ms: i64,
) -> (TouchGesture, usize) {
let mut fsm = StrokeFsm::new();
fsm.open_stroke(open_ms);
let mut moves = 0;
for &(x, y, t) in samples {
if fsm.record_move(x, y, t).is_some() {
moves += 1;
}
}
let g = fsm.close_stroke(close_ms).expect("a gesture");
(g, moves)
}
#[test]
fn tap_is_short_and_still() {
let (g, moves) = run_stroke(0, &[(100, 100, 0)], 100);
assert_eq!(g.kind, GestureKind::Tap);
assert!(g.direction.is_none());
assert_eq!(moves, 1);
assert_eq!((g.start_x, g.start_y), (100, 100));
assert_eq!((g.end_x, g.end_y), (100, 100));
}
#[test]
fn long_press_is_long_and_still() {
let (g, _) = run_stroke(0, &[(100, 100, 0)], 400);
assert_eq!(g.kind, GestureKind::LongPress);
assert!(g.direction.is_none());
}
#[test]
fn just_under_long_threshold_is_tap() {
let (g, _) = run_stroke(0, &[(50, 50, 0)], 399);
assert_eq!(g.kind, GestureKind::Tap);
}
#[test]
fn small_displacement_under_floor_stays_tap() {
let (g, _) = run_stroke(0, &[(0, 0, 0), (8, 0, 50)], 100);
assert_eq!(g.kind, GestureKind::Tap);
}
#[test]
fn fast_long_move_is_a_swipe_right() {
let (g, _) = run_stroke(0, &[(0, 0, 0), (40, 0, 100)], 200);
assert_eq!(g.kind, GestureKind::Swipe);
assert_eq!(g.direction, Some(Direction::Right));
assert!(g.velocity_px_per_s > 0.0);
}
#[test]
fn swipe_up_when_vertical_component_larger_and_negative() {
let (g, _) = run_stroke(0, &[(0, 0, 0), (10, -50, 100)], 200);
assert_eq!(g.kind, GestureKind::Swipe);
assert_eq!(g.direction, Some(Direction::Up));
}
#[test]
fn slow_long_move_is_a_drag() {
let (g, _) = run_stroke(0, &[(0, 0, 0), (40, 0, 150)], 300);
assert_eq!(g.kind, GestureKind::Drag);
assert_eq!(g.direction, Some(Direction::Right));
}
#[test]
fn fast_short_move_above_tap_floor_below_swipe_floor_is_a_drag() {
let (g, _) = run_stroke(0, &[(0, 0, 0), (16, 0, 50)], 100);
assert_eq!(g.kind, GestureKind::Drag);
}
#[test]
fn move_delta_filter_drops_sub_threshold_samples() {
let mut fsm = StrokeFsm::new();
fsm.open_stroke(0);
assert!(fsm.record_move(100, 100, 0).is_some());
assert!(fsm.record_move(101, 101, 10).is_none());
assert!(fsm.record_move(102, 100, 20).is_some());
}
#[test]
fn close_without_any_sample_emits_nothing() {
let mut fsm = StrokeFsm::new();
fsm.open_stroke(0);
assert!(fsm.close_stroke(100).is_none());
assert!(!fsm.pen_down());
}
#[test]
fn samples_are_carried_on_the_gesture_and_fsm_resets() {
let mut fsm = StrokeFsm::new();
fsm.open_stroke(0);
fsm.record_move(0, 0, 0);
fsm.record_move(40, 0, 100);
let g = fsm.close_stroke(200).unwrap();
assert_eq!(g.samples.len(), 2);
fsm.open_stroke(1000);
fsm.record_move(5, 5, 1000);
let g2 = fsm.close_stroke(1100).unwrap();
assert_eq!(g2.samples.len(), 1);
}
#[test]
fn a_long_held_stroke_keeps_its_origin_and_recent_path_within_the_cap() {
let mut fsm = StrokeFsm::new();
fsm.open_stroke(0);
let total = MAX_STROKE_SAMPLES as i32 * 3;
for i in 0..total {
let x = (i % 2) * MOVE_DELTA_PX * 2;
assert!(fsm.record_move(x, i, i64::from(i)).is_some());
}
let g = fsm.close_stroke(i64::from(total)).unwrap();
assert_eq!(g.samples.len(), MAX_STROKE_SAMPLES);
assert_eq!(g.samples[0].timestamp_ms, 0, "origin kept");
assert_eq!(
g.samples.last().unwrap().timestamp_ms,
i64::from(total - 1),
"latest kept"
);
assert_eq!(
g.samples[1].timestamp_ms,
i64::from(total) - MAX_STROKE_SAMPLES as i64 + 1,
"the oldest samples after the origin were the ones dropped"
);
assert_eq!((g.start_x, g.start_y), (0, 0));
}
#[test]
fn direction_ties_favour_horizontal() {
assert_eq!(direction_for(10, 10), Direction::Right);
assert_eq!(direction_for(-10, 10), Direction::Left);
assert_eq!(direction_for(0, 5), Direction::Down);
assert_eq!(direction_for(0, -5), Direction::Up);
}
#[test]
fn classify_thresholds_at_the_boundaries() {
assert_eq!(classify(100, 12.0), GestureKind::Drag);
assert_eq!(classify(100, 11.9), GestureKind::Tap);
assert_eq!(classify(400, 0.0), GestureKind::LongPress);
assert_eq!(classify(249, 24.0), GestureKind::Swipe);
assert_eq!(classify(250, 24.0), GestureKind::Drag);
}
#[test]
fn affine_transform_feeds_the_fsm() {
let m = Affine {
a: 2.0,
b: 0.0,
c: 0.0,
d: 0.0,
e: 2.0,
f: 0.0,
};
let mut fsm = StrokeFsm::new();
fsm.open_stroke(0);
let (x0, y0) = m.apply(0, 0);
let (x1, y1) = m.apply(20, 0); fsm.record_move(x0, y0, 0);
fsm.record_move(x1, y1, 100);
let g = fsm.close_stroke(200).unwrap();
assert_eq!(g.end_x, 40);
assert_eq!(g.kind, GestureKind::Swipe);
}
#[test]
fn gesture_and_direction_wire_strings() {
assert_eq!(GestureKind::Tap.as_str(), "tap");
assert_eq!(GestureKind::LongPress.as_str(), "long_press");
assert_eq!(GestureKind::Swipe.as_str(), "swipe");
assert_eq!(GestureKind::Drag.as_str(), "drag");
assert_eq!(Direction::Up.as_str(), "up");
assert_eq!(Direction::Down.as_str(), "down");
assert_eq!(Direction::Left.as_str(), "left");
assert_eq!(Direction::Right.as_str(), "right");
}
}