use crate::render::motion::MotionProfile;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum PointerKind {
Mouse,
Touch,
}
pub fn pointer_kind_from_ua(ua: &str) -> PointerKind {
let ua = ua.to_ascii_lowercase();
if ua.contains("iphone")
|| ua.contains("ipad")
|| ua.contains("android") && ua.contains("mobile")
|| ua.contains("mobile safari")
{
return PointerKind::Touch;
}
PointerKind::Mouse
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub enum TouchPhase {
Start,
Move,
End,
Cancel,
}
#[derive(Debug, Clone, Copy, PartialEq)]
pub struct TouchFrame {
pub phase: TouchPhase,
pub x: f64,
pub y: f64,
pub delay_ms: u64,
pub radius_px: f64,
pub force: f64,
}
#[derive(Debug, Clone, Copy)]
pub struct TouchParams {
pub tap_hold_ms_min: u64,
pub tap_hold_ms_max: u64,
pub move_step_ms_min: u64,
pub move_step_ms_max: u64,
pub radius_px: f64,
pub force: f64,
}
impl TouchParams {
pub fn for_profile(p: MotionProfile) -> Self {
match p {
MotionProfile::Fast => TouchParams {
tap_hold_ms_min: 10,
tap_hold_ms_max: 25,
move_step_ms_min: 5,
move_step_ms_max: 15,
radius_px: 10.0,
force: 0.5,
},
MotionProfile::Balanced => TouchParams {
tap_hold_ms_min: 60,
tap_hold_ms_max: 180,
move_step_ms_min: 12,
move_step_ms_max: 28,
radius_px: 11.0,
force: 0.5,
},
MotionProfile::Human => TouchParams {
tap_hold_ms_min: 80,
tap_hold_ms_max: 240,
move_step_ms_min: 14,
move_step_ms_max: 36,
radius_px: 12.0,
force: 0.55,
},
MotionProfile::Paranoid => TouchParams {
tap_hold_ms_min: 140,
tap_hold_ms_max: 400,
move_step_ms_min: 18,
move_step_ms_max: 48,
radius_px: 13.0,
force: 0.6,
},
}
}
}
pub fn sequence_from_trajectory(
points: &[(f64, f64, u64)],
params: &TouchParams,
tap_hold_ms: u64,
) -> Vec<TouchFrame> {
if points.is_empty() {
return Vec::new();
}
let mut out = Vec::with_capacity(points.len() + 1);
let (x0, y0, _) = points[0];
out.push(TouchFrame {
phase: TouchPhase::Start,
x: x0,
y: y0,
delay_ms: 0,
radius_px: params.radius_px,
force: params.force,
});
for (x, y, d) in points.iter().skip(1) {
out.push(TouchFrame {
phase: TouchPhase::Move,
x: *x,
y: *y,
delay_ms: *d,
radius_px: params.radius_px,
force: params.force,
});
}
let (xn, yn, _) = *points.last().unwrap();
out.push(TouchFrame {
phase: TouchPhase::End,
x: xn,
y: yn,
delay_ms: tap_hold_ms,
radius_px: params.radius_px,
force: 0.0,
});
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn detect_mobile_ua() {
let ios = "Mozilla/5.0 (iPhone; CPU iPhone OS 17_0 like Mac OS X) Mobile/15E148";
assert_eq!(pointer_kind_from_ua(ios), PointerKind::Touch);
let android =
"Mozilla/5.0 (Linux; Android 13; Pixel 7) AppleWebKit/537.36 Mobile Safari/537.36";
assert_eq!(pointer_kind_from_ua(android), PointerKind::Touch);
}
#[test]
fn detect_desktop_ua() {
let desktop = "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36";
assert_eq!(pointer_kind_from_ua(desktop), PointerKind::Mouse);
}
#[test]
fn sequence_has_start_move_end() {
let p = TouchParams::for_profile(MotionProfile::Balanced);
let pts = vec![(0.0, 0.0, 0), (5.0, 5.0, 10), (10.0, 10.0, 10)];
let seq = sequence_from_trajectory(&pts, &p, 100);
assert_eq!(seq.first().unwrap().phase, TouchPhase::Start);
assert_eq!(seq.last().unwrap().phase, TouchPhase::End);
assert!(seq.iter().any(|f| f.phase == TouchPhase::Move));
}
#[test]
fn empty_trajectory_empty_sequence() {
let p = TouchParams::for_profile(MotionProfile::Balanced);
assert!(sequence_from_trajectory(&[], &p, 0).is_empty());
}
#[test]
fn radius_matches_params() {
let p = TouchParams::for_profile(MotionProfile::Human);
let pts = vec![(0.0, 0.0, 0), (1.0, 1.0, 5)];
let seq = sequence_from_trajectory(&pts, &p, 80);
for f in seq.iter().take_while(|f| f.phase != TouchPhase::End) {
assert!((f.radius_px - p.radius_px).abs() < 1e-9);
}
}
}