ec9a9a8892
Every swipe on the rack display classified as up-to-down. The first one took the display from the top row to the bottom row, and from there "down" is a no-op, so it was stuck permanently -- looking like dead gestures while the process was alive and the panel still grabbed. X and Y arrive as SEPARATE events, so a new contact's opening frame is ABS_MT_POSITION_X then ABS_MT_POSITION_Y. Treating (0, 0) as "start not yet known" meant the start was captured on the X event alone, recording a Y of zero. Every later comparison then measured from the top edge of the panel rather than from the finger: dy became the absolute Y coordinate, dwarfed dx, and the gesture came out vertical. A swipe at y=360 on a 720-tall panel reported 360 pixels of downward travel that never happened. The start position is now Option per axis. An axis that never reports contributes no displacement, so there is no sentinel to collide with a real coordinate. The slot bookkeeping moves into a SlotTracker fed by a small Touched enum rather than evdev's types, because it was untestable before and that is exactly where the bug lived -- the existing classifier tests built Track tuples by hand and skipped the decoding entirely. Six new tests drive realistic protocol-B streams: the regression itself, per-axis start capture, all four directions end to end, ghost contacts decoding as one swipe of the correct length, completion only once every contact lifts, and reset between gestures. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
651 lines
22 KiB
Rust
651 lines
22 KiB
Rust
//! Touch gestures straight from the kernel, replacing lisgd.
|
|
//!
|
|
//! libinput deliberately emits gesture events only for touchpads, never for
|
|
//! touchscreens, so libinput-gestures and friends cannot work here at all. We
|
|
//! read multitouch protocol B from the event device and synthesise swipes.
|
|
//!
|
|
//! Protocol B reports each contact in a numbered slot: `ABS_MT_SLOT` selects the
|
|
//! slot, `ABS_MT_TRACKING_ID` of -1 lifts it, and `ABS_MT_POSITION_X/Y` update
|
|
//! it. We record where each slot started and where it ended, then decide on the
|
|
//! release of the last contact.
|
|
|
|
use std::collections::HashMap;
|
|
use std::path::Path;
|
|
|
|
use anyhow::{bail, Context, Result};
|
|
use evdev::{AbsoluteAxisType, Device, InputEventKind};
|
|
|
|
use crate::config::{Direction, Touch};
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub struct Swipe {
|
|
pub direction: Direction,
|
|
/// Peak simultaneous contacts during the gesture.
|
|
pub fingers: usize,
|
|
}
|
|
|
|
/// One contact's journey.
|
|
///
|
|
/// The start position is per axis and `Option`, NOT a `(0, 0)` sentinel. X and Y
|
|
/// arrive as SEPARATE events, so a contact's opening frame is `POSITION_X` then
|
|
/// `POSITION_Y`: capturing "the start" on the first of those records a Y of
|
|
/// zero, and every later comparison then measures from the top edge of the panel
|
|
/// rather than from the finger. That made `dy` enormous and positive, so every
|
|
/// swipe -- horizontal ones included -- classified as up-to-down.
|
|
#[derive(Debug, Default, Clone, Copy)]
|
|
struct Slot {
|
|
start_x: Option<i32>,
|
|
start_y: Option<i32>,
|
|
last: (i32, i32),
|
|
active: bool,
|
|
}
|
|
|
|
impl Slot {
|
|
/// An axis that never reported a position contributes no displacement.
|
|
fn track(&self) -> Track {
|
|
(
|
|
(
|
|
self.start_x.unwrap_or(self.last.0),
|
|
self.start_y.unwrap_or(self.last.1),
|
|
),
|
|
self.last,
|
|
)
|
|
}
|
|
}
|
|
|
|
/// The parts of a multitouch protocol-B stream we care about. Kept separate from
|
|
/// evdev's own types so the slot bookkeeping is testable without a device --
|
|
/// which is precisely the code the above bug lived in, untested.
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum Touched {
|
|
/// `ABS_MT_SLOT`: subsequent events apply to this slot.
|
|
Slot(i32),
|
|
/// `ABS_MT_TRACKING_ID`: a new contact when >= 0, a lift when -1.
|
|
TrackingId(i32),
|
|
X(i32),
|
|
Y(i32),
|
|
}
|
|
|
|
/// Slot bookkeeping for multitouch protocol B.
|
|
#[derive(Debug, Default)]
|
|
pub struct SlotTracker {
|
|
slots: HashMap<i32, Slot>,
|
|
current: i32,
|
|
peak: usize,
|
|
}
|
|
|
|
impl SlotTracker {
|
|
pub fn feed(&mut self, ev: Touched) {
|
|
match ev {
|
|
Touched::Slot(n) => self.current = n,
|
|
Touched::TrackingId(id) => {
|
|
if id < 0 {
|
|
if let Some(s) = self.slots.get_mut(&self.current) {
|
|
s.active = false;
|
|
}
|
|
} else {
|
|
self.slots.insert(
|
|
self.current,
|
|
Slot {
|
|
active: true,
|
|
..Slot::default()
|
|
},
|
|
);
|
|
self.peak = self.peak.max(self.active());
|
|
}
|
|
}
|
|
Touched::X(x) => {
|
|
let s = self.slot();
|
|
s.last.0 = x;
|
|
s.start_x.get_or_insert(x);
|
|
}
|
|
Touched::Y(y) => {
|
|
let s = self.slot();
|
|
s.last.1 = y;
|
|
s.start_y.get_or_insert(y);
|
|
}
|
|
}
|
|
}
|
|
|
|
fn slot(&mut self) -> &mut Slot {
|
|
self.slots.entry(self.current).or_insert(Slot {
|
|
active: true,
|
|
..Slot::default()
|
|
})
|
|
}
|
|
|
|
fn active(&self) -> usize {
|
|
self.slots.values().filter(|s| s.active).count()
|
|
}
|
|
|
|
/// A gesture is over when every contact seen has lifted.
|
|
pub fn complete(&self) -> bool {
|
|
!self.slots.is_empty() && self.active() == 0
|
|
}
|
|
|
|
pub fn tracks(&self) -> Vec<Track> {
|
|
self.slots.values().map(Slot::track).collect()
|
|
}
|
|
|
|
/// Peak simultaneous contacts, which is what `touch.fingers` matches.
|
|
pub fn peak(&self) -> usize {
|
|
self.peak
|
|
}
|
|
|
|
pub fn reset(&mut self) {
|
|
self.slots.clear();
|
|
self.peak = 0;
|
|
}
|
|
}
|
|
|
|
pub struct Touchpanel {
|
|
device: Device,
|
|
cfg: Touch,
|
|
}
|
|
|
|
/// Why a candidate gesture was not emitted. Only ever shown by `doctor`.
|
|
#[derive(Debug, Clone)]
|
|
pub enum Rejected {
|
|
TooShort { travel: f64, threshold: u32 },
|
|
OffAxis { degrees: f64, leniency: u32 },
|
|
WrongFingerCount { saw: usize, want: Vec<usize> },
|
|
}
|
|
|
|
impl std::fmt::Display for Rejected {
|
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
|
match self {
|
|
Self::TooShort { travel, threshold } => write!(
|
|
f,
|
|
"travelled {travel:.0}px, needs {threshold}px (touch.threshold)"
|
|
),
|
|
Self::OffAxis { degrees, leniency } => write!(
|
|
f,
|
|
"{degrees:.0}\u{b0} off axis, tolerance is {leniency}\u{b0} (touch.leniency)"
|
|
),
|
|
Self::WrongFingerCount { saw, want } => write!(
|
|
f,
|
|
"saw {saw} contact(s), config accepts {want:?} -- add {saw} to touch.fingers"
|
|
),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub enum Event {
|
|
Swipe(Swipe),
|
|
/// A gesture was seen and discarded. Carries the direction it would have
|
|
/// been, so `doctor` can say "that was a left swipe, but ...".
|
|
Discarded(Direction, Rejected),
|
|
}
|
|
|
|
impl Touchpanel {
|
|
pub fn open(cfg: &Touch) -> Result<Self> {
|
|
let path = Path::new(&cfg.device);
|
|
if !path.exists() {
|
|
bail!(
|
|
"{} does not exist.\n\
|
|
Touch devices move around when USB re-enumerates -- always use a \
|
|
/dev/input/by-id/ path, never eventN.\n\
|
|
Run `socktop-swipe doctor --list` to see what is present.",
|
|
cfg.device
|
|
);
|
|
}
|
|
|
|
let mut device = Device::open(path).with_context(|| {
|
|
format!(
|
|
"cannot open {}.\n\
|
|
Reading touch events needs access to the device. Either install the \
|
|
udev rule (packaging/70-socktop-swipe.rules) or add yourself to the \
|
|
'input' group and log out and back in.",
|
|
cfg.device
|
|
)
|
|
})?;
|
|
|
|
let has_mt = device
|
|
.supported_absolute_axes()
|
|
.is_some_and(|a| a.contains(AbsoluteAxisType::ABS_MT_POSITION_X));
|
|
if !has_mt {
|
|
bail!(
|
|
"{} does not report multitouch positions -- it is probably not the \
|
|
touchscreen.\nRun `socktop-swipe doctor --list`.",
|
|
cfg.device
|
|
);
|
|
}
|
|
|
|
if cfg.grab {
|
|
// Take the device exclusively so X never sees the touches. This is
|
|
// what the v1 xorg.conf.d "Ignore" rule was faking, and it needs no
|
|
// X restart or relogin.
|
|
device.grab().with_context(|| {
|
|
format!(
|
|
"cannot take exclusive control of {}.\n\
|
|
Something else may already hold it (another socktop-swipe?). \
|
|
Set touch.grab: false to share the device with X, but then also \
|
|
install the X ignore rule -- see the README.",
|
|
cfg.device
|
|
)
|
|
})?;
|
|
}
|
|
|
|
Ok(Self {
|
|
device,
|
|
cfg: cfg.clone(),
|
|
})
|
|
}
|
|
|
|
/// Blocking gesture loop. Calls `on_event` for every completed gesture,
|
|
/// including rejected ones, and stops when it returns `false`.
|
|
pub fn run(&mut self, mut on_event: impl FnMut(Event) -> bool) -> Result<()> {
|
|
let mut tracker = SlotTracker::default();
|
|
|
|
loop {
|
|
for ev in self.device.fetch_events().context("reading touch events")? {
|
|
let touched = match ev.kind() {
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
|
|
Touched::Slot(ev.value())
|
|
}
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
|
|
Touched::TrackingId(ev.value())
|
|
}
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
|
|
Touched::X(ev.value())
|
|
}
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => {
|
|
Touched::Y(ev.value())
|
|
}
|
|
_ => continue,
|
|
};
|
|
tracker.feed(touched);
|
|
}
|
|
|
|
// The gesture ends when the last contact lifts.
|
|
if tracker.complete() {
|
|
if let Some(ev) = classify(&self.cfg, &tracker.tracks(), tracker.peak()) {
|
|
if !on_event(ev) {
|
|
return Ok(());
|
|
}
|
|
}
|
|
tracker.reset();
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
/// One contact's journey: where it landed and where it lifted.
|
|
pub type Track = ((i32, i32), (i32, i32));
|
|
|
|
/// Decide what a completed gesture was. Split out from the device so the rules
|
|
/// -- threshold, leniency, contact count -- can be tested without hardware.
|
|
pub fn classify(cfg: &Touch, tracks: &[Track], peak: usize) -> Option<Event> {
|
|
// Average the contacts' travel: a "one finger" swipe that the panel reports
|
|
// as two or three contacts is one motion counted repeatedly, so the mean is
|
|
// the real displacement rather than a multiple of it.
|
|
let n = tracks.len() as f64;
|
|
if n == 0.0 {
|
|
return None;
|
|
}
|
|
let (dx, dy) = tracks.iter().fold((0.0, 0.0), |(ax, ay), (start, last)| {
|
|
(
|
|
ax + (last.0 - start.0) as f64 / n,
|
|
ay + (last.1 - start.1) as f64 / n,
|
|
)
|
|
});
|
|
|
|
let travel = (dx * dx + dy * dy).sqrt();
|
|
let horizontal = dx.abs() >= dy.abs();
|
|
let direction = match (horizontal, dx > 0.0, dy > 0.0) {
|
|
(true, true, _) => Direction::LR,
|
|
(true, false, _) => Direction::RL,
|
|
// Y grows downward on a touch panel, so a positive dy is a downward
|
|
// swipe: up-to-down.
|
|
(false, _, true) => Direction::UD,
|
|
(false, _, false) => Direction::DU,
|
|
};
|
|
|
|
if travel < cfg.threshold as f64 {
|
|
return Some(Event::Discarded(
|
|
direction,
|
|
Rejected::TooShort {
|
|
travel,
|
|
threshold: cfg.threshold,
|
|
},
|
|
));
|
|
}
|
|
|
|
// Angle away from the dominant axis.
|
|
let (along, across) = if horizontal {
|
|
(dx.abs(), dy.abs())
|
|
} else {
|
|
(dy.abs(), dx.abs())
|
|
};
|
|
let degrees = across.atan2(along).to_degrees();
|
|
if degrees > cfg.leniency as f64 {
|
|
return Some(Event::Discarded(
|
|
direction,
|
|
Rejected::OffAxis {
|
|
degrees,
|
|
leniency: cfg.leniency,
|
|
},
|
|
));
|
|
}
|
|
|
|
if !cfg.fingers.contains(&peak) {
|
|
return Some(Event::Discarded(
|
|
direction,
|
|
Rejected::WrongFingerCount {
|
|
saw: peak,
|
|
want: cfg.fingers.clone(),
|
|
},
|
|
));
|
|
}
|
|
|
|
Some(Event::Swipe(Swipe {
|
|
direction,
|
|
fingers: peak,
|
|
}))
|
|
}
|
|
|
|
/// Candidate touchscreens, for `doctor --list` and the installer.
|
|
pub fn list_touchscreens() -> Vec<(String, String)> {
|
|
let mut out = Vec::new();
|
|
let by_id = Path::new("/dev/input/by-id");
|
|
let entries = std::fs::read_dir(by_id).into_iter().flatten().flatten();
|
|
for e in entries {
|
|
let path = e.path();
|
|
let Ok(dev) = Device::open(&path) else {
|
|
continue;
|
|
};
|
|
let multitouch = dev
|
|
.supported_absolute_axes()
|
|
.is_some_and(|a| a.contains(AbsoluteAxisType::ABS_MT_POSITION_X));
|
|
if multitouch {
|
|
out.push((
|
|
path.to_string_lossy().into_owned(),
|
|
dev.name().unwrap_or("unnamed device").to_owned(),
|
|
));
|
|
}
|
|
}
|
|
out.sort();
|
|
out
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
|
|
fn touch(threshold: u32, leniency: u32, fingers: Vec<usize>) -> Touch {
|
|
Touch {
|
|
device: "/dev/null".into(),
|
|
width: 1280,
|
|
height: 720,
|
|
grab: false,
|
|
threshold,
|
|
leniency,
|
|
fingers,
|
|
}
|
|
}
|
|
|
|
/// One contact travelling from `from` by `(dx, dy)`.
|
|
fn track(from: (i32, i32), dx: i32, dy: i32) -> Track {
|
|
(from, (from.0 + dx, from.1 + dy))
|
|
}
|
|
|
|
fn swipe(cfg: &Touch, tracks: &[Track], peak: usize) -> Event {
|
|
classify(cfg, tracks, peak).expect("a completed gesture should classify")
|
|
}
|
|
|
|
#[test]
|
|
fn recognises_the_four_directions() {
|
|
let cfg = touch(80, 30, vec![1]);
|
|
let cases = [
|
|
((-200, 0), Direction::RL),
|
|
((200, 0), Direction::LR),
|
|
// Y grows downward, so a negative dy is a swipe upward.
|
|
((0, -200), Direction::DU),
|
|
((0, 200), Direction::UD),
|
|
];
|
|
for ((dx, dy), want) in cases {
|
|
match swipe(&cfg, &[track((640, 360), dx, dy)], 1) {
|
|
Event::Swipe(s) => assert_eq!(s.direction, want, "({dx},{dy})"),
|
|
other => panic!("({dx},{dy}) should be a swipe, got {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ghost_contacts_do_not_multiply_the_travel() {
|
|
// The ILITEK panel reports one physical finger as 2-3 contacts. Each
|
|
// reports the same motion, so the average must equal one finger's
|
|
// travel -- not the sum, which would make short drags look long.
|
|
let cfg = touch(150, 30, vec![1, 2, 3]);
|
|
let one = [track((900, 300), -100, 0)];
|
|
let three = [
|
|
track((900, 300), -100, 0),
|
|
track((902, 305), -100, 0),
|
|
track((898, 295), -100, 0),
|
|
];
|
|
for tracks in [&one[..], &three[..]] {
|
|
match classify(&cfg, tracks, tracks.len()) {
|
|
Some(Event::Discarded(Direction::RL, Rejected::TooShort { travel, .. })) => {
|
|
assert!((travel - 100.0).abs() < 1.0, "travel was {travel}");
|
|
}
|
|
other => panic!("100px under a 150px threshold should be too short: {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn short_drags_are_rejected_with_the_measurement() {
|
|
let cfg = touch(80, 30, vec![1]);
|
|
match swipe(&cfg, &[track((640, 360), -40, 0)], 1) {
|
|
Event::Discarded(Direction::RL, Rejected::TooShort { travel, threshold }) => {
|
|
assert_eq!(threshold, 80);
|
|
assert!((travel - 40.0).abs() < 0.01);
|
|
}
|
|
other => panic!("40px should be too short: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn diagonal_swipes_are_rejected_past_the_leniency() {
|
|
let cfg = touch(80, 30, vec![1]);
|
|
// 45 degrees: equal travel on both axes, well past a 30 degree tolerance.
|
|
match swipe(&cfg, &[track((640, 360), -200, -200)], 1) {
|
|
Event::Discarded(_, Rejected::OffAxis { degrees, leniency }) => {
|
|
assert_eq!(leniency, 30);
|
|
assert!((degrees - 45.0).abs() < 0.01, "was {degrees}");
|
|
}
|
|
other => panic!("a 45 degree drag should be off-axis: {other:?}"),
|
|
}
|
|
// 20 degrees off: within tolerance, still a left swipe.
|
|
let dy = -(200.0 * 20f64.to_radians().tan()) as i32;
|
|
match swipe(&cfg, &[track((640, 360), -200, dy)], 1) {
|
|
Event::Swipe(s) => assert_eq!(s.direction, Direction::RL),
|
|
other => panic!("20 degrees off axis should pass: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn unconfigured_contact_counts_are_rejected_and_name_the_fix() {
|
|
// The v1 failure that cost the most time: gestures detected perfectly,
|
|
// nothing ever fires, because the panel reports 2 contacts and the
|
|
// config accepts only 1.
|
|
let cfg = touch(80, 30, vec![1]);
|
|
match swipe(
|
|
&cfg,
|
|
&[track((900, 300), -200, 0), track((905, 305), -200, 0)],
|
|
2,
|
|
) {
|
|
Event::Discarded(Direction::RL, r @ Rejected::WrongFingerCount { saw, .. }) => {
|
|
assert_eq!(saw, 2);
|
|
assert!(r.to_string().contains("touch.fingers"), "{r}");
|
|
}
|
|
other => panic!("2 contacts against fingers:[1] should be rejected: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn threshold_is_checked_before_the_contact_count() {
|
|
// Otherwise a stray tap on a panel with ghost contacts reports the
|
|
// finger-count problem, sending you to fix the wrong setting.
|
|
let cfg = touch(80, 30, vec![1]);
|
|
match swipe(
|
|
&cfg,
|
|
&[track((640, 360), -5, 0), track((641, 361), -5, 0)],
|
|
2,
|
|
) {
|
|
Event::Discarded(_, Rejected::TooShort { .. }) => {}
|
|
other => panic!("expected the short-travel reason first: {other:?}"),
|
|
}
|
|
}
|
|
|
|
// -- slot tracking: real protocol-B event streams -----------------------
|
|
//
|
|
// These exist because the classifier tests above build Track tuples by hand
|
|
// and so never exercised the decoding. The bug that shipped to the rack
|
|
// display lived exactly here: every swipe came out as up-to-down, and the
|
|
// display got stuck on the bottom row because "down" from there is a no-op.
|
|
|
|
/// One contact moving from `from` to `to`, reported the way the kernel does:
|
|
/// tracking id, then X and Y as separate events, then a lift.
|
|
fn contact(t: &mut SlotTracker, slot: i32, from: (i32, i32), to: (i32, i32), steps: i32) {
|
|
t.feed(Touched::Slot(slot));
|
|
t.feed(Touched::TrackingId(slot + 1));
|
|
for i in 0..=steps {
|
|
let x = from.0 + (to.0 - from.0) * i / steps;
|
|
let y = from.1 + (to.1 - from.1) * i / steps;
|
|
t.feed(Touched::Slot(slot));
|
|
t.feed(Touched::X(x));
|
|
t.feed(Touched::Y(y));
|
|
}
|
|
}
|
|
|
|
fn lift(t: &mut SlotTracker, slot: i32) {
|
|
t.feed(Touched::Slot(slot));
|
|
t.feed(Touched::TrackingId(-1));
|
|
}
|
|
|
|
#[test]
|
|
fn a_horizontal_swipe_low_on_the_panel_is_not_read_as_downward() {
|
|
// THE REGRESSION. A right-to-left swipe at y=360 on a 720-tall panel.
|
|
// With a (0,0) start sentinel, Y was captured as 0 and dy became +360,
|
|
// dwarfing dx and classifying this as UD.
|
|
let mut t = SlotTracker::default();
|
|
contact(&mut t, 0, (900, 360), (700, 362), 10);
|
|
lift(&mut t, 0);
|
|
assert!(t.complete());
|
|
|
|
match classify(&touch(80, 30, vec![1]), &t.tracks(), t.peak()) {
|
|
Some(Event::Swipe(s)) => {
|
|
assert_eq!(
|
|
s.direction,
|
|
Direction::RL,
|
|
"a left swipe must not read as down"
|
|
);
|
|
assert_eq!(s.fingers, 1);
|
|
}
|
|
other => panic!("expected an RL swipe, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn start_is_captured_per_axis() {
|
|
// X arrives before Y, so a start captured on the first event alone would
|
|
// record y=0 and report the finger travelling the height of the panel.
|
|
let mut t = SlotTracker::default();
|
|
t.feed(Touched::Slot(0));
|
|
t.feed(Touched::TrackingId(1));
|
|
t.feed(Touched::X(900));
|
|
t.feed(Touched::Y(360));
|
|
t.feed(Touched::X(700));
|
|
t.feed(Touched::Y(360));
|
|
lift(&mut t, 0);
|
|
assert_eq!(t.tracks(), vec![((900, 360), (700, 360))]);
|
|
}
|
|
|
|
#[test]
|
|
fn all_four_directions_survive_decoding() {
|
|
let cfg = touch(80, 30, vec![1]);
|
|
// Centre of a 1280x720 panel, 200px each way.
|
|
for (to, want) in [
|
|
((440, 360), Direction::RL),
|
|
((840, 360), Direction::LR),
|
|
((640, 160), Direction::DU),
|
|
((640, 560), Direction::UD),
|
|
] {
|
|
let mut t = SlotTracker::default();
|
|
contact(&mut t, 0, (640, 360), to, 10);
|
|
lift(&mut t, 0);
|
|
match classify(&cfg, &t.tracks(), t.peak()) {
|
|
Some(Event::Swipe(s)) => assert_eq!(s.direction, want, "moving to {to:?}"),
|
|
other => panic!("moving to {to:?}: {other:?}"),
|
|
}
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn ghost_contacts_decode_as_one_swipe_of_the_right_length() {
|
|
// The ILITEK panel reports one finger as two or three contacts.
|
|
let mut t = SlotTracker::default();
|
|
contact(&mut t, 0, (900, 300), (700, 300), 8);
|
|
contact(&mut t, 1, (903, 305), (703, 305), 8);
|
|
contact(&mut t, 2, (897, 295), (697, 295), 8);
|
|
lift(&mut t, 0);
|
|
lift(&mut t, 1);
|
|
lift(&mut t, 2);
|
|
|
|
assert_eq!(t.peak(), 3, "peak contacts drive the touch.fingers match");
|
|
match classify(&touch(150, 30, vec![1, 2, 3]), &t.tracks(), t.peak()) {
|
|
Some(Event::Swipe(s)) => {
|
|
assert_eq!(s.direction, Direction::RL);
|
|
assert_eq!(s.fingers, 3);
|
|
}
|
|
other => panic!("expected one RL swipe: {other:?}"),
|
|
}
|
|
// 200px of travel, not 600.
|
|
match classify(&touch(250, 30, vec![1, 2, 3]), &t.tracks(), t.peak()) {
|
|
Some(Event::Discarded(_, Rejected::TooShort { travel, .. })) => {
|
|
assert!((travel - 200.0).abs() < 2.0, "travel was {travel}");
|
|
}
|
|
other => panic!("expected 200px of travel: {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_gesture_is_only_complete_once_every_contact_lifts() {
|
|
let mut t = SlotTracker::default();
|
|
assert!(!t.complete(), "nothing has been touched yet");
|
|
contact(&mut t, 0, (900, 300), (800, 300), 4);
|
|
assert!(!t.complete(), "still down");
|
|
contact(&mut t, 1, (500, 300), (400, 300), 4);
|
|
lift(&mut t, 0);
|
|
assert!(!t.complete(), "one contact is still down");
|
|
lift(&mut t, 1);
|
|
assert!(t.complete());
|
|
}
|
|
|
|
#[test]
|
|
fn reset_clears_the_previous_gesture() {
|
|
let mut t = SlotTracker::default();
|
|
contact(&mut t, 0, (900, 300), (700, 300), 4);
|
|
lift(&mut t, 0);
|
|
t.reset();
|
|
assert!(!t.complete());
|
|
assert_eq!(t.peak(), 0);
|
|
assert!(t.tracks().is_empty());
|
|
|
|
// A second swipe must measure from its own origin, not the first one's.
|
|
contact(&mut t, 0, (300, 300), (500, 300), 4);
|
|
lift(&mut t, 0);
|
|
match classify(&touch(80, 30, vec![1]), &t.tracks(), t.peak()) {
|
|
Some(Event::Swipe(s)) => assert_eq!(s.direction, Direction::LR),
|
|
other => panic!("{other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn a_gesture_with_no_contacts_is_not_a_gesture() {
|
|
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
|
|
}
|
|
}
|