2026-09-09 13:00:52 -07:00
|
|
|
//! 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,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:48:37 -07:00
|
|
|
/// 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)]
|
2026-09-09 13:00:52 -07:00
|
|
|
struct Slot {
|
2026-09-09 13:48:37 -07:00
|
|
|
start_x: Option<i32>,
|
|
|
|
|
start_y: Option<i32>,
|
2026-09-09 13:00:52 -07:00
|
|
|
last: (i32, i32),
|
|
|
|
|
active: bool,
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:48:37 -07:00
|
|
|
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;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:00:52 -07:00
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:08:42 -07:00
|
|
|
Ok(Self {
|
|
|
|
|
device,
|
|
|
|
|
cfg: cfg.clone(),
|
|
|
|
|
})
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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<()> {
|
2026-09-09 13:48:37 -07:00
|
|
|
let mut tracker = SlotTracker::default();
|
2026-09-09 13:00:52 -07:00
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
for ev in self.device.fetch_events().context("reading touch events")? {
|
2026-09-09 13:48:37 -07:00
|
|
|
let touched = match ev.kind() {
|
2026-09-09 13:00:52 -07:00
|
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
|
2026-09-09 13:48:37 -07:00
|
|
|
Touched::Slot(ev.value())
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
|
2026-09-09 13:48:37 -07:00
|
|
|
Touched::TrackingId(ev.value())
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
|
2026-09-09 13:48:37 -07:00
|
|
|
Touched::X(ev.value())
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => {
|
2026-09-09 13:48:37 -07:00
|
|
|
Touched::Y(ev.value())
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
2026-09-09 13:48:37 -07:00
|
|
|
_ => continue,
|
|
|
|
|
};
|
|
|
|
|
tracker.feed(touched);
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The gesture ends when the last contact lifts.
|
2026-09-09 13:48:37 -07:00
|
|
|
if tracker.complete() {
|
|
|
|
|
if let Some(ev) = classify(&self.cfg, &tracker.tracks(), tracker.peak()) {
|
2026-09-09 13:00:52 -07:00
|
|
|
if !on_event(ev) {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
}
|
2026-09-09 13:48:37 -07:00
|
|
|
tracker.reset();
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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)| {
|
2026-09-09 13:08:42 -07:00
|
|
|
(
|
|
|
|
|
ax + (last.0 - start.0) as f64 / n,
|
|
|
|
|
ay + (last.1 - start.1) as f64 / n,
|
|
|
|
|
)
|
2026-09-09 13:00:52 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-09 13:08:42 -07:00
|
|
|
Rejected::TooShort {
|
|
|
|
|
travel,
|
|
|
|
|
threshold: cfg.threshold,
|
|
|
|
|
},
|
2026-09-09 13:00:52 -07:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Angle away from the dominant axis.
|
2026-09-09 13:08:42 -07:00
|
|
|
let (along, across) = if horizontal {
|
|
|
|
|
(dx.abs(), dy.abs())
|
|
|
|
|
} else {
|
|
|
|
|
(dy.abs(), dx.abs())
|
|
|
|
|
};
|
2026-09-09 13:00:52 -07:00
|
|
|
let degrees = across.atan2(along).to_degrees();
|
|
|
|
|
if degrees > cfg.leniency as f64 {
|
|
|
|
|
return Some(Event::Discarded(
|
|
|
|
|
direction,
|
2026-09-09 13:08:42 -07:00
|
|
|
Rejected::OffAxis {
|
|
|
|
|
degrees,
|
|
|
|
|
leniency: cfg.leniency,
|
|
|
|
|
},
|
2026-09-09 13:00:52 -07:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !cfg.fingers.contains(&peak) {
|
|
|
|
|
return Some(Event::Discarded(
|
|
|
|
|
direction,
|
2026-09-09 13:08:42 -07:00
|
|
|
Rejected::WrongFingerCount {
|
|
|
|
|
saw: peak,
|
|
|
|
|
want: cfg.fingers.clone(),
|
|
|
|
|
},
|
2026-09-09 13:00:52 -07:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:08:42 -07:00
|
|
|
Some(Event::Swipe(Swipe {
|
|
|
|
|
direction,
|
|
|
|
|
fingers: peak,
|
|
|
|
|
}))
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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();
|
2026-09-09 13:08:42 -07:00
|
|
|
let Ok(dev) = Device::open(&path) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-09-09 13:00:52 -07:00
|
|
|
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]);
|
2026-09-09 13:08:42 -07:00
|
|
|
match swipe(
|
|
|
|
|
&cfg,
|
|
|
|
|
&[track((900, 300), -200, 0), track((905, 305), -200, 0)],
|
|
|
|
|
2,
|
|
|
|
|
) {
|
2026-09-09 13:00:52 -07:00
|
|
|
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]);
|
2026-09-09 13:08:42 -07:00
|
|
|
match swipe(
|
|
|
|
|
&cfg,
|
|
|
|
|
&[track((640, 360), -5, 0), track((641, 361), -5, 0)],
|
|
|
|
|
2,
|
|
|
|
|
) {
|
2026-09-09 13:00:52 -07:00
|
|
|
Event::Discarded(_, Rejected::TooShort { .. }) => {}
|
|
|
|
|
other => panic!("expected the short-travel reason first: {other:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:48:37 -07:00
|
|
|
// -- 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:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:00:52 -07:00
|
|
|
#[test]
|
|
|
|
|
fn a_gesture_with_no_contacts_is_not_a_gesture() {
|
|
|
|
|
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
|
|
|
|
|
}
|
|
|
|
|
}
|