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
+143
View File
@@ -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)]);
}
}
+466
View File
@@ -0,0 +1,466 @@
//! Config file types, defaults and path resolution.
//!
//! The screen entry is deliberately a flat struct rather than a tagged enum:
//! every monitor type accepts `command:` and `title:` overrides, future types
//! add optional fields without restructuring, and validation errors can name
//! the offending screen by coordinate instead of surfacing as a serde variant
//! mismatch.
pub mod coord;
use std::collections::BTreeSet;
use std::fmt;
use std::path::{Path, PathBuf};
use anyhow::{bail, Context, Result};
use serde::de::{self, Deserializer, SeqAccess, Visitor};
use serde::Deserialize;
pub use coord::Coord;
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Config {
#[serde(default = "default_session")]
pub session: String,
/// Terminal used by the autostart the installer writes, and by `run`.
#[serde(default)]
pub terminal: Option<String>,
/// Show a position indicator in the tmux status line.
#[serde(default)]
pub indicator: bool,
#[serde(default)]
pub binaries: Binaries,
pub touch: Touch,
#[serde(default)]
pub gestures: Gestures,
pub screens: Vec<Screen>,
}
fn default_session() -> String {
"socktop-swipe".into()
}
/// Full paths to the monitor programs. i3 and non-login shells do not have
/// `~/.cargo/bin` on PATH, so resolving this in one place stops every screen
/// entry from needing an absolute path.
#[derive(Debug, Clone, Default, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Binaries {
pub socktop: Option<String>,
#[serde(rename = "uptime-kuma-status", alias = "uptime_kuma_status")]
pub uptime_kuma_status: Option<String>,
pub unifly: Option<String>,
}
impl Binaries {
pub fn get(&self, kind: MonitorType) -> String {
let (configured, fallback) = match kind {
MonitorType::Socktop => (&self.socktop, "socktop"),
MonitorType::UptimeKumaStatus => (&self.uptime_kuma_status, "uptime-kuma-status"),
MonitorType::Unifly => (&self.unifly, "unifly"),
MonitorType::Generic => (&None, ""),
};
expand_tilde(configured.as_deref().unwrap_or(fallback))
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Touch {
pub device: String,
/// The PANEL's resolution, not the X screen. With a second monitor attached
/// X reports the combined root window, which skews edge and distance maths.
pub width: u32,
pub height: u32,
/// Take the device exclusively (EVIOCGRAB) so X never sees the touches.
#[serde(default = "yes")]
pub grab: bool,
/// Pixels of travel before a drag counts as a swipe.
#[serde(default = "default_threshold")]
pub threshold: u32,
/// Degrees off-axis tolerated, max 45.
#[serde(default = "default_leniency")]
pub leniency: u32,
/// Contact counts accepted for one logical swipe. Many panels report 2 or 3
/// contacts for a physically one-finger swipe.
#[serde(default = "default_fingers")]
pub fingers: Vec<usize>,
}
fn yes() -> bool {
true
}
fn default_threshold() -> u32 {
80
}
fn default_leniency() -> u32 {
30
}
fn default_fingers() -> Vec<usize> {
vec![1, 2, 3]
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
pub enum Direction {
/// Right-to-left finger motion.
RL,
/// Left-to-right finger motion.
LR,
/// Down-to-up finger motion.
DU,
/// Up-to-down finger motion.
UD,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Gestures {
#[serde(default = "default_forward")]
pub forward: Direction,
#[serde(default = "default_back")]
pub back: Direction,
#[serde(default = "default_up")]
pub up: Direction,
#[serde(default = "default_down")]
pub down: Direction,
}
fn default_forward() -> Direction {
Direction::RL
}
fn default_back() -> Direction {
Direction::LR
}
fn default_up() -> Direction {
Direction::DU
}
fn default_down() -> Direction {
Direction::UD
}
impl Default for Gestures {
fn default() -> Self {
Self {
forward: default_forward(),
back: default_back(),
up: default_up(),
down: default_down(),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum MonitorType {
Socktop,
#[serde(alias = "uptime_kuma_status", alias = "kuma")]
UptimeKumaStatus,
Unifly,
Generic,
}
impl fmt::Display for MonitorType {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Self::Socktop => "socktop",
Self::UptimeKumaStatus => "uptime-kuma-status",
Self::Unifly => "unifly",
Self::Generic => "generic",
})
}
}
/// tmux layout for a cell's overview.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub enum Layout {
#[default]
Tiled,
EvenHorizontal,
EvenVertical,
MainHorizontal,
MainVertical,
}
impl Layout {
pub fn as_tmux(&self) -> &'static str {
match self {
Self::Tiled => "tiled",
Self::EvenHorizontal => "even-horizontal",
Self::EvenVertical => "even-vertical",
Self::MainHorizontal => "main-horizontal",
Self::MainVertical => "main-vertical",
}
}
}
/// A list of socktop profile names, written either as a YAML sequence or as a
/// comma-separated string.
#[derive(Debug, Clone, Default)]
pub struct HostList(pub Vec<String>);
impl<'de> Deserialize<'de> for HostList {
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
struct V;
impl<'de> Visitor<'de> for V {
type Value = HostList;
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
f.write_str("a list of host names, or a comma-separated string")
}
fn visit_str<E: de::Error>(self, v: &str) -> Result<HostList, E> {
Ok(HostList(
v.split(',')
.map(str::trim)
.filter(|s| !s.is_empty())
.map(str::to_owned)
.collect(),
))
}
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<HostList, A::Error> {
let mut out = Vec::new();
while let Some(s) = seq.next_element::<String>()? {
let s = s.trim().to_owned();
if !s.is_empty() {
out.push(s);
}
}
Ok(HostList(out))
}
}
d.deserialize_any(V)
}
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Screen {
pub at: Coord,
#[serde(rename = "type")]
pub kind: MonitorType,
// -- socktop ---------------------------------------------------------
#[serde(default)]
pub socktop_group: Option<HostList>,
#[serde(default)]
pub layout: Option<Layout>,
// -- uptime-kuma-status ----------------------------------------------
#[serde(default)]
pub url: Option<String>,
// -- unifly: no parameters yet. `site` and `controller` are carved out
// here so adding them later is not a breaking config change.
#[serde(default)]
pub site: Option<String>,
#[serde(default)]
pub controller: Option<String>,
// -- any type ---------------------------------------------------------
/// Replaces the generated command outright. Required for `generic`.
#[serde(default)]
pub command: Option<String>,
/// Extra arguments appended to the generated command.
#[serde(default)]
pub args: Option<Vec<String>>,
/// Overrides the pane border label.
#[serde(default)]
pub title: Option<String>,
}
impl Config {
/// Search order: `--config`, then XDG, then `/etc`.
pub fn search_paths() -> Vec<PathBuf> {
let mut v = Vec::new();
if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME")
.map(PathBuf::from)
.filter(|p| p.is_absolute())
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
{
v.push(dir.join("socktop-swipe/config.yaml"));
}
v.push(PathBuf::from("/etc/socktop-swipe/config.yaml"));
v
}
pub fn locate(explicit: Option<&Path>) -> Result<PathBuf> {
if let Some(p) = explicit {
if !p.exists() {
bail!("no config file at {}", p.display());
}
return Ok(p.to_path_buf());
}
let searched = Self::search_paths();
searched
.iter()
.find(|p| p.exists())
.cloned()
.with_context(|| {
format!(
"no config file found. Looked in:\n{}\n\nWrite one there, or pass --config <path>. \
A commented starting point ships as config.example.yaml.",
searched
.iter()
.map(|p| format!(" {}", p.display()))
.collect::<Vec<_>>()
.join("\n")
)
})
}
pub fn load(path: &Path) -> Result<Self> {
let text = std::fs::read_to_string(path)
.with_context(|| format!("cannot read {}", path.display()))?;
let cfg: Config = serde_yaml::from_str(&text)
.with_context(|| format!("cannot parse {}", path.display()))?;
cfg.check()?;
Ok(cfg)
}
/// Structural checks that serde cannot express. Reports every problem it
/// finds rather than only the first, so one `validate` run is enough.
fn check(&self) -> Result<()> {
let mut errs: Vec<String> = Vec::new();
if self.screens.is_empty() {
errs.push("no screens defined -- the grid would be empty".into());
}
let mut seen: BTreeSet<Coord> = BTreeSet::new();
for s in &self.screens {
let at = s.at;
if !seen.insert(at) {
errs.push(format!("two screens both claim {at}"));
}
let mut wrong = |msg: String| errs.push(format!("screen {at} ({}): {msg}", s.kind));
match s.kind {
MonitorType::Socktop => {
match &s.socktop_group {
None if s.command.is_none() => wrong(
"needs socktop_group -- a list of socktop profile names, \
or a comma-separated string"
.into(),
),
Some(g) if g.0.is_empty() => {
wrong("socktop_group is empty".into())
}
_ => {}
}
if s.url.is_some() {
wrong("url is not a socktop parameter".into());
}
}
MonitorType::UptimeKumaStatus => {
if s.url.is_none() && s.command.is_none() {
wrong("needs url -- the public Uptime Kuma status page to render".into());
}
if s.socktop_group.is_some() {
wrong("socktop_group is not an uptime-kuma-status parameter".into());
}
}
MonitorType::Unifly => {
if s.socktop_group.is_some() {
wrong("socktop_group is not a unifly parameter".into());
}
}
MonitorType::Generic => {
if s.command.is_none() {
wrong("needs command -- generic screens have nothing else to run".into());
}
if s.socktop_group.is_some() || s.url.is_some() {
wrong(
"socktop_group and url are ignored for generic; put everything \
in command"
.into(),
);
}
}
}
if s.layout.is_some() && s.kind != MonitorType::Socktop {
wrong("layout only applies to socktop screens, which have several panes".into());
}
if let Some(c) = &s.command {
if shell_words::split(c).is_err() {
wrong(format!("command has unbalanced quotes: {c}"));
}
}
}
if self.touch.leniency > 45 {
errs.push(format!(
"touch.leniency is {} -- the maximum is 45 degrees, beyond which \
horizontal and vertical swipes cannot be told apart",
self.touch.leniency
));
}
if self.touch.fingers.is_empty() {
errs.push("touch.fingers is empty -- no contact count would ever match".into());
}
if self.touch.fingers.contains(&0) {
errs.push("touch.fingers contains 0 -- a swipe needs at least one contact".into());
}
if self.touch.width == 0 || self.touch.height == 0 {
errs.push("touch.width and touch.height must be the panel's real resolution".into());
}
let g = &self.gestures;
let horizontal = |d: Direction| matches!(d, Direction::RL | Direction::LR);
if horizontal(g.forward) != horizontal(g.back) {
errs.push("gestures.forward and gestures.back must be on the same axis".into());
}
if horizontal(g.up) || horizontal(g.down) {
errs.push("gestures.up and gestures.down must be vertical (DU or UD)".into());
}
let all = [g.forward, g.back, g.up, g.down];
for (i, a) in all.iter().enumerate() {
if all[i + 1..].contains(a) {
errs.push(format!("gesture {a:?} is bound to more than one action"));
}
}
if errs.is_empty() {
return Ok(());
}
bail!(
"{} problem{} in the config:\n{}",
errs.len(),
if errs.len() == 1 { "" } else { "s" },
errs.iter()
.map(|e| format!(" - {e}"))
.collect::<Vec<_>>()
.join("\n")
)
}
}
/// Expand a leading `~/`. Paths in the config are written by hand and `~` is
/// the natural thing to type, but nothing expands it when the value is passed
/// straight to exec.
pub fn expand_tilde(s: &str) -> String {
if let Some(rest) = s.strip_prefix("~/") {
if let Some(home) = std::env::var_os("HOME") {
return Path::new(&home).join(rest).to_string_lossy().into_owned();
}
}
s.to_owned()
}
+121
View File
@@ -0,0 +1,121 @@
//! A small control socket, so the grid can be driven without touching it.
//!
//! Two reasons this exists beyond testing: a wall display often has no keyboard
//! but the box it runs on does, and binding these to keys is the only way to
//! navigate if the panel dies. `run` and `daemon` listen; the bare movement
//! subcommands connect.
use std::io::{BufRead, BufReader, Write};
use std::os::unix::net::{UnixListener, UnixStream};
use std::path::PathBuf;
use std::sync::mpsc::Sender;
use anyhow::{bail, Context, Result};
use crate::grid::Move;
/// What the main loop reacts to, whatever produced it.
pub enum Ctl {
/// A move to make. The stream, when present, is a caller of the movement
/// subcommands waiting to be told where it ended up -- so `socktop-swipe
/// forward` is synchronous and scriptable rather than fire-and-forget.
Go(Move, Option<UnixStream>),
/// The session or the terminal went away; wind up cleanly.
Quit,
Failed(anyhow::Error),
}
pub fn socket_path(session: &str) -> PathBuf {
let dir = std::env::var_os("XDG_RUNTIME_DIR")
.map(PathBuf::from)
.unwrap_or_else(std::env::temp_dir);
dir.join(format!("socktop-swipe-{session}.sock"))
}
pub fn parse_move(s: &str) -> Option<Move> {
match s.trim() {
"forward" | "next" => Some(Move::Forward),
"back" | "prev" => Some(Move::Back),
"up" => Some(Move::Up),
"down" => Some(Move::Down),
_ => None,
}
}
/// Bind the socket and feed moves into `tx` until the listener is dropped.
pub fn listen(session: &str, tx: Sender<Ctl>) -> Result<UnixListener> {
let path = socket_path(session);
// A socket left behind by a killed process would block the bind. Only
// remove it once we know nobody is listening on it.
if path.exists() {
if UnixStream::connect(&path).is_ok() {
bail!(
"another socktop-swipe is already running for session {session:?} \
({}).\nStop it first, or use a different `session:` in the config.",
path.display()
);
}
let _ = std::fs::remove_file(&path);
}
let listener = UnixListener::bind(&path)
.with_context(|| format!("cannot create control socket at {}", path.display()))?;
let accepting = listener.try_clone().context("cannot clone control socket")?;
std::thread::spawn(move || {
for stream in accepting.incoming() {
let Ok(stream) = stream else { continue };
let reply = stream.try_clone().ok();
let mut lines = BufReader::new(stream).lines();
let Some(Ok(line)) = lines.next() else { continue };
match parse_move(&line) {
Some(m) => {
if tx.send(Ctl::Go(m, reply)).is_err() {
return;
}
}
None => {
if let Some(mut r) = reply {
let _ = writeln!(r, "error: {:?} is not a direction", line.trim());
}
}
}
}
});
Ok(listener)
}
/// Send one move to a running instance and wait to hear where it landed.
pub fn send(session: &str, m: &str) -> Result<String> {
let path = socket_path(session);
let mut stream = UnixStream::connect(&path).with_context(|| {
format!(
"no socktop-swipe listening for session {session:?} at {}.\n\
Start one with `socktop-swipe run`.",
path.display()
)
})?;
writeln!(stream, "{m}").context("cannot write to the control socket")?;
stream.shutdown(std::net::Shutdown::Write).ok();
let mut reply = String::new();
BufReader::new(&stream)
.read_line(&mut reply)
.context("no reply from socktop-swipe")?;
let reply = reply.trim().to_owned();
if let Some(e) = reply.strip_prefix("error: ") {
bail!("{e}");
}
Ok(reply)
}
/// Removes the socket file when the listener goes away.
pub struct SocketGuard(pub PathBuf);
impl Drop for SocketGuard {
fn drop(&mut self) {
let _ = std::fs::remove_file(&self.0);
}
}
+71
View File
@@ -0,0 +1,71 @@
//! Touch diagnostics. Replaces v1's tools/diag.sh and tools/find-device.sh.
//!
//! The question it exists to answer is "the panel works, so why does nothing
//! happen?" -- which in v1 meant reading lisgd's `Cfg(f=1) <=> Evt(f=2)` output
//! and knowing that meant the contact count. Here the answer is printed in
//! English instead.
use anyhow::{bail, Result};
use crate::config::Config;
use crate::input::{list_touchscreens, Event, Touchpanel};
pub fn list() -> Result<()> {
let found = list_touchscreens();
if found.is_empty() {
bail!(
"no multitouch devices found under /dev/input/by-id/.\n\
If the panel is plugged in, you may not have permission to read the \
devices: install the udev rule or join the 'input' group and log back in."
);
}
println!("Multitouch devices:\n");
for (path, name) in found {
println!(" {name}\n {path}\n");
}
println!("Put the path in touch.device. Always the by-id path -- eventN numbers");
println!("get reshuffled on reboot or USB re-enumeration.");
Ok(())
}
pub fn run(cfg: &Config) -> Result<()> {
println!("Watching {}", cfg.touch.device);
println!(
" panel {}x{}, threshold {}px, leniency {}\u{b0}, contacts accepted: {:?}",
cfg.touch.width, cfg.touch.height, cfg.touch.threshold, cfg.touch.leniency, cfg.touch.fingers
);
println!(
" grab: {}\n",
if cfg.touch.grab {
"yes -- X will not see these touches"
} else {
"no -- X also receives these touches"
}
);
println!("Swipe left, right, up and down. Ctrl-C when done.\n");
let mut panel = Touchpanel::open(&cfg.touch)?;
let g = &cfg.gestures;
panel.run(|ev| {
match ev {
Event::Swipe(s) => {
let action = match s.direction {
d if d == g.forward => "forward",
d if d == g.back => "back",
d if d == g.up => "up",
d if d == g.down => "down",
_ => "not bound to anything",
};
println!(
" {:?} swipe, {} contact(s) -> {action}",
s.direction, s.fingers
);
}
Event::Discarded(dir, why) => {
println!(" {dir:?} swipe ignored: {why}");
}
}
true
})
}
+479
View File
@@ -0,0 +1,479 @@
//! The navigation state machine. Pure: no tmux, no evdev, no I/O.
//!
//! Model (notes/PLAN-v2.md section 2):
//!
//! * Coordinates are sparse ordinals. Rows sort ascending, cells sort by column
//! within a row, and movement steps to the next *defined* neighbour. `0x1` and
//! `0x5` are interchangeable as long as they sort the same way.
//! * A socktop group is ONE cell containing a sub-sequence: tiled overview, then
//! each host zoomed. Horizontal movement walks that sub-sequence and only
//! leaves the cell after its last sub-screen.
//! * Entering a cell horizontally from the left lands on its first sub-screen,
//! from the right on its last.
//! * Vertical movement returns to where you were in that row if you have been
//! there before. Snapping to the nearest column only happens on first entry.
//! * No wrap-around at any edge: on a wall display, wrapping makes it impossible
//! to tell where you are.
use anyhow::{bail, Result};
use crate::config::Coord;
use crate::monitor::Cell;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Move {
Forward,
Back,
Up,
Down,
}
#[derive(Debug)]
pub struct Row {
pub row: i32,
/// Sorted by column ascending.
pub cells: Vec<Cell>,
/// Index into `cells`. Persists so a vertical return restores it.
pub cursor: usize,
visited: bool,
}
#[derive(Debug)]
pub struct Grid {
/// Sorted by row ascending. Row increases downward, so index 0 is the top.
pub rows: Vec<Row>,
pub cursor: usize,
}
/// Where the grid ended up after a move. `changed` is false when the move hit
/// an edge, which the caller uses to skip pointless tmux work.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Position {
pub coord: Coord,
/// Sub-screen within the cell.
pub screen: usize,
pub changed: bool,
}
impl Grid {
pub fn new(cells: Vec<Cell>) -> Result<Self> {
if cells.is_empty() {
bail!("no screens defined -- the grid would be empty");
}
let mut row_numbers: Vec<i32> = cells.iter().map(|c| c.coord.row).collect();
row_numbers.sort_unstable();
row_numbers.dedup();
let rows: Vec<Row> = row_numbers
.into_iter()
.map(|r| {
let mut cells: Vec<Cell> =
cells.iter().filter(|c| c.coord.row == r).cloned().collect();
cells.sort_by_key(|c| c.coord.col);
Row { row: r, cells, cursor: 0, visited: false }
})
.collect();
// Start at 0x0 when it exists; otherwise the leftmost cell of row 0, and
// failing that the top-left of the whole grid.
let cursor = rows
.iter()
.position(|r| r.row == 0)
.unwrap_or(0);
let mut grid = Grid { rows, cursor };
let start_col = grid.rows[cursor].cells.iter().position(|c| c.coord.col == 0);
grid.rows[cursor].cursor = start_col.unwrap_or(0);
grid.rows[cursor].visited = true;
Ok(grid)
}
pub fn row(&self) -> &Row {
&self.rows[self.cursor]
}
pub fn cell(&self) -> &Cell {
let r = self.row();
&r.cells[r.cursor]
}
fn cell_mut(&mut self) -> &mut Cell {
let r = &mut self.rows[self.cursor];
&mut r.cells[r.cursor]
}
pub fn position(&self, changed: bool) -> Position {
let c = self.cell();
Position { coord: c.coord, screen: c.cursor, changed }
}
pub fn apply(&mut self, m: Move) -> Position {
let changed = match m {
Move::Forward => self.forward(),
Move::Back => self.back(),
Move::Up => self.vertical(true),
Move::Down => self.vertical(false),
};
self.position(changed)
}
fn forward(&mut self) -> bool {
if self.cell().cursor < self.cell().last_screen() {
self.cell_mut().cursor += 1;
return true;
}
let row = &mut self.rows[self.cursor];
if row.cursor + 1 < row.cells.len() {
row.cursor += 1;
// Entered from the left: land on the first sub-screen.
row.cells[row.cursor].cursor = 0;
return true;
}
false
}
fn back(&mut self) -> bool {
if self.cell().cursor > 0 {
self.cell_mut().cursor -= 1;
return true;
}
let row = &mut self.rows[self.cursor];
if row.cursor > 0 {
row.cursor -= 1;
// Entered from the right: land on the last sub-screen, so the
// carousel reads as continuous rather than jumping to an overview.
let last = row.cells[row.cursor].last_screen();
row.cells[row.cursor].cursor = last;
return true;
}
false
}
fn vertical(&mut self, up: bool) -> bool {
let Some(target) = (if up {
self.cursor.checked_sub(1)
} else {
(self.cursor + 1 < self.rows.len()).then(|| self.cursor + 1)
}) else {
return false;
};
let from_col = self.cell().coord.col;
let row = &mut self.rows[target];
// Return memory wins. Snapping only decides the very first entry.
if !row.visited {
row.cursor = nearest_column(&row.cells, from_col);
row.visited = true;
}
self.cursor = target;
true
}
/// Every cell in grid order, for `validate` and session building.
pub fn cells(&self) -> impl Iterator<Item = &Cell> {
self.rows.iter().flat_map(|r| r.cells.iter())
}
/// The cell above and below the current one, for the indicator.
pub fn neighbours(&self) -> (Option<&Cell>, Option<&Cell>) {
let peek = |i: Option<usize>| -> Option<&Cell> {
let r = &self.rows[i?];
r.cells.get(if r.visited {
r.cursor
} else {
nearest_column(&r.cells, self.cell().coord.col)
})
};
(
peek(self.cursor.checked_sub(1)),
peek((self.cursor + 1 < self.rows.len()).then(|| self.cursor + 1)),
)
}
}
/// Index of the cell whose column is closest to `col`. Exact match wins; ties
/// break toward the lower column. `cells` is sorted by column and non-empty.
fn nearest_column(cells: &[Cell], col: i32) -> usize {
cells
.iter()
.enumerate()
.min_by_key(|(_, c)| ((c.coord.col - col).abs(), c.coord.col))
.map(|(i, _)| i)
.unwrap_or(0)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::config::{Layout, MonitorType};
use crate::monitor::Pane;
/// A cell at `coord` with `n` panes -- so `n + 1` sub-screens when n > 1.
fn cell(coord: &str, n: usize) -> Cell {
let coord: Coord = coord.parse().unwrap();
Cell {
coord,
kind: MonitorType::Socktop,
label: format!("{coord}"),
panes: (0..n)
.map(|i| Pane { title: format!("{coord}#{i}"), command: vec!["true".into()] })
.collect(),
layout: Layout::Tiled,
cursor: 0,
}
}
/// Walk a sequence of moves, collecting "coord@screen" after each.
fn walk(g: &mut Grid, moves: &[Move]) -> Vec<String> {
moves
.iter()
.map(|&m| {
let p = g.apply(m);
format!("{}@{}", p.coord, p.screen)
})
.collect()
}
fn at(g: &Grid) -> String {
let p = g.position(true);
format!("{}@{}", p.coord, p.screen)
}
// -- sub-screen expansion ------------------------------------------------
#[test]
fn multi_pane_cell_is_overview_plus_each_pane() {
let c = cell("0x0", 4);
assert_eq!(c.screens(), 5);
assert!(c.has_overview());
}
#[test]
fn single_pane_cell_is_one_screen_with_nothing_to_zoom() {
// An overview of one pane and that pane zoomed are the same picture.
let c = cell("0x0", 1);
assert_eq!(c.screens(), 1);
assert!(!c.has_overview());
assert_eq!(c.zoomed_pane(), Some(0));
}
#[test]
fn zoomed_pane_maps_cursor_past_the_overview() {
let mut c = cell("0x0", 3);
assert_eq!(c.zoomed_pane(), None); // overview
c.cursor = 1;
assert_eq!(c.zoomed_pane(), Some(0));
c.cursor = 3;
assert_eq!(c.zoomed_pane(), Some(2));
}
// -- horizontal ----------------------------------------------------------
#[test]
fn forward_walks_sub_screens_then_moves_to_the_next_cell() {
let mut g = Grid::new(vec![cell("0x0", 4), cell("0x1", 2)]).unwrap();
assert_eq!(at(&g), "0x0@0");
assert_eq!(
walk(&mut g, &[Move::Forward; 5]),
["0x0@1", "0x0@2", "0x0@3", "0x0@4", "0x1@0"]
);
}
#[test]
fn entering_from_the_right_lands_on_the_last_sub_screen() {
let mut g = Grid::new(vec![cell("0x0", 4), cell("0x1", 2)]).unwrap();
walk(&mut g, &[Move::Forward; 5]);
assert_eq!(at(&g), "0x1@0");
// Back out of 0x1 and into 0x0 -- should be 0x0's LAST host, not its
// overview, or the carousel jumps.
assert_eq!(walk(&mut g, &[Move::Back]), ["0x0@4"]);
}
#[test]
fn no_wrap_around_at_either_end() {
let mut g = Grid::new(vec![cell("0x0", 2), cell("0x1", 1)]).unwrap();
for _ in 0..10 {
g.apply(Move::Back);
}
assert_eq!(at(&g), "0x0@0", "should stop at the first sub-screen");
for _ in 0..20 {
g.apply(Move::Forward);
}
assert_eq!(at(&g), "0x1@0", "should stop at the last cell");
}
#[test]
fn edge_moves_report_unchanged() {
let mut g = Grid::new(vec![cell("0x0", 2)]).unwrap();
assert!(!g.apply(Move::Back).changed);
assert!(g.apply(Move::Forward).changed);
assert!(g.apply(Move::Forward).changed);
assert!(!g.apply(Move::Forward).changed);
}
#[test]
fn sparse_columns_are_pure_ordering() {
// 0x0, 0x5, 0x99 must behave exactly like 0x0, 0x1, 0x2.
let mut sparse = Grid::new(vec![cell("0x0", 1), cell("0x5", 1), cell("0x99", 1)]).unwrap();
let mut dense = Grid::new(vec![cell("0x0", 1), cell("0x1", 1), cell("0x2", 1)]).unwrap();
let moves = [Move::Forward, Move::Forward, Move::Back, Move::Forward];
let s: Vec<_> = walk(&mut sparse, &moves).iter().map(|p| p.split('@').nth(1).unwrap().to_string()).collect();
let d: Vec<_> = walk(&mut dense, &moves).iter().map(|p| p.split('@').nth(1).unwrap().to_string()).collect();
assert_eq!(s, d);
assert_eq!(sparse.cell().coord, Coord::new(0, 99));
assert_eq!(dense.cell().coord, Coord::new(0, 2));
}
#[test]
fn columns_out_of_order_in_the_file_still_sort() {
let mut g = Grid::new(vec![cell("0x2", 1), cell("0x0", 1), cell("0x1", 1)]).unwrap();
assert_eq!(at(&g), "0x0@0");
assert_eq!(walk(&mut g, &[Move::Forward, Move::Forward]), ["0x1@0", "0x2@0"]);
}
// -- vertical ------------------------------------------------------------
#[test]
fn default_rack_layout_round_trips_to_the_same_host() {
// -1x0 unifly, 0x0 four Pis, 0x1 two boxes, 1x0 kuma.
let mut g = Grid::new(vec![
cell("-1x0", 1),
cell("0x0", 4),
cell("0x1", 2),
cell("1x0", 1),
])
.unwrap();
// Zoom into the third Pi.
walk(&mut g, &[Move::Forward, Move::Forward, Move::Forward]);
assert_eq!(at(&g), "0x0@3");
// Up to unifly, and back down to exactly the same Pi.
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x0@0"]);
assert_eq!(walk(&mut g, &[Move::Down]), ["0x0@3"]);
// Down again to kuma, and back up to the same Pi.
assert_eq!(walk(&mut g, &[Move::Down]), ["1x0@0"]);
assert_eq!(walk(&mut g, &[Move::Up]), ["0x0@3"]);
}
#[test]
fn horizontal_swipes_on_a_single_cell_row_do_nothing() {
let mut g = Grid::new(vec![cell("-1x0", 1), cell("0x0", 2)]).unwrap();
g.apply(Move::Up);
assert_eq!(at(&g), "-1x0@0");
assert!(!g.apply(Move::Forward).changed);
assert!(!g.apply(Move::Back).changed);
assert_eq!(at(&g), "-1x0@0");
}
#[test]
fn return_memory_beats_snapping() {
// Row -1 has only column 0, so leaving from 0x1 must snap on the way up
// but must NOT snap on the way back down.
let mut g = Grid::new(vec![cell("-1x0", 1), cell("0x0", 2), cell("0x1", 3)]).unwrap();
// 0x0 has 3 sub-screens and 0x1 has 4, so five forwards lands us in the
// MIDDLE of 0x1 -- a return to "0x1@0" would look like success otherwise.
assert_eq!(
walk(&mut g, &[Move::Forward; 5]),
["0x0@1", "0x0@2", "0x1@0", "0x1@1", "0x1@2"]
);
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x0@0"], "snapped on first entry");
assert_eq!(
walk(&mut g, &[Move::Down]),
["0x1@2"],
"must return to 0x1 sub-screen 2, not snap to 0x0"
);
}
#[test]
fn snapping_picks_the_nearest_column_on_first_entry_only() {
let mut g = Grid::new(vec![
cell("-1x1", 1),
cell("-1x9", 1),
cell("0x0", 1),
cell("0x8", 1),
])
.unwrap();
g.apply(Move::Forward); // to 0x8
assert_eq!(at(&g), "0x8@0");
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x9@0"], "9 is nearer to 8 than 1");
}
#[test]
fn snap_ties_break_toward_the_lower_column() {
// From 0x2, both -1x1 and -1x3 are distance 1.
let mut g = Grid::new(vec![cell("-1x1", 1), cell("-1x3", 1), cell("0x2", 1)]).unwrap();
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x1@0"]);
}
#[test]
fn vertical_steps_to_the_next_defined_row_however_numbered() {
// Rows -7, 0 and 42 must behave exactly like -1, 0 and 1.
let mut g = Grid::new(vec![cell("-7x0", 1), cell("0x0", 1), cell("42x0", 1)]).unwrap();
assert_eq!(walk(&mut g, &[Move::Up]), ["-7x0@0"]);
assert_eq!(walk(&mut g, &[Move::Down, Move::Down]), ["0x0@0", "42x0@0"]);
assert!(!g.apply(Move::Down).changed, "no row below 42");
}
#[test]
fn no_vertical_movement_in_a_single_row_grid() {
let mut g = Grid::new(vec![cell("0x0", 2), cell("0x1", 2)]).unwrap();
assert!(!g.apply(Move::Up).changed);
assert!(!g.apply(Move::Down).changed);
}
#[test]
fn horizontal_movement_updates_what_a_vertical_return_restores() {
let mut g = Grid::new(vec![cell("-1x0", 1), cell("0x0", 1), cell("0x1", 1)]).unwrap();
g.apply(Move::Up);
g.apply(Move::Down); // row 0 remembered 0x0
assert_eq!(at(&g), "0x0@0");
g.apply(Move::Forward); // now at 0x1
g.apply(Move::Up);
assert_eq!(walk(&mut g, &[Move::Down]), ["0x1@0"], "memory follows the move");
}
// -- start position ------------------------------------------------------
#[test]
fn starts_at_0x0_when_it_exists() {
let g = Grid::new(vec![cell("1x0", 1), cell("-1x0", 1), cell("0x0", 1)]).unwrap();
assert_eq!(g.cell().coord, Coord::new(0, 0));
}
#[test]
fn starts_at_the_leftmost_cell_of_row_0_when_0x0_is_missing() {
let g = Grid::new(vec![cell("0x7", 1), cell("0x3", 1), cell("-1x0", 1)]).unwrap();
assert_eq!(g.cell().coord, Coord::new(0, 3));
}
#[test]
fn starts_at_the_top_left_when_there_is_no_row_0() {
let g = Grid::new(vec![cell("5x2", 1), cell("3x9", 1), cell("3x4", 1)]).unwrap();
assert_eq!(g.cell().coord, Coord::new(3, 4));
}
#[test]
fn empty_grid_is_rejected() {
assert!(Grid::new(vec![]).is_err());
}
// -- indicator -----------------------------------------------------------
#[test]
fn neighbours_reports_the_rows_above_and_below() {
let mut g = Grid::new(vec![cell("-1x0", 1), cell("0x0", 2), cell("1x0", 1)]).unwrap();
let (up, down) = g.neighbours();
assert_eq!(up.unwrap().coord, Coord::new(-1, 0));
assert_eq!(down.unwrap().coord, Coord::new(1, 0));
g.apply(Move::Up);
let (up, down) = g.neighbours();
assert!(up.is_none(), "nothing above the top row");
assert_eq!(down.unwrap().coord, Coord::new(0, 0));
}
}
+395
View File
@@ -0,0 +1,395 @@
//! Touch gestures straight from the kernel, replacing lisgd.
//!
//! libinput deliberately emits gesture events only for touchpads, never for
//! touchscreens, so libinput-gestures and friends cannot work here at all. We
//! read multitouch protocol B from the event device and synthesise swipes.
//!
//! Protocol B reports each contact in a numbered slot: `ABS_MT_SLOT` selects the
//! slot, `ABS_MT_TRACKING_ID` of -1 lifts it, and `ABS_MT_POSITION_X/Y` update
//! it. We record where each slot started and where it ended, then decide on the
//! release of the last contact.
use std::collections::HashMap;
use std::path::Path;
use anyhow::{bail, Context, Result};
use evdev::{AbsoluteAxisType, Device, InputEventKind};
use crate::config::{Direction, Touch};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Swipe {
pub direction: Direction,
/// Peak simultaneous contacts during the gesture.
pub fingers: usize,
}
#[derive(Debug, Clone, Copy)]
struct Slot {
start: (i32, i32),
last: (i32, i32),
active: bool,
}
pub struct Touchpanel {
device: Device,
cfg: Touch,
}
/// Why a candidate gesture was not emitted. Only ever shown by `doctor`.
#[derive(Debug, Clone)]
pub enum Rejected {
TooShort { travel: f64, threshold: u32 },
OffAxis { degrees: f64, leniency: u32 },
WrongFingerCount { saw: usize, want: Vec<usize> },
}
impl std::fmt::Display for Rejected {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
Self::TooShort { travel, threshold } => write!(
f,
"travelled {travel:.0}px, needs {threshold}px (touch.threshold)"
),
Self::OffAxis { degrees, leniency } => write!(
f,
"{degrees:.0}\u{b0} off axis, tolerance is {leniency}\u{b0} (touch.leniency)"
),
Self::WrongFingerCount { saw, want } => write!(
f,
"saw {saw} contact(s), config accepts {want:?} -- add {saw} to touch.fingers"
),
}
}
}
#[derive(Debug, Clone)]
pub enum Event {
Swipe(Swipe),
/// A gesture was seen and discarded. Carries the direction it would have
/// been, so `doctor` can say "that was a left swipe, but ...".
Discarded(Direction, Rejected),
}
impl Touchpanel {
pub fn open(cfg: &Touch) -> Result<Self> {
let path = Path::new(&cfg.device);
if !path.exists() {
bail!(
"{} does not exist.\n\
Touch devices move around when USB re-enumerates -- always use a \
/dev/input/by-id/ path, never eventN.\n\
Run `socktop-swipe doctor --list` to see what is present.",
cfg.device
);
}
let mut device = Device::open(path).with_context(|| {
format!(
"cannot open {}.\n\
Reading touch events needs access to the device. Either install the \
udev rule (packaging/70-socktop-swipe.rules) or add yourself to the \
'input' group and log out and back in.",
cfg.device
)
})?;
let has_mt = device
.supported_absolute_axes()
.is_some_and(|a| a.contains(AbsoluteAxisType::ABS_MT_POSITION_X));
if !has_mt {
bail!(
"{} does not report multitouch positions -- it is probably not the \
touchscreen.\nRun `socktop-swipe doctor --list`.",
cfg.device
);
}
if cfg.grab {
// Take the device exclusively so X never sees the touches. This is
// what the v1 xorg.conf.d "Ignore" rule was faking, and it needs no
// X restart or relogin.
device.grab().with_context(|| {
format!(
"cannot take exclusive control of {}.\n\
Something else may already hold it (another socktop-swipe?). \
Set touch.grab: false to share the device with X, but then also \
install the X ignore rule -- see the README.",
cfg.device
)
})?;
}
Ok(Self { device, cfg: cfg.clone() })
}
/// Blocking gesture loop. Calls `on_event` for every completed gesture,
/// including rejected ones, and stops when it returns `false`.
pub fn run(&mut self, mut on_event: impl FnMut(Event) -> bool) -> Result<()> {
let mut slots: HashMap<i32, Slot> = HashMap::new();
let mut current: i32 = 0;
let mut peak: usize = 0;
loop {
for ev in self.device.fetch_events().context("reading touch events")? {
match ev.kind() {
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
current = ev.value();
}
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
if ev.value() < 0 {
if let Some(s) = slots.get_mut(&current) {
s.active = false;
}
} else {
slots.insert(
current,
Slot { start: (0, 0), last: (0, 0), active: true },
);
peak = peak.max(slots.values().filter(|s| s.active).count());
}
}
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
update(&mut slots, current, |p| p.0 = ev.value());
}
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => {
update(&mut slots, current, |p| p.1 = ev.value());
}
_ => {}
}
}
// The gesture ends when the last contact lifts.
if !slots.is_empty() && slots.values().all(|s| !s.active) {
let tracks: Vec<Track> =
slots.values().map(|s| (s.start, s.last)).collect();
if let Some(ev) = classify(&self.cfg, &tracks, peak) {
if !on_event(ev) {
return Ok(());
}
}
slots.clear();
peak = 0;
}
}
}
}
/// One contact's journey: where it landed and where it lifted.
pub type Track = ((i32, i32), (i32, i32));
/// Decide what a completed gesture was. Split out from the device so the rules
/// -- threshold, leniency, contact count -- can be tested without hardware.
pub fn classify(cfg: &Touch, tracks: &[Track], peak: usize) -> Option<Event> {
// Average the contacts' travel: a "one finger" swipe that the panel reports
// as two or three contacts is one motion counted repeatedly, so the mean is
// the real displacement rather than a multiple of it.
let n = tracks.len() as f64;
if n == 0.0 {
return None;
}
let (dx, dy) = tracks.iter().fold((0.0, 0.0), |(ax, ay), (start, last)| {
(ax + (last.0 - start.0) as f64 / n, ay + (last.1 - start.1) as f64 / n)
});
let travel = (dx * dx + dy * dy).sqrt();
let horizontal = dx.abs() >= dy.abs();
let direction = match (horizontal, dx > 0.0, dy > 0.0) {
(true, true, _) => Direction::LR,
(true, false, _) => Direction::RL,
// Y grows downward on a touch panel, so a positive dy is a downward
// swipe: up-to-down.
(false, _, true) => Direction::UD,
(false, _, false) => Direction::DU,
};
if travel < cfg.threshold as f64 {
return Some(Event::Discarded(
direction,
Rejected::TooShort { travel, threshold: cfg.threshold },
));
}
// Angle away from the dominant axis.
let (along, across) = if horizontal { (dx.abs(), dy.abs()) } else { (dy.abs(), dx.abs()) };
let degrees = across.atan2(along).to_degrees();
if degrees > cfg.leniency as f64 {
return Some(Event::Discarded(
direction,
Rejected::OffAxis { degrees, leniency: cfg.leniency },
));
}
if !cfg.fingers.contains(&peak) {
return Some(Event::Discarded(
direction,
Rejected::WrongFingerCount { saw: peak, want: cfg.fingers.clone() },
));
}
Some(Event::Swipe(Swipe { direction, fingers: peak }))
}
fn update(slots: &mut HashMap<i32, Slot>, current: i32, f: impl Fn(&mut (i32, i32))) {
let slot = slots.entry(current).or_insert(Slot {
start: (0, 0),
last: (0, 0),
active: true,
});
f(&mut slot.last);
// The first position report after a contact begins is also its origin.
if slot.start == (0, 0) {
slot.start = slot.last;
}
}
/// Candidate touchscreens, for `doctor --list` and the installer.
pub fn list_touchscreens() -> Vec<(String, String)> {
let mut out = Vec::new();
let by_id = Path::new("/dev/input/by-id");
let entries = std::fs::read_dir(by_id).into_iter().flatten().flatten();
for e in entries {
let path = e.path();
let Ok(dev) = Device::open(&path) else { continue };
let multitouch = dev
.supported_absolute_axes()
.is_some_and(|a| a.contains(AbsoluteAxisType::ABS_MT_POSITION_X));
if multitouch {
out.push((
path.to_string_lossy().into_owned(),
dev.name().unwrap_or("unnamed device").to_owned(),
));
}
}
out.sort();
out
}
#[cfg(test)]
mod tests {
use super::*;
fn touch(threshold: u32, leniency: u32, fingers: Vec<usize>) -> Touch {
Touch {
device: "/dev/null".into(),
width: 1280,
height: 720,
grab: false,
threshold,
leniency,
fingers,
}
}
/// One contact travelling from `from` by `(dx, dy)`.
fn track(from: (i32, i32), dx: i32, dy: i32) -> Track {
(from, (from.0 + dx, from.1 + dy))
}
fn swipe(cfg: &Touch, tracks: &[Track], peak: usize) -> Event {
classify(cfg, tracks, peak).expect("a completed gesture should classify")
}
#[test]
fn recognises_the_four_directions() {
let cfg = touch(80, 30, vec![1]);
let cases = [
((-200, 0), Direction::RL),
((200, 0), Direction::LR),
// Y grows downward, so a negative dy is a swipe upward.
((0, -200), Direction::DU),
((0, 200), Direction::UD),
];
for ((dx, dy), want) in cases {
match swipe(&cfg, &[track((640, 360), dx, dy)], 1) {
Event::Swipe(s) => assert_eq!(s.direction, want, "({dx},{dy})"),
other => panic!("({dx},{dy}) should be a swipe, got {other:?}"),
}
}
}
#[test]
fn ghost_contacts_do_not_multiply_the_travel() {
// The ILITEK panel reports one physical finger as 2-3 contacts. Each
// reports the same motion, so the average must equal one finger's
// travel -- not the sum, which would make short drags look long.
let cfg = touch(150, 30, vec![1, 2, 3]);
let one = [track((900, 300), -100, 0)];
let three = [
track((900, 300), -100, 0),
track((902, 305), -100, 0),
track((898, 295), -100, 0),
];
for tracks in [&one[..], &three[..]] {
match classify(&cfg, tracks, tracks.len()) {
Some(Event::Discarded(Direction::RL, Rejected::TooShort { travel, .. })) => {
assert!((travel - 100.0).abs() < 1.0, "travel was {travel}");
}
other => panic!("100px under a 150px threshold should be too short: {other:?}"),
}
}
}
#[test]
fn short_drags_are_rejected_with_the_measurement() {
let cfg = touch(80, 30, vec![1]);
match swipe(&cfg, &[track((640, 360), -40, 0)], 1) {
Event::Discarded(Direction::RL, Rejected::TooShort { travel, threshold }) => {
assert_eq!(threshold, 80);
assert!((travel - 40.0).abs() < 0.01);
}
other => panic!("40px should be too short: {other:?}"),
}
}
#[test]
fn diagonal_swipes_are_rejected_past_the_leniency() {
let cfg = touch(80, 30, vec![1]);
// 45 degrees: equal travel on both axes, well past a 30 degree tolerance.
match swipe(&cfg, &[track((640, 360), -200, -200)], 1) {
Event::Discarded(_, Rejected::OffAxis { degrees, leniency }) => {
assert_eq!(leniency, 30);
assert!((degrees - 45.0).abs() < 0.01, "was {degrees}");
}
other => panic!("a 45 degree drag should be off-axis: {other:?}"),
}
// 20 degrees off: within tolerance, still a left swipe.
let dy = -(200.0 * 20f64.to_radians().tan()) as i32;
match swipe(&cfg, &[track((640, 360), -200, dy)], 1) {
Event::Swipe(s) => assert_eq!(s.direction, Direction::RL),
other => panic!("20 degrees off axis should pass: {other:?}"),
}
}
#[test]
fn unconfigured_contact_counts_are_rejected_and_name_the_fix() {
// The v1 failure that cost the most time: gestures detected perfectly,
// nothing ever fires, because the panel reports 2 contacts and the
// config accepts only 1.
let cfg = touch(80, 30, vec![1]);
match swipe(&cfg, &[track((900, 300), -200, 0), track((905, 305), -200, 0)], 2) {
Event::Discarded(Direction::RL, r @ Rejected::WrongFingerCount { saw, .. }) => {
assert_eq!(saw, 2);
assert!(r.to_string().contains("touch.fingers"), "{r}");
}
other => panic!("2 contacts against fingers:[1] should be rejected: {other:?}"),
}
}
#[test]
fn threshold_is_checked_before_the_contact_count() {
// Otherwise a stray tap on a panel with ghost contacts reports the
// finger-count problem, sending you to fix the wrong setting.
let cfg = touch(80, 30, vec![1]);
match swipe(&cfg, &[track((640, 360), -5, 0), track((641, 361), -5, 0)], 2) {
Event::Discarded(_, Rejected::TooShort { .. }) => {}
other => panic!("expected the short-travel reason first: {other:?}"),
}
}
#[test]
fn a_gesture_with_no_contacts_is_not_a_gesture() {
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
}
}
+12
View File
@@ -0,0 +1,12 @@
//! socktop-swipe: swipe between terminal dashboards on a touchscreen.
//!
//! The binary is a thin CLI over these modules. They are public so the
//! integration tests can drive a real tmux session without a touch panel.
pub mod config;
pub mod control;
pub mod doctor;
pub mod grid;
pub mod input;
pub mod monitor;
pub mod session;
+343
View File
@@ -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,
})
}
+169
View File
@@ -0,0 +1,169 @@
//! Turning a config screen into the panes tmux should run.
//!
//! Only `socktop` produces more than one pane today. The representation is a
//! plain `Vec<Pane>` per cell, so a future type wanting the same treatment is a
//! data change rather than a redesign.
use anyhow::{bail, Context, Result};
use crate::config::{expand_tilde, Binaries, Config, Coord, Layout, MonitorType, Screen};
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Pane {
/// Shown in the pane border.
pub title: String,
/// argv, already split. Never passed through a shell.
pub command: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct Cell {
pub coord: Coord,
pub kind: MonitorType,
/// Human label for the position indicator.
pub label: String,
pub panes: Vec<Pane>,
pub layout: Layout,
/// Which sub-screen of this cell is current. Persists while you are
/// elsewhere, so a vertical return lands where you left.
pub cursor: usize,
}
impl Cell {
/// A multi-pane cell shows a tiled overview first, then each pane zoomed.
/// A single-pane cell has nothing to zoom into, so it is one sub-screen.
pub fn has_overview(&self) -> bool {
self.panes.len() > 1
}
pub fn screens(&self) -> usize {
if self.has_overview() {
self.panes.len() + 1
} else {
1
}
}
pub fn last_screen(&self) -> usize {
self.screens() - 1
}
/// `None` for the overview, otherwise the pane index to zoom.
pub fn zoomed_pane(&self) -> Option<usize> {
match (self.has_overview(), self.cursor) {
(true, 0) => None,
(true, i) => Some(i - 1),
(false, _) => Some(0),
}
}
/// What the indicator shows for the current sub-screen.
pub fn screen_label(&self) -> String {
match self.zoomed_pane() {
None => format!("{} (all)", self.label),
Some(i) if self.has_overview() => self.panes[i].title.clone(),
Some(_) => self.label.clone(),
}
}
}
pub fn build_cell(screen: &Screen, bins: &Binaries) -> Result<Cell> {
let at = screen.at;
let extra = screen.args.clone().unwrap_or_default();
// An explicit `command:` replaces the generated one for every type.
if let Some(cmd) = &screen.command {
let mut argv = shell_words::split(cmd)
.with_context(|| format!("screen {at}: cannot parse command: {cmd}"))?;
if argv.is_empty() {
bail!("screen {at}: command is empty");
}
argv[0] = expand_tilde(&argv[0]);
argv.extend(extra);
let title = screen
.title
.clone()
.unwrap_or_else(|| screen.kind.to_string());
return Ok(Cell {
coord: at,
kind: screen.kind,
label: title.clone(),
panes: vec![Pane { title, command: argv }],
layout: screen.layout.unwrap_or_default(),
cursor: 0,
});
}
let bin = bins.get(screen.kind);
let (label, panes) = match screen.kind {
MonitorType::Socktop => {
let hosts = screen
.socktop_group
.as_ref()
.expect("validated: socktop needs socktop_group");
let panes = hosts
.0
.iter()
.map(|h| Pane {
title: h.clone(),
command: {
let mut c = vec![bin.clone(), "-P".into(), h.clone()];
c.extend(extra.clone());
c
},
})
.collect();
let label = if hosts.0.len() == 1 {
hosts.0[0].clone()
} else {
format!("{} hosts", hosts.0.len())
};
(label, panes)
}
MonitorType::UptimeKumaStatus => {
let url = screen.url.as_ref().expect("validated: kuma needs url");
let mut c = vec![bin, url.clone()];
c.extend(extra);
(
"uptime kuma".to_string(),
vec![Pane { title: "uptime kuma".into(), command: c }],
)
}
MonitorType::Unifly => {
let mut c = vec![bin, "tui".into()];
// Carved out for when the fork grows the flags; see notes/PLAN-v2.md.
if let Some(site) = &screen.site {
c.push("--site".into());
c.push(site.clone());
}
if let Some(controller) = &screen.controller {
c.push("--controller".into());
c.push(controller.clone());
}
c.extend(extra);
(
"unifly".to_string(),
vec![Pane { title: "unifly".into(), command: c }],
)
}
MonitorType::Generic => unreachable!("validated: generic always has a command"),
};
let label = screen.title.clone().unwrap_or(label);
Ok(Cell {
coord: at,
kind: screen.kind,
label,
panes,
layout: screen.layout.unwrap_or_default(),
cursor: 0,
})
}
pub fn build_cells(cfg: &Config) -> Result<Vec<Cell>> {
cfg.screens.iter().map(|s| build_cell(s, &cfg.binaries)).collect()
}
+32
View File
@@ -0,0 +1,32 @@
//! The multiplexer boundary.
//!
//! This module and its children are the ONLY place that knows tmux is in use.
//! zellij is shelved rather than rejected (notes/DESIGN.md); keeping the seam
//! here is what makes revisiting that cheap.
pub mod tmux;
use anyhow::Result;
use crate::grid::{Grid, Position};
pub trait Multiplexer {
/// Tear down any previous session and build one window per cell.
fn build(&self, grid: &Grid) -> Result<()>;
/// Learn the layout of a session that is already running, and check it
/// matches this grid.
fn adopt(&self, grid: &Grid) -> Result<()>;
/// Make `pos` the visible screen.
fn show(&self, grid: &Grid, pos: &Position) -> Result<()>;
/// True when the session exists right now.
fn is_running(&self) -> bool;
/// Replace this process with a client attached to the session.
fn attach(&self) -> Result<std::convert::Infallible>;
/// Command line that attaches a client, for handing to a terminal emulator.
fn attach_argv(&self) -> Vec<String>;
}
+303
View File
@@ -0,0 +1,303 @@
//! tmux implementation of [`Multiplexer`].
//!
//! Windows and panes are addressed by tmux *id* (`@3`, `%12`), captured at
//! creation, never by index. Indices renumber when a pane dies; ids do not, and
//! `remain-on-exit` cannot be relied on to hold the numbering stable if a
//! monitor program is killed by hand.
use std::cell::RefCell;
use std::collections::HashMap;
use std::os::unix::process::CommandExt;
use std::process::{Command, Stdio};
use anyhow::{anyhow, bail, Context, Result};
use crate::config::Coord;
use crate::grid::{Grid, Position};
use crate::monitor::Cell;
use super::Multiplexer;
#[derive(Debug, Clone)]
struct Placed {
window: String,
panes: Vec<String>,
}
pub struct Tmux {
session: String,
indicator: bool,
placed: RefCell<HashMap<Coord, Placed>>,
}
impl Tmux {
pub fn new(session: &str, indicator: bool) -> Self {
Self {
session: session.to_owned(),
indicator,
placed: RefCell::new(HashMap::new()),
}
}
fn run(&self, args: &[&str]) -> Result<String> {
let out = Command::new("tmux")
.args(args)
.stdin(Stdio::null())
.output()
.context("cannot run tmux -- is it installed and on PATH?")?;
if !out.status.success() {
bail!(
"tmux {} failed: {}",
args.join(" "),
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
}
/// tmux takes the command as one string and runs it through `sh -c`, so the
/// argv has to be quoted back into a shell word list. Building argv first
/// and quoting once here keeps every caller free of quoting concerns.
///
/// The command is wrapped so the pane outlives it. A monitor that exits --
/// a typo in a `generic` command, a socktop that cannot reach its agent --
/// would otherwise take its pane with it, and tmux destroys a window when
/// its last pane goes. During construction that breaks the next
/// `split-window` with a baffling "no current target"; afterwards it
/// silently reshuffles the display. Keeping the pane means the failure is
/// visible ON the wall display, with its exit status, which is the whole
/// point of a wall display.
///
/// This replaces v1's `remain-on-exit`, which cannot do the job: it is a
/// per-window option that new windows do not inherit, so there is always a
/// gap between creating a window and setting it.
fn shell_command(argv: &[String]) -> String {
let cmd = shell_words::join(argv.iter().map(String::as_str));
let name = shell_words::quote(&argv[0]).into_owned();
format!(
"{cmd}; s=$?; printf '\\n[%s exited: status %s]\\n' {name} \"$s\"; \
while :; do sleep 86400; done"
)
}
fn zoomed(&self, window: &str) -> Result<bool> {
Ok(self.run(&["display-message", "-p", "-t", window, "#{window_zoomed_flag}"])? == "1")
}
fn place(&self, cell: &Cell, first: bool) -> Result<Placed> {
let name = cell.coord.window_name();
let head = Self::shell_command(&cell.panes[0].command);
let window = if first {
self.run(&[
"new-session", "-d", "-s", &self.session, "-n", &name,
"-P", "-F", "#{window_id}", &head,
])?
} else {
self.run(&[
"new-window", "-d", "-t", &self.session, "-n", &name,
"-P", "-F", "#{window_id}", &head,
])?
};
let first_pane = self.run(&[
"display-message", "-p", "-t", &window, "#{pane_id}",
])?;
self.run(&["select-pane", "-t", &first_pane, "-T", &cell.panes[0].title])?;
let mut panes = vec![first_pane];
for pane in &cell.panes[1..] {
// Split the most recently created pane so creation order matches
// the visual order the layout will impose.
let id = self.run(&[
"split-window", "-t", panes.last().unwrap(), "-P", "-F", "#{pane_id}",
&Self::shell_command(&pane.command),
])?;
self.run(&["select-pane", "-t", &id, "-T", &pane.title])?;
panes.push(id);
}
if panes.len() > 1 {
self.run(&["select-layout", "-t", &window, cell.layout.as_tmux()])?;
}
Ok(Placed { window, panes })
}
/// Learn the window and pane ids of a session someone else built.
///
/// `daemon` drives a session it did not create, so it has none of the ids
/// `build` captured. Rediscovering them by window name is also the check
/// that the running session actually matches this config -- otherwise the
/// daemon would cheerfully drive a stale layout.
fn adopt(&self, grid: &Grid) -> Result<()> {
let listing = self.run(&[
"list-windows", "-t", &self.session, "-F", "#{window_id} #{window_name}",
])?;
let mut by_name: HashMap<&str, &str> = HashMap::new();
for line in listing.lines() {
if let Some((id, name)) = line.split_once(' ') {
by_name.insert(name, id);
}
}
let mut placed = HashMap::new();
for cell in grid.cells() {
let name = cell.coord.window_name();
let window = by_name.get(name.as_str()).copied().ok_or_else(|| {
anyhow!(
"the running session {:?} has no window for {} -- it was built from a \
different config.\nRebuild it with `socktop-swipe build`.",
self.session,
cell.coord
)
})?;
let panes: Vec<String> = self
.run(&["list-panes", "-t", window, "-F", "#{pane_id}"])?
.lines()
.map(str::to_owned)
.collect();
if panes.len() != cell.panes.len() {
bail!(
"{} has {} pane(s) in the running session but {} in the config -- \
it was built from a different config.\nRebuild it with \
`socktop-swipe build`.",
cell.coord,
panes.len(),
cell.panes.len()
);
}
placed.insert(cell.coord, Placed { window: (*window).to_owned(), panes });
}
*self.placed.borrow_mut() = placed;
Ok(())
}
fn lookup(&self, coord: Coord) -> Result<Placed> {
self.placed
.borrow()
.get(&coord)
.cloned()
.ok_or_else(|| anyhow!("no tmux window for {coord} -- was the session rebuilt?"))
}
fn set_indicator(&self, grid: &Grid) -> Result<()> {
if !self.indicator {
return Ok(());
}
let cell = grid.cell();
let (up, down) = grid.neighbours();
let mut parts = Vec::new();
if let Some(u) = up {
parts.push(format!("{} \u{25b2}", u.label));
}
let here = if cell.screens() > 1 {
format!(" {} [{}/{}] ", cell.screen_label(), cell.cursor + 1, cell.screens())
} else {
format!(" {} ", cell.screen_label())
};
parts.push(here);
if let Some(d) = down {
parts.push(format!("\u{25bc} {}", d.label));
}
let text = format!(" {} ", parts.join(" \u{b7} "));
self.run(&["set-option", "-t", &self.session, "status-left", &text])?;
Ok(())
}
}
impl Multiplexer for Tmux {
fn adopt(&self, grid: &Grid) -> Result<()> {
Tmux::adopt(self, grid)
}
fn build(&self, grid: &Grid) -> Result<()> {
let _ = Command::new("tmux")
.args(["kill-session", "-t", &self.session])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
let mut placed = HashMap::new();
for (i, cell) in grid.cells().enumerate() {
placed.insert(cell.coord, self.place(cell, i == 0)?);
}
*self.placed.borrow_mut() = placed;
let s = &self.session;
self.run(&["set-option", "-t", s, "pane-border-status", "top"])?;
self.run(&["set-option", "-t", s, "pane-border-format", " #{pane_title} "])?;
// Mouse mode MUST stay off. With it on, a touch swipe is also delivered
// to tmux as a click-drag: dragging across a pane border resizes it and
// taps reselect panes, both fighting the gesture layer. Exclusive evdev
// grab normally prevents this, but a panel that fails to grab would
// otherwise produce exactly the v1 symptom.
self.run(&["set-option", "-t", s, "mouse", "off"])?;
if self.indicator {
self.run(&["set-option", "-t", s, "status", "on"])?;
self.run(&["set-option", "-t", s, "status-style", "bg=default"])?;
self.run(&["set-option", "-t", s, "status-right", ""])?;
self.run(&["set-option", "-t", s, "status-left-length", "200"])?;
} else {
self.run(&["set-option", "-t", s, "status", "off"])?;
}
self.show(grid, &grid.position(true))
}
fn show(&self, grid: &Grid, pos: &Position) -> Result<()> {
let placed = self.lookup(pos.coord)?;
self.run(&["select-window", "-t", &placed.window])?;
let cell = grid.cell();
match cell.zoomed_pane() {
None => {
if self.zoomed(&placed.window)? {
self.run(&["resize-pane", "-Z", "-t", &placed.window])?;
}
// Building a window leaves the LAST split pane active, and
// coming back from a zoom leaves whichever was zoomed. Neither
// is what "the overview" should highlight, and the active pane
// border is visible on the wall. Always the first.
self.run(&["select-pane", "-t", &placed.panes[0]])?;
}
Some(i) => {
let pane = placed
.panes
.get(i)
.ok_or_else(|| anyhow!("{} has no pane {i}", pos.coord))?;
// Selecting a different pane auto-unzooms, so zoom afterwards
// and only if the window is not already zoomed.
self.run(&["select-pane", "-t", pane])?;
if cell.has_overview() && !self.zoomed(&placed.window)? {
self.run(&["resize-pane", "-Z", "-t", pane])?;
}
}
}
self.set_indicator(grid)
}
fn is_running(&self) -> bool {
Command::new("tmux")
.args(["has-session", "-t", &self.session])
.stdin(Stdio::null())
.stdout(Stdio::null())
.stderr(Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
fn attach(&self) -> Result<std::convert::Infallible> {
let err = Command::new("tmux")
.args(["attach", "-t", &self.session])
.exec();
Err(err).context("cannot exec tmux attach")
}
fn attach_argv(&self) -> Vec<String> {
vec!["tmux".into(), "attach".into(), "-t".into(), self.session.clone()]
}
}