2026-09-09 13:00:52 -07:00
|
|
|
//! 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,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[derive(Debug, Clone, Copy)]
|
|
|
|
|
struct Slot {
|
|
|
|
|
start: (i32, i32),
|
|
|
|
|
last: (i32, i32),
|
|
|
|
|
active: bool,
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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
|
|
|
|
|
)
|
|
|
|
|
})?;
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:08:42 -07:00
|
|
|
Ok(Self {
|
|
|
|
|
device,
|
|
|
|
|
cfg: cfg.clone(),
|
|
|
|
|
})
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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;
|
|
|
|
|
|
|
|
|
|
loop {
|
|
|
|
|
for ev in self.device.fetch_events().context("reading touch events")? {
|
|
|
|
|
match ev.kind() {
|
|
|
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
|
|
|
|
|
current = 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,
|
2026-09-09 13:08:42 -07:00
|
|
|
Slot {
|
|
|
|
|
start: (0, 0),
|
|
|
|
|
last: (0, 0),
|
|
|
|
|
active: true,
|
|
|
|
|
},
|
2026-09-09 13:00:52 -07:00
|
|
|
);
|
|
|
|
|
peak = peak.max(slots.values().filter(|s| s.active).count());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
|
|
|
|
|
update(&mut slots, current, |p| p.0 = ev.value());
|
|
|
|
|
}
|
|
|
|
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => {
|
|
|
|
|
update(&mut slots, current, |p| p.1 = ev.value());
|
|
|
|
|
}
|
|
|
|
|
_ => {}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// The gesture ends when the last contact lifts.
|
|
|
|
|
if !slots.is_empty() && slots.values().all(|s| !s.active) {
|
2026-09-09 13:08:42 -07:00
|
|
|
let tracks: Vec<Track> = slots.values().map(|s| (s.start, s.last)).collect();
|
2026-09-09 13:00:52 -07:00
|
|
|
if let Some(ev) = classify(&self.cfg, &tracks, peak) {
|
|
|
|
|
if !on_event(ev) {
|
|
|
|
|
return Ok(());
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
slots.clear();
|
|
|
|
|
peak = 0;
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
/// 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)| {
|
2026-09-09 13:08:42 -07:00
|
|
|
(
|
|
|
|
|
ax + (last.0 - start.0) as f64 / n,
|
|
|
|
|
ay + (last.1 - start.1) as f64 / n,
|
|
|
|
|
)
|
2026-09-09 13:00:52 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
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,
|
2026-09-09 13:08:42 -07:00
|
|
|
Rejected::TooShort {
|
|
|
|
|
travel,
|
|
|
|
|
threshold: cfg.threshold,
|
|
|
|
|
},
|
2026-09-09 13:00:52 -07:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
// Angle away from the dominant axis.
|
2026-09-09 13:08:42 -07:00
|
|
|
let (along, across) = if horizontal {
|
|
|
|
|
(dx.abs(), dy.abs())
|
|
|
|
|
} else {
|
|
|
|
|
(dy.abs(), dx.abs())
|
|
|
|
|
};
|
2026-09-09 13:00:52 -07:00
|
|
|
let degrees = across.atan2(along).to_degrees();
|
|
|
|
|
if degrees > cfg.leniency as f64 {
|
|
|
|
|
return Some(Event::Discarded(
|
|
|
|
|
direction,
|
2026-09-09 13:08:42 -07:00
|
|
|
Rejected::OffAxis {
|
|
|
|
|
degrees,
|
|
|
|
|
leniency: cfg.leniency,
|
|
|
|
|
},
|
2026-09-09 13:00:52 -07:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
if !cfg.fingers.contains(&peak) {
|
|
|
|
|
return Some(Event::Discarded(
|
|
|
|
|
direction,
|
2026-09-09 13:08:42 -07:00
|
|
|
Rejected::WrongFingerCount {
|
|
|
|
|
saw: peak,
|
|
|
|
|
want: cfg.fingers.clone(),
|
|
|
|
|
},
|
2026-09-09 13:00:52 -07:00
|
|
|
));
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:08:42 -07:00
|
|
|
Some(Event::Swipe(Swipe {
|
|
|
|
|
direction,
|
|
|
|
|
fingers: peak,
|
|
|
|
|
}))
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
|
|
|
|
|
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();
|
|
|
|
|
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();
|
2026-09-09 13:08:42 -07:00
|
|
|
let Ok(dev) = Device::open(&path) else {
|
|
|
|
|
continue;
|
|
|
|
|
};
|
2026-09-09 13:00:52 -07:00
|
|
|
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]);
|
2026-09-09 13:08:42 -07:00
|
|
|
match swipe(
|
|
|
|
|
&cfg,
|
|
|
|
|
&[track((900, 300), -200, 0), track((905, 305), -200, 0)],
|
|
|
|
|
2,
|
|
|
|
|
) {
|
2026-09-09 13:00:52 -07:00
|
|
|
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]);
|
2026-09-09 13:08:42 -07:00
|
|
|
match swipe(
|
|
|
|
|
&cfg,
|
|
|
|
|
&[track((640, 360), -5, 0), track((641, 361), -5, 0)],
|
|
|
|
|
2,
|
|
|
|
|
) {
|
2026-09-09 13:00:52 -07:00
|
|
|
Event::Discarded(_, Rejected::TooShort { .. }) => {}
|
|
|
|
|
other => panic!("expected the short-travel reason first: {other:?}"),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
#[test]
|
|
|
|
|
fn a_gesture_with_no_contacts_is_not_a_gesture() {
|
|
|
|
|
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
|
|
|
|
|
}
|
|
|
|
|
}
|