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
+343
@@ -0,0 +1,343 @@
|
||||
//! socktop-swipe: swipe between terminal dashboards on a touchscreen.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::Command;
|
||||
use std::sync::mpsc;
|
||||
|
||||
use anyhow::{Context, Result};
|
||||
use clap::{Parser, Subcommand};
|
||||
|
||||
use socktop_swipe::config::{self, Config, Direction};
|
||||
use socktop_swipe::control::{self, Ctl};
|
||||
use socktop_swipe::grid::{Grid, Move};
|
||||
use socktop_swipe::input::{Event, Touchpanel};
|
||||
use socktop_swipe::session::tmux::Tmux;
|
||||
use socktop_swipe::session::Multiplexer;
|
||||
use socktop_swipe::{doctor, monitor};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(
|
||||
name = "socktop-swipe",
|
||||
version,
|
||||
about = "Swipe between terminal dashboards on a touchscreen",
|
||||
long_about = None
|
||||
)]
|
||||
struct Cli {
|
||||
/// Config file. Defaults to ~/.config/socktop-swipe/config.yaml, then
|
||||
/// /etc/socktop-swipe/config.yaml.
|
||||
#[arg(short, long, global = true, value_name = "PATH")]
|
||||
config: Option<PathBuf>,
|
||||
|
||||
#[command(subcommand)]
|
||||
command: Option<Cmd>,
|
||||
}
|
||||
|
||||
#[derive(Subcommand)]
|
||||
enum Cmd {
|
||||
/// Build the session, open it in a terminal and start reading the panel.
|
||||
/// This is the one command to autostart.
|
||||
Run {
|
||||
/// Do not open the touch panel; navigate only via the control socket
|
||||
/// (`socktop-swipe forward`, and so on). For a box whose panel is not
|
||||
/// wired up yet, or for driving the grid from the keyboard.
|
||||
#[arg(long)]
|
||||
no_touch: bool,
|
||||
},
|
||||
|
||||
/// Build the session and attach to it in this terminal. No gestures.
|
||||
Attach,
|
||||
|
||||
/// Build the session and leave it running detached, without attaching.
|
||||
/// For splitting the terminal and the gesture daemon into separate units.
|
||||
Build,
|
||||
|
||||
/// Read the panel and drive an already-running session. For a systemd split.
|
||||
Daemon {
|
||||
/// Do not open the touch panel; control socket only.
|
||||
#[arg(long)]
|
||||
no_touch: bool,
|
||||
},
|
||||
|
||||
/// Check the config, resolve the grid and print the map.
|
||||
Validate,
|
||||
|
||||
/// Watch the touch panel and explain what it sees.
|
||||
Doctor {
|
||||
/// List candidate touch devices instead of watching one.
|
||||
#[arg(long)]
|
||||
list: bool,
|
||||
},
|
||||
|
||||
/// Move deeper into the grid, as a forward swipe would.
|
||||
#[command(visible_alias = "next")]
|
||||
Forward,
|
||||
/// Move back, as a backward swipe would.
|
||||
#[command(visible_alias = "prev")]
|
||||
Back,
|
||||
/// Move to the row above.
|
||||
Up,
|
||||
/// Move to the row below.
|
||||
Down,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
if let Err(e) = real_main() {
|
||||
eprintln!("socktop-swipe: {e:#}");
|
||||
std::process::exit(1);
|
||||
}
|
||||
}
|
||||
|
||||
fn real_main() -> Result<()> {
|
||||
let cli = Cli::parse();
|
||||
|
||||
// --list is the one command that must work before a config exists.
|
||||
if let Some(Cmd::Doctor { list: true }) = cli.command {
|
||||
return doctor::list();
|
||||
}
|
||||
|
||||
let path = Config::locate(cli.config.as_deref())?;
|
||||
let cfg = Config::load(&path)?;
|
||||
|
||||
match cli.command.unwrap_or(Cmd::Run { no_touch: false }) {
|
||||
Cmd::Validate => validate(&cfg, &path),
|
||||
Cmd::Doctor { .. } => doctor::run(&cfg),
|
||||
Cmd::Build => {
|
||||
let (tmux, grid) = build(&cfg)?;
|
||||
tmux.build(&grid)?;
|
||||
println!("session {:?} is up. Attach with: tmux attach -t {}", cfg.session, cfg.session);
|
||||
Ok(())
|
||||
}
|
||||
Cmd::Attach => {
|
||||
let (tmux, grid) = build(&cfg)?;
|
||||
tmux.build(&grid)?;
|
||||
tmux.attach()?;
|
||||
unreachable!()
|
||||
}
|
||||
Cmd::Daemon { no_touch } => daemon(&cfg, no_touch),
|
||||
Cmd::Run { no_touch } => run(&cfg, no_touch),
|
||||
Cmd::Forward => step(&cfg, "forward"),
|
||||
Cmd::Back => step(&cfg, "back"),
|
||||
Cmd::Up => step(&cfg, "up"),
|
||||
Cmd::Down => step(&cfg, "down"),
|
||||
}
|
||||
}
|
||||
|
||||
fn step(cfg: &Config, direction: &str) -> Result<()> {
|
||||
println!("{}", control::send(&cfg.session, direction)?);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn build(cfg: &Config) -> Result<(Tmux, Grid)> {
|
||||
let grid = Grid::new(monitor::build_cells(cfg)?)?;
|
||||
Ok((Tmux::new(&cfg.session, cfg.indicator), grid))
|
||||
}
|
||||
|
||||
fn validate(cfg: &Config, path: &std::path::Path) -> Result<()> {
|
||||
let grid = Grid::new(monitor::build_cells(cfg)?)?;
|
||||
|
||||
println!("{}\n", path.display());
|
||||
|
||||
let mut missing = Vec::new();
|
||||
for row in &grid.rows {
|
||||
println!("row {}:", row.row);
|
||||
for cell in &row.cells {
|
||||
let start = if cell.coord == config::Coord::new(0, 0) { " <- start" } else { "" };
|
||||
println!(
|
||||
" {:<7} {:<20} {} screen{}{start}",
|
||||
cell.coord.to_string(),
|
||||
cell.kind.to_string(),
|
||||
cell.screens(),
|
||||
if cell.screens() == 1 { "" } else { "s" },
|
||||
);
|
||||
for pane in &cell.panes {
|
||||
let bin = &pane.command[0];
|
||||
let ok = which(bin);
|
||||
if !ok {
|
||||
missing.push(bin.clone());
|
||||
}
|
||||
println!(
|
||||
" {} {}",
|
||||
if ok { "\u{2713}" } else { "\u{2717}" },
|
||||
shell_words::join(pane.command.iter().map(String::as_str))
|
||||
);
|
||||
}
|
||||
}
|
||||
println!();
|
||||
}
|
||||
|
||||
if grid.cells().all(|c| c.coord != config::Coord::new(0, 0)) {
|
||||
println!(
|
||||
"note: no screen at 0x0, so the display starts at {} instead.\n",
|
||||
grid.cell().coord
|
||||
);
|
||||
}
|
||||
|
||||
if !std::path::Path::new(&cfg.touch.device).exists() {
|
||||
println!("touch: \u{2717} {} is not present right now", cfg.touch.device);
|
||||
println!(" `socktop-swipe doctor --list` shows what is.\n");
|
||||
} else {
|
||||
println!("touch: \u{2713} {}\n", cfg.touch.device);
|
||||
}
|
||||
|
||||
if missing.is_empty() {
|
||||
println!("Config is valid.");
|
||||
} else {
|
||||
missing.sort();
|
||||
missing.dedup();
|
||||
println!("Config parses, but these are not installed or not on PATH:");
|
||||
for m in &missing {
|
||||
println!(" {m}");
|
||||
}
|
||||
println!("\nSet full paths under `binaries:` if they are installed elsewhere.");
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn which(bin: &str) -> bool {
|
||||
if bin.contains('/') {
|
||||
return std::path::Path::new(bin).is_file();
|
||||
}
|
||||
std::env::var_os("PATH")
|
||||
.map(|p| {
|
||||
std::env::split_paths(&p).any(|dir| dir.join(bin).is_file())
|
||||
})
|
||||
.unwrap_or(false)
|
||||
}
|
||||
|
||||
/// Build the session, hand it to a terminal, and read the panel until that
|
||||
/// terminal exits. One process, so a second copy cannot fight the first over
|
||||
/// the input device -- the evdev grab fails immediately and says so.
|
||||
fn run(cfg: &Config, no_touch: bool) -> Result<()> {
|
||||
let (tmux, mut grid) = build(cfg)?;
|
||||
tmux.build(&grid)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _socket = control::listen(&cfg.session, tx.clone())?;
|
||||
let _guard = control::SocketGuard(control::socket_path(&cfg.session));
|
||||
|
||||
let attach = tmux.attach_argv();
|
||||
let mut child = match &cfg.terminal {
|
||||
Some(term) => {
|
||||
let mut argv = shell_words::split(term)
|
||||
.with_context(|| format!("cannot parse terminal: {term}"))?;
|
||||
let prog = argv.remove(0);
|
||||
Command::new(&prog)
|
||||
.args(argv)
|
||||
.arg("-e")
|
||||
.args(&attach)
|
||||
.spawn()
|
||||
.with_context(|| format!("cannot start terminal {prog:?}"))?
|
||||
}
|
||||
None => Command::new(&attach[0])
|
||||
.args(&attach[1..])
|
||||
.spawn()
|
||||
.context("cannot attach to the session")?,
|
||||
};
|
||||
|
||||
// Closing the dashboard window is the ordinary way this ends.
|
||||
let quit = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
let _ = quit.send(Ctl::Quit);
|
||||
});
|
||||
|
||||
if !no_touch {
|
||||
spawn_panel(cfg, tx)?;
|
||||
}
|
||||
drive(&tmux, &mut grid, rx)
|
||||
}
|
||||
|
||||
fn daemon(cfg: &Config, no_touch: bool) -> Result<()> {
|
||||
let (tmux, mut grid) = build(cfg)?;
|
||||
if !tmux.is_running() {
|
||||
anyhow::bail!(
|
||||
"no session named {:?}. Start one with `socktop-swipe attach`, \
|
||||
or use `socktop-swipe run` to do both.",
|
||||
cfg.session
|
||||
);
|
||||
}
|
||||
tmux.adopt(&grid)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _socket = control::listen(&cfg.session, tx.clone())?;
|
||||
let _guard = control::SocketGuard(control::socket_path(&cfg.session));
|
||||
if !no_touch {
|
||||
spawn_panel(cfg, tx)?;
|
||||
} else {
|
||||
// Without a panel there is nothing else to hold the channel open.
|
||||
drop(tx);
|
||||
}
|
||||
drive(&tmux, &mut grid, rx)
|
||||
}
|
||||
|
||||
/// Read the panel on its own thread. Opening it here rather than in the thread
|
||||
/// keeps a permission or grab failure on the main path, where it can be
|
||||
/// reported properly instead of vanishing into a detached thread.
|
||||
fn spawn_panel(cfg: &Config, tx: mpsc::Sender<Ctl>) -> Result<()> {
|
||||
let mut panel = Touchpanel::open(&cfg.touch)?;
|
||||
let gestures = cfg.gestures.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = panel.run(|ev| {
|
||||
let Event::Swipe(s) = ev else { return true };
|
||||
match binding(&gestures, s.direction) {
|
||||
Some(m) => tx.send(Ctl::Go(m, None)).is_ok(),
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
if let Err(e) = result {
|
||||
let _ = tx.send(Ctl::Failed(e));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drive(tmux: &Tmux, grid: &mut Grid, rx: mpsc::Receiver<Ctl>) -> Result<()> {
|
||||
for msg in rx {
|
||||
match msg {
|
||||
Ctl::Quit => return Ok(()),
|
||||
Ctl::Failed(e) => return Err(e),
|
||||
Ctl::Go(m, reply) => {
|
||||
let pos = grid.apply(m);
|
||||
let outcome = if pos.changed {
|
||||
tmux.show(grid, &pos)
|
||||
} else {
|
||||
Ok(())
|
||||
};
|
||||
if let Err(e) = outcome {
|
||||
// A vanished session is the normal way this ends: the user
|
||||
// closed the terminal. Anything else is worth surfacing.
|
||||
if !tmux.is_running() {
|
||||
return Ok(());
|
||||
}
|
||||
answer(reply, &format!("error: {e:#}"));
|
||||
return Err(e);
|
||||
}
|
||||
let edge = if pos.changed { "" } else { " (edge, nothing to move to)" };
|
||||
answer(
|
||||
reply,
|
||||
&format!("{} {}{edge}", pos.coord, grid.cell().screen_label()),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Tell a waiting movement subcommand what happened. Best effort: it may have
|
||||
/// been interrupted, and a gesture carries no stream at all.
|
||||
fn answer(reply: Option<std::os::unix::net::UnixStream>, text: &str) {
|
||||
use std::io::Write;
|
||||
if let Some(mut r) = reply {
|
||||
let _ = writeln!(r, "{text}");
|
||||
}
|
||||
}
|
||||
|
||||
fn binding(g: &config::Gestures, d: Direction) -> Option<Move> {
|
||||
Some(match d {
|
||||
_ if d == g.forward => Move::Forward,
|
||||
_ if d == g.back => Move::Back,
|
||||
_ if d == g.up => Move::Up,
|
||||
_ if d == g.down => Move::Down,
|
||||
_ => return None,
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user