//! 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, start_y: Option, 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, 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 { 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 }, } 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 { 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 { // 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) -> 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()); } }