2026-09-09 13:00:52 -07:00
|
|
|
//! 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)?;
|
2026-09-09 13:08:42 -07:00
|
|
|
println!(
|
|
|
|
|
"session {:?} is up. Attach with: tmux attach -t {}",
|
|
|
|
|
cfg.session, cfg.session
|
|
|
|
|
);
|
2026-09-09 13:00:52 -07:00
|
|
|
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 {
|
2026-09-09 13:08:42 -07:00
|
|
|
let start = if cell.coord == config::Coord::new(0, 0) {
|
|
|
|
|
" <- start"
|
|
|
|
|
} else {
|
|
|
|
|
""
|
|
|
|
|
};
|
2026-09-09 13:00:52 -07:00
|
|
|
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() {
|
2026-09-09 13:08:42 -07:00
|
|
|
println!(
|
|
|
|
|
"touch: \u{2717} {} is not present right now",
|
|
|
|
|
cfg.touch.device
|
|
|
|
|
);
|
2026-09-09 13:00:52 -07:00
|
|
|
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")
|
2026-09-09 13:08:42 -07:00
|
|
|
.map(|p| std::env::split_paths(&p).any(|dir| dir.join(bin).is_file()))
|
2026-09-09 13:00:52 -07:00
|
|
|
.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}"))?;
|
2026-09-09 13:24:16 -07:00
|
|
|
// Same tilde expansion as `binaries:`. A window manager's PATH
|
|
|
|
|
// rarely includes ~/.cargo/bin, so a full path is the usual answer
|
|
|
|
|
// here and it should not have to be spelled out longhand.
|
|
|
|
|
let prog = config::expand_tilde(&argv.remove(0));
|
2026-09-09 13:00:52 -07:00
|
|
|
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 {
|
2026-09-09 13:30:11 -07:00
|
|
|
// Deliberately not fatal. The dashboard is already on the wall by this
|
|
|
|
|
// point; exiting because the panel is missing would replace a display
|
|
|
|
|
// you cannot swipe with no display at all. Say so loudly and carry on
|
|
|
|
|
// serving the control socket, which is still a way to drive it.
|
|
|
|
|
if let Err(e) = spawn_panel(cfg, tx) {
|
|
|
|
|
eprintln!("socktop-swipe: touch gestures are NOT active: {e:#}");
|
|
|
|
|
eprintln!(
|
|
|
|
|
"socktop-swipe: the dashboard is up; drive it with `socktop-swipe forward` etc."
|
|
|
|
|
);
|
|
|
|
|
}
|
2026-09-09 13:00:52 -07:00
|
|
|
}
|
|
|
|
|
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)
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:30:11 -07:00
|
|
|
/// Open the touch panel, retrying briefly.
|
|
|
|
|
///
|
|
|
|
|
/// At boot the autostart can win the race against USB enumeration, so the
|
|
|
|
|
/// device is simply not there yet. Ten seconds covers that without making a
|
|
|
|
|
/// genuinely wrong device path take ten seconds to report.
|
|
|
|
|
fn open_panel(cfg: &Config) -> Result<Touchpanel> {
|
|
|
|
|
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
|
|
|
|
loop {
|
|
|
|
|
match Touchpanel::open(&cfg.touch) {
|
|
|
|
|
Ok(p) => return Ok(p),
|
|
|
|
|
Err(e) if std::time::Instant::now() < deadline => {
|
|
|
|
|
std::thread::sleep(std::time::Duration::from_millis(500));
|
|
|
|
|
let _ = e;
|
|
|
|
|
}
|
|
|
|
|
Err(e) => return Err(e),
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
}
|
|
|
|
|
|
2026-09-09 13:00:52 -07:00
|
|
|
/// 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<()> {
|
2026-09-09 13:30:11 -07:00
|
|
|
let mut panel = open_panel(cfg)?;
|
2026-09-09 13:00:52 -07:00
|
|
|
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);
|
|
|
|
|
}
|
2026-09-09 13:08:42 -07:00
|
|
|
let edge = if pos.changed {
|
|
|
|
|
""
|
|
|
|
|
} else {
|
|
|
|
|
" (edge, nothing to move to)"
|
|
|
|
|
};
|
2026-09-09 13:00:52 -07:00
|
|
|
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,
|
|
|
|
|
})
|
|
|
|
|
}
|