diff --git a/src/input.rs b/src/input.rs index 0f07f9e..93f2af1 100644 --- a/src/input.rs +++ b/src/input.rs @@ -24,13 +24,120 @@ pub struct Swipe { pub fingers: usize, } -#[derive(Debug, Clone, Copy)] +/// 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: (i32, i32), + 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, @@ -129,53 +236,36 @@ impl Touchpanel { /// 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 slots: HashMap = HashMap::new(); - let mut current: i32 = 0; - let mut peak: usize = 0; + let mut tracker = SlotTracker::default(); loop { for ev in self.device.fetch_events().context("reading touch events")? { - match ev.kind() { + let touched = match ev.kind() { InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => { - current = ev.value(); + Touched::Slot(ev.value()) } InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => { - if ev.value() < 0 { - if let Some(s) = slots.get_mut(¤t) { - s.active = false; - } - } else { - slots.insert( - current, - Slot { - start: (0, 0), - last: (0, 0), - active: true, - }, - ); - peak = peak.max(slots.values().filter(|s| s.active).count()); - } + Touched::TrackingId(ev.value()) } InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => { - update(&mut slots, current, |p| p.0 = ev.value()); + Touched::X(ev.value()) } InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => { - update(&mut slots, current, |p| p.1 = ev.value()); + Touched::Y(ev.value()) } - _ => {} - } + _ => continue, + }; + tracker.feed(touched); } // The gesture ends when the last contact lifts. - if !slots.is_empty() && slots.values().all(|s| !s.active) { - let tracks: Vec = slots.values().map(|s| (s.start, s.last)).collect(); - if let Some(ev) = classify(&self.cfg, &tracks, peak) { + if tracker.complete() { + if let Some(ev) = classify(&self.cfg, &tracker.tracks(), tracker.peak()) { if !on_event(ev) { return Ok(()); } } - slots.clear(); - peak = 0; + tracker.reset(); } } } @@ -255,19 +345,6 @@ pub fn classify(cfg: &Touch, tracks: &[Track], peak: usize) -> Option { })) } -fn update(slots: &mut HashMap, current: i32, f: impl Fn(&mut (i32, i32))) { - let slot = slots.entry(current).or_insert(Slot { - start: (0, 0), - last: (0, 0), - active: true, - }); - f(&mut slot.last); - // The first position report after a contact begins is also its origin. - if slot.start == (0, 0) { - slot.start = slot.last; - } -} - /// Candidate touchscreens, for `doctor --list` and the installer. pub fn list_touchscreens() -> Vec<(String, String)> { let mut out = Vec::new(); @@ -422,6 +499,150 @@ mod tests { } } + // -- 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());