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:
co-authored by
Claude Opus 5
parent
9f4bcec250
commit
9f082b52b7
@@ -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)]);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user