Rust core: YAML grid config, navigation state machine, evdev input
Replaces the three shell scripts' logic with one binary. tmux stays the pane engine; src/session/ is the only module that knows that. The grid model: coordinates are sparse ordinals, so only their sort order matters and a socktop group is one cell however many hosts it holds. Horizontal movement walks a cell's sub-sequence (tiled overview, then each host zoomed) and leaves only after the last one; entry direction decides whether you land on the first or last sub-screen. Vertical movement returns to where you were in that row, and snaps to the nearest column only on the first visit. Notable details found while building: * Unquoted "at: 0x0" is hexadecimal 0 to YAML, and "1x0" is not valid hex, so only the row-0 entries would break. Deserialization catches the integer case and names the fix. * Panes are addressed by tmux id, never index, and each command is wrapped so the pane outlives it. v1's remain-on-exit cannot do this: it is a per-window option that new windows do not inherit, so a monitor that exits during construction destroys its window and the next split fails with "no current target". Now a dead monitor stays on screen with its status. * Contact-count averaging, not summing: a panel reporting one finger as three contacts must not look like three times the travel. * Movement subcommands wait for the move to happen and report where they landed, so they are scriptable rather than fire-and-forget. 36 tests: the grid model, the gesture classifier and an end-to-end pass against a real tmux server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
+395
@@ -0,0 +1,395 @@
|
||||
//! 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
|
||||
)
|
||||
})?;
|
||||
}
|
||||
|
||||
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 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,
|
||||
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) => {
|
||||
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) {
|
||||
let tracks: Vec<Track> =
|
||||
slots.values().map(|s| (s.start, s.last)).collect();
|
||||
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)| {
|
||||
(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 }))
|
||||
}
|
||||
|
||||
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();
|
||||
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<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]);
|
||||
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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gesture_with_no_contacts_is_not_a_gesture() {
|
||||
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user