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:
jasonwitty
2026-09-09 13:01:08 -07:00
co-authored by Claude Opus 5
parent 9f4bcec250
commit 9f082b52b7
16 changed files with 3337 additions and 0 deletions
+143
View File
@@ -0,0 +1,143 @@
//! Grid coordinates: `"<row>x<col>"`, e.g. `"0x0"`, `"-1x0"`, `"1x-2"`.
//!
//! Row increases *downward* (`-1x0` is above `0x0`); column increases rightward.
//! Coordinates are sparse ordinals -- only their ordering matters, so `0x1` and
//! `0x5` are interchangeable as long as they sort the same way. See
//! notes/PLAN-v2.md section 2.
use std::fmt;
use serde::de::{self, Deserializer, Visitor};
use serde::Deserialize;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
pub struct Coord {
/// Sorts first, and ascending row means descending on screen.
pub row: i32,
pub col: i32,
}
impl Coord {
pub fn new(row: i32, col: i32) -> Self {
Self { row, col }
}
/// A tmux-safe window name. tmux treats `:` and `.` as target separators, so
/// negative coordinates use `m` ("minus") rather than a sign character.
pub fn window_name(&self) -> String {
fn part(prefix: char, v: i32) -> String {
if v < 0 {
format!("{prefix}m{}", v.unsigned_abs())
} else {
format!("{prefix}{v}")
}
}
format!("{}{}", part('r', self.row), part('c', self.col))
}
}
impl fmt::Display for Coord {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
write!(f, "{}x{}", self.row, self.col)
}
}
impl std::str::FromStr for Coord {
type Err = String;
fn from_str(s: &str) -> Result<Self, Self::Err> {
let t = s.trim();
// Split on the separator 'x', which cannot be part of either number.
let (row, col) = t
.split_once('x')
.ok_or_else(|| format!("{t:?} is not a coordinate -- expected \"<row>x<col>\", e.g. \"0x0\" or \"-1x0\""))?;
let parse = |part: &str, which: &str| -> Result<i32, String> {
part.trim().parse::<i32>().map_err(|_| {
format!("{t:?} is not a coordinate -- the {which} {part:?} is not a whole number")
})
};
Ok(Coord::new(parse(row, "row")?, parse(col, "column")?))
}
}
impl<'de> Deserialize<'de> for Coord {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = Coord;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a quoted coordinate such as \"0x0\" or \"-1x0\"")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<Coord, E> {
v.parse().map_err(de::Error::custom)
}
// YAML reads an unquoted `0x0` as the HEXADECIMAL number 0, so the
// coordinate never reaches us as a string at all. `1x0` is not valid
// hex and does arrive as a string, which makes the failure look
// arbitrary -- only the row-0 entries break. Say exactly that.
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Coord, E> {
Err(de::Error::custom(format!(
"YAML read this coordinate as the hexadecimal number {v}, not as text. \
Unquoted `0x0` is hex for 0. Quote it: at: \"0x0\""
)))
}
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Coord, E> {
self.visit_i64(v as i64)
}
}
d.deserialize_any(V)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn parses_signed_coordinates() {
assert_eq!("0x0".parse::<Coord>().unwrap(), Coord::new(0, 0));
assert_eq!("-1x0".parse::<Coord>().unwrap(), Coord::new(-1, 0));
assert_eq!("1x-2".parse::<Coord>().unwrap(), Coord::new(1, -2));
assert_eq!("-3x-4".parse::<Coord>().unwrap(), Coord::new(-3, -4));
assert_eq!(" 2x7 ".parse::<Coord>().unwrap(), Coord::new(2, 7));
}
#[test]
fn rejects_malformed_coordinates() {
for bad in ["", "0", "0x", "x0", "0x0x0", "axb", "0.5x1"] {
assert!(bad.parse::<Coord>().is_err(), "{bad:?} should not parse");
}
}
#[test]
fn window_names_are_tmux_safe() {
assert_eq!(Coord::new(0, 0).window_name(), "r0c0");
assert_eq!(Coord::new(-1, 0).window_name(), "rm1c0");
assert_eq!(Coord::new(1, -2).window_name(), "r1cm2");
for c in [Coord::new(0, 0), Coord::new(-1, -1), Coord::new(9, 9)] {
let n = c.window_name();
assert!(!n.contains(':') && !n.contains('.') && !n.contains('-'), "{n}");
}
}
#[test]
fn unquoted_hex_coordinate_gets_a_useful_error() {
// This is what YAML actually hands us for `at: 0x0`.
let err = serde_yaml::from_str::<Coord>("0x0").unwrap_err().to_string();
assert!(err.contains("hexadecimal"), "unhelpful error: {err}");
assert!(err.contains("at: \"0x0\""), "error should show the fix: {err}");
}
#[test]
fn sorts_by_row_then_column() {
let mut v = vec![Coord::new(1, 0), Coord::new(-1, 5), Coord::new(0, 2), Coord::new(0, -1)];
v.sort();
assert_eq!(v, vec![Coord::new(-1, 5), Coord::new(0, -1), Coord::new(0, 2), Coord::new(1, 0)]);
}
}