Files
socktop-swipe/src/input.rs
T

651 lines
22 KiB
Rust
Raw Normal View History

//! 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());
}
}