Capture a contact's start position per axis, not with a (0,0) sentinel
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>
This commit is contained in:
+265
-44
@@ -24,13 +24,120 @@ pub struct Swipe {
|
|||||||
pub fingers: usize,
|
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 {
|
struct Slot {
|
||||||
start: (i32, i32),
|
start_x: Option<i32>,
|
||||||
|
start_y: Option<i32>,
|
||||||
last: (i32, i32),
|
last: (i32, i32),
|
||||||
active: bool,
|
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 {
|
pub struct Touchpanel {
|
||||||
device: Device,
|
device: Device,
|
||||||
cfg: Touch,
|
cfg: Touch,
|
||||||
@@ -129,53 +236,36 @@ impl Touchpanel {
|
|||||||
/// Blocking gesture loop. Calls `on_event` for every completed gesture,
|
/// Blocking gesture loop. Calls `on_event` for every completed gesture,
|
||||||
/// including rejected ones, and stops when it returns `false`.
|
/// including rejected ones, and stops when it returns `false`.
|
||||||
pub fn run(&mut self, mut on_event: impl FnMut(Event) -> bool) -> Result<()> {
|
pub fn run(&mut self, mut on_event: impl FnMut(Event) -> bool) -> Result<()> {
|
||||||
let mut slots: HashMap<i32, Slot> = HashMap::new();
|
let mut tracker = SlotTracker::default();
|
||||||
let mut current: i32 = 0;
|
|
||||||
let mut peak: usize = 0;
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
for ev in self.device.fetch_events().context("reading touch events")? {
|
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) => {
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
|
||||||
current = ev.value();
|
Touched::Slot(ev.value())
|
||||||
}
|
}
|
||||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
|
||||||
if ev.value() < 0 {
|
Touched::TrackingId(ev.value())
|
||||||
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());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
|
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) => {
|
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.
|
// The gesture ends when the last contact lifts.
|
||||||
if !slots.is_empty() && slots.values().all(|s| !s.active) {
|
if tracker.complete() {
|
||||||
let tracks: Vec<Track> = slots.values().map(|s| (s.start, s.last)).collect();
|
if let Some(ev) = classify(&self.cfg, &tracker.tracks(), tracker.peak()) {
|
||||||
if let Some(ev) = classify(&self.cfg, &tracks, peak) {
|
|
||||||
if !on_event(ev) {
|
if !on_event(ev) {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
slots.clear();
|
tracker.reset();
|
||||||
peak = 0;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -255,19 +345,6 @@ pub fn classify(cfg: &Touch, tracks: &[Track], peak: usize) -> Option<Event> {
|
|||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update(slots: &mut HashMap<i32, Slot>, 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.
|
/// Candidate touchscreens, for `doctor --list` and the installer.
|
||||||
pub fn list_touchscreens() -> Vec<(String, String)> {
|
pub fn list_touchscreens() -> Vec<(String, String)> {
|
||||||
let mut out = Vec::new();
|
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]
|
#[test]
|
||||||
fn a_gesture_with_no_contacts_is_not_a_gesture() {
|
fn a_gesture_with_no_contacts_is_not_a_gesture() {
|
||||||
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
|
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
|
||||||
|
|||||||
Reference in New Issue
Block a user