v2: Rust rewrite with a YAML grid layout, evdev gestures and a preflighting installer #1

Merged
jason merged 8 commits from v2-rust into main 2026-09-09 21:27:12 +00:00
Showing only changes of commit ec9a9a8892 - Show all commits
+265 -44
View File
@@ -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<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,
@@ -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<i32, Slot> = 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(&current) {
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<Track> = 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<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.
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());