use teksilo_canvas::{Point, Vec2};
use teksilo_tokens::GestureProfile;
use crate::kinetic::VelocityTracker;
use crate::pointer::EventTime;
use crate::pointer::touch_action::{Axis, PanClaim};
use super::{GestureRecognizer, GestureResult, RawPointerEvent, RecognizerContext};
#[derive(Debug)]
pub struct PanRecognizer {
claim: PanClaim,
slop: Option<f32>,
tracker: VelocityTracker,
origin: Option<Point>,
last: Option<Point>,
armed: bool,
}
impl PanRecognizer {
pub fn new(claim: PanClaim) -> Self {
Self {
claim,
slop: None,
tracker: VelocityTracker::new(),
origin: None,
last: None,
armed: false,
}
}
pub fn with_slop(claim: PanClaim, slop: f32) -> Self {
Self {
slop: Some(slop),
..Self::new(claim)
}
}
pub fn claim(&self) -> PanClaim {
self.claim
}
pub fn slop(&self, profile: &GestureProfile) -> Option<f32> {
self.slop.or(profile.pan_slop)
}
pub fn press(&mut self, position: Point, time: EventTime) {
self.tracker.clear();
self.tracker.add(time, position);
self.origin = Some(position);
self.last = Some(position);
self.armed = false;
}
pub fn feed(&mut self, position: Point, time: EventTime) -> Vec2 {
self.tracker.add(time, position);
let previous = self.last.replace(position).unwrap_or(position);
self.on_axes(Vec2::new(position.x - previous.x, position.y - previous.y))
}
pub fn feed_coalesced(
&mut self,
history: &[crate::pointer::CoalescedSample],
position: Point,
time: EventTime,
) -> Vec2 {
self.tracker
.add_coalesced(history.iter().map(|s| (s.time, s.window_position)));
self.feed(position, time)
}
pub fn past_slop(&mut self, profile: &GestureProfile) -> Option<Axis> {
let (Some(origin), Some(last), Some(slop)) = (self.origin, self.last, self.slop(profile))
else {
return None;
};
let dx = (last.x - origin.x).abs();
let dy = (last.y - origin.y).abs();
let axis = if dx >= dy {
[(Axis::X, dx), (Axis::Y, dy)]
} else {
[(Axis::Y, dy), (Axis::X, dx)]
}
.into_iter()
.find(|&(axis, travel)| self.claim.axes.contains(axis) && travel >= slop)
.map(|(axis, _)| axis);
if axis.is_some() {
self.armed = true;
}
axis
}
pub fn is_armed(&self) -> bool {
self.armed
}
pub fn velocity(&self, profile: &GestureProfile) -> Vec2 {
let Some(estimate) = self.tracker.estimate() else {
return Vec2::ZERO;
};
let max = profile.max_fling_velocity;
let v = self.on_axes(estimate.pixels_per_second);
Vec2::new(v.x.clamp(-max, max), v.y.clamp(-max, max))
}
pub fn should_fling(&self, velocity: Vec2, profile: &GestureProfile) -> bool {
self.claim.kinetic
&& (velocity.x.abs() >= profile.min_fling_velocity
|| velocity.y.abs() >= profile.min_fling_velocity)
}
fn on_axes(&self, v: Vec2) -> Vec2 {
Vec2::new(
if self.claim.axes.contains(Axis::X) {
v.x
} else {
0.0
},
if self.claim.axes.contains(Axis::Y) {
v.y
} else {
0.0
},
)
}
}
impl GestureRecognizer for PanRecognizer {
fn process(&mut self, event: &RawPointerEvent, cx: &RecognizerContext) -> GestureResult {
match event {
RawPointerEvent::Down { position, time, .. } => {
self.press(*position, *time);
GestureResult::Pending
}
RawPointerEvent::Move { position, time, .. } => {
self.feed(*position, *time);
self.past_slop(&cx.profile);
GestureResult::Pending
}
RawPointerEvent::Up { .. } | RawPointerEvent::Cancel { .. } => GestureResult::Failed,
}
}
fn reset(&mut self) {
self.tracker.clear();
self.origin = None;
self.last = None;
self.armed = false;
}
fn priority(&self) -> u32 {
0
}
fn competes_for_sequence(&self) -> bool {
true
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::gesture::default_profile;
use teksilo_tokens::PointerKind;
fn touch() -> GestureProfile {
default_profile(PointerKind::Touch)
}
fn at(ms: u64) -> EventTime {
EventTime::from_millis(ms)
}
#[test]
fn a_mouse_never_arms_a_pan_because_it_has_no_pan_slop() {
let mouse = default_profile(PointerKind::Mouse);
assert!(
mouse.pan_slop.is_none(),
"the mouse profile has no pan slop"
);
let mut pan = PanRecognizer::new(PanClaim::both());
pan.press(Point::new(0.0, 0.0), at(0));
pan.feed(Point::new(500.0, 500.0), at(16));
assert_eq!(pan.past_slop(&mouse), None);
}
#[test]
fn the_claim_zeroes_the_axis_it_does_not_name() {
let mut pan = PanRecognizer::new(PanClaim::vertical());
pan.press(Point::new(0.0, 0.0), at(0));
let delta = pan.feed(Point::new(30.0, 40.0), at(16));
assert_eq!(delta.x, 0.0, "a vertical claim never reports x movement");
assert_eq!(delta.y, 40.0);
}
#[test]
fn slop_is_crossed_on_the_dominant_claimed_axis() {
let profile = touch();
let slop = profile.pan_slop.expect("touch pans");
let mut pan = PanRecognizer::new(PanClaim::both());
pan.press(Point::new(0.0, 0.0), at(0));
pan.feed(Point::new(slop - 1.0, 0.0), at(8));
assert_eq!(
pan.past_slop(&profile),
None,
"still inside the slop radius"
);
assert!(!pan.is_armed());
pan.feed(Point::new(slop + 1.0, 2.0), at(16));
assert_eq!(pan.past_slop(&profile), Some(Axis::X));
assert!(pan.is_armed());
}
#[test]
fn a_claim_that_forbids_the_travelled_axis_never_arms() {
let profile = touch();
let slop = profile.pan_slop.expect("touch pans");
let mut pan = PanRecognizer::new(PanClaim::vertical());
pan.press(Point::new(0.0, 0.0), at(0));
pan.feed(Point::new(slop * 4.0, 0.0), at(16));
assert_eq!(
pan.past_slop(&profile),
None,
"a vertical claim is not armed by horizontal travel"
);
}
#[test]
fn once_armed_it_stays_armed_even_if_the_finger_comes_back() {
let profile = touch();
let slop = profile.pan_slop.expect("touch pans");
let mut pan = PanRecognizer::new(PanClaim::vertical());
pan.press(Point::new(0.0, 0.0), at(0));
pan.feed(Point::new(0.0, slop + 5.0), at(16));
assert!(pan.past_slop(&profile).is_some());
pan.feed(Point::new(0.0, 0.0), at(32));
assert!(
pan.is_armed(),
"the claim is taken at the crossing and not given back"
);
}
#[test]
fn coalesced_samples_recover_a_flick_the_packet_rate_would_lose() {
let profile = touch();
let claim = PanClaim {
kinetic: true,
..PanClaim::vertical()
};
let all: Vec<crate::pointer::CoalescedSample> = (0..=16)
.step_by(2)
.map(|ms| {
crate::pointer::CoalescedSample::new(at(ms), Point::new(0.0, ms as f32 / 2.0))
})
.collect();
let last = *all.last().expect("non-empty");
let (time, position) = (last.time, last.window_position);
let history = &all[1..all.len() - 1];
let mut coalesced = PanRecognizer::new(claim);
coalesced.press(all[0].window_position, all[0].time);
coalesced.feed_coalesced(history, position, time);
let mut decimated = PanRecognizer::new(claim);
decimated.press(all[0].window_position, all[0].time);
decimated.feed(position, time);
let full = coalesced.velocity(&profile);
assert!(
(full.y - 500.0).abs() < 25.0,
"the coalesced fit recovers the real 500 dp/s, got {}",
full.y
);
assert!(coalesced.should_fling(full, &profile));
let thin = decimated.velocity(&profile);
assert_eq!(
thin,
Vec2::ZERO,
"two samples are under the minimum fit, so the flick reads as nothing"
);
assert!(!decimated.should_fling(thin, &profile));
}
#[test]
fn velocity_is_clamped_and_gated_by_the_profile() {
let profile = touch();
let claim = PanClaim {
kinetic: true,
..PanClaim::vertical()
};
let mut pan = PanRecognizer::new(claim);
pan.press(Point::new(0.0, 0.0), at(0));
pan.feed(Point::new(0.0, 500.0), at(1));
pan.feed(Point::new(0.0, 1000.0), at(2));
pan.feed(Point::new(0.0, 1500.0), at(3));
let v = pan.velocity(&profile);
assert!(
v.y.abs() <= profile.max_fling_velocity,
"clamped to the profile ceiling"
);
assert!(pan.should_fling(v, &profile));
assert!(
!pan.should_fling(Vec2::new(0.0, 1.0), &profile),
"a crawl is under `min_fling_velocity`"
);
}
#[test]
fn a_non_kinetic_claim_never_flings() {
let profile = touch();
let pan = PanRecognizer::new(PanClaim::vertical());
assert!(!pan.claim().kinetic);
assert!(!pan.should_fling(Vec2::new(0.0, 5000.0), &profile));
}
#[test]
fn the_recognizer_declares_itself_a_sequence_competitor() {
let pan = PanRecognizer::new(PanClaim::both());
assert!(pan.competes_for_sequence());
assert!(
!pan.wants_all_pointers(),
"a pan follows one contact; the pinch is the multi-contact one"
);
}
#[test]
fn process_feeds_the_tracker_and_fails_on_release() {
let profile = touch();
let mut pan = PanRecognizer::new(PanClaim::vertical());
let pointer = crate::pointer::PointerInfo::touch(crate::pointer::PointerId::MOUSE, at(0));
let cx = RecognizerContext::new(at(0), profile, teksilo_canvas::Rect::ZERO, pointer);
assert!(matches!(
pan.process(
&RawPointerEvent::Down {
position: Point::ZERO,
button: crate::event::PointerButton::Primary,
modifiers: crate::event::Modifiers::NONE,
pointer,
time: at(0),
},
&cx
),
GestureResult::Pending
));
let slop = profile.pan_slop.expect("touch pans");
assert!(matches!(
pan.process(
&RawPointerEvent::Move {
position: Point::new(0.0, slop + 5.0),
pointer,
time: at(16),
},
&cx
),
GestureResult::Pending
));
assert!(pan.is_armed(), "the move armed it through the trait path");
assert!(matches!(
pan.process(
&RawPointerEvent::Up {
position: Point::new(0.0, slop + 5.0),
button: crate::event::PointerButton::Primary,
modifiers: crate::event::Modifiers::NONE,
pointer,
time: at(32),
},
&cx
),
GestureResult::Failed
));
}
#[test]
fn reset_forgets_the_press() {
let profile = touch();
let mut pan = PanRecognizer::new(PanClaim::vertical());
pan.press(Point::ZERO, at(0));
pan.feed(Point::new(0.0, 200.0), at(16));
assert!(pan.past_slop(&profile).is_some());
pan.reset();
assert!(!pan.is_armed());
assert_eq!(pan.past_slop(&profile), None);
assert_eq!(pan.velocity(&profile), Vec2::ZERO);
}
}