//! 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, #[command(subcommand)] command: Option, } #[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}"))?; // 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)); 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 { // 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." ); } } 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) } /// 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 { 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), } } } /// 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) -> Result<()> { let mut panel = open_panel(cfg)?; 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) -> 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, text: &str) { use std::io::Write; if let Some(mut r) = reply { let _ = writeln!(r, "{text}"); } } fn binding(g: &config::Gestures, d: Direction) -> Option { 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, }) }