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:00:52 -07:00
parent 9f4bcec250
commit 9f082b52b7
16 changed files with 3337 additions and 0 deletions
+182
View File
@@ -0,0 +1,182 @@
//! End-to-end against a real tmux server.
//!
//! Covers what the unit tests deliberately cannot: that the grid's idea of
//! "sub-screen 3 of the cell at 0x0" lands on the pane a person would expect,
//! zoomed. Skipped when tmux is not installed.
use std::process::{Command, Stdio};
use socktop_swipe::config::Config;
use socktop_swipe::grid::{Grid, Move};
use socktop_swipe::monitor;
use socktop_swipe::session::tmux::Tmux;
use socktop_swipe::session::Multiplexer;
const SESSION: &str = "socktop-swipe-selftest";
fn have_tmux() -> bool {
Command::new("tmux")
.arg("-V")
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn tmux(args: &[&str]) -> String {
let out = Command::new("tmux").args(args).output().expect("tmux");
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
fn config() -> Config {
// `true` exits at once, which also exercises remain-on-exit keeping the
// pane addressable afterwards.
let yaml = format!(
r#"
session: {SESSION}
binaries: {{ socktop: /bin/echo, unifly: /bin/echo, uptime-kuma-status: /bin/echo }}
touch: {{ device: /dev/null, width: 1280, height: 720, grab: false }}
screens:
- at: "-1x0"
type: unifly
- at: "0x0"
type: socktop
socktop_group: [alpha, bravo, charlie, delta]
layout: tiled
- at: "0x5"
type: socktop
socktop_group: "echo1, foxtrot"
layout: even-vertical
- at: "1x0"
type: uptime-kuma-status
url: https://example.invalid/status
"#
);
serde_yaml::from_str(&yaml).expect("test config should parse")
}
/// (active window name, active pane title, is the window zoomed)
fn visible() -> (String, String, bool) {
let s = tmux(&[
"display-message",
"-p",
"-t",
SESSION,
"#{window_name}\t#{pane_title}\t#{window_zoomed_flag}",
]);
let f: Vec<&str> = s.split('\t').collect();
(f[0].into(), f[1].into(), f[2] == "1")
}
struct Cleanup;
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = Command::new("tmux")
.args(["kill-session", "-t", SESSION])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
}
}
#[test]
fn session_layout_and_navigation_match_the_grid() {
if !have_tmux() {
eprintln!("skipping: tmux is not installed");
return;
}
let _cleanup = Cleanup;
let cfg = config();
let mut grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
let mux = Tmux::new(&cfg.session, false);
mux.build(&grid).expect("session should build");
// One window per cell, in grid order, named by coordinate.
let windows = tmux(&["list-windows", "-t", SESSION, "-F", "#{window_name} #{window_panes}"]);
assert_eq!(
windows.lines().collect::<Vec<_>>(),
vec!["rm1c0 1", "r0c0 4", "r0c5 2", "r1c0 1"],
"windows should be one per cell, in grid order, with one pane per host"
);
// Starts at 0x0's overview: not zoomed, so all four Pis are visible.
let (win, _, zoomed) = visible();
assert_eq!(win, "r0c0");
assert!(!zoomed, "the overview must not be zoomed");
// Forward walks the sub-sequence, zooming each host in declaration order.
for expected in ["alpha", "bravo", "charlie", "delta"] {
let pos = grid.apply(Move::Forward);
mux.show(&grid, &pos).unwrap();
let (win, title, zoomed) = visible();
assert_eq!(win, "r0c0");
assert_eq!(title, expected, "wrong host zoomed");
assert!(zoomed, "{expected} should be zoomed full-screen");
}
// Past the last host, on to the next cell's overview.
let pos = grid.apply(Move::Forward);
mux.show(&grid, &pos).unwrap();
let (win, _, zoomed) = visible();
assert_eq!(win, "r0c5", "0x5 follows 0x0 despite the gap in column numbers");
assert!(!zoomed);
// Back must land on 0x0's LAST host, not its overview.
let pos = grid.apply(Move::Back);
mux.show(&grid, &pos).unwrap();
let (win, title, zoomed) = visible();
assert_eq!((win.as_str(), title.as_str(), zoomed), ("r0c0", "delta", true));
// Up to unifly: a single-pane cell, so nothing to zoom.
let pos = grid.apply(Move::Up);
mux.show(&grid, &pos).unwrap();
let (win, _, zoomed) = visible();
assert_eq!(win, "rm1c0");
assert!(!zoomed, "a one-pane cell has nothing to zoom into");
// And back down to exactly the host we left.
let pos = grid.apply(Move::Down);
mux.show(&grid, &pos).unwrap();
let (win, title, zoomed) = visible();
assert_eq!(
(win.as_str(), title.as_str(), zoomed),
("r0c0", "delta", true),
"returning must restore the host AND its zoom"
);
// Down twice: through row 0 to kuma.
let pos = grid.apply(Move::Down);
mux.show(&grid, &pos).unwrap();
assert_eq!(visible().0, "r1c0");
}
#[test]
fn pane_commands_are_quoted_not_interpreted() {
if !have_tmux() {
return;
}
// A profile name containing shell metacharacters must reach the program as
// one argument, not be re-split or evaluated by the sh tmux runs it under.
let cfg: Config = serde_yaml::from_str(
r#"
session: socktop-swipe-quoting-test
binaries: { socktop: /bin/echo }
touch: { device: /dev/null, width: 1, height: 1, grab: false }
screens:
- at: "0x0"
type: socktop
socktop_group: ["a b; touch /tmp/socktop-swipe-pwned"]
"#,
)
.unwrap();
let cells = monitor::build_cells(&cfg).unwrap();
let argv = &cells[0].panes[0].command;
assert_eq!(argv, &["/bin/echo", "-P", "a b; touch /tmp/socktop-swipe-pwned"]);
let quoted = shell_words::join(argv.iter().map(String::as_str));
let reparsed = shell_words::split(&quoted).unwrap();
assert_eq!(&reparsed, argv, "quoting must survive the round trip through sh");
}