diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ea8c4bf --- /dev/null +++ b/.gitignore @@ -0,0 +1 @@ +/target diff --git a/Cargo.lock b/Cargo.lock new file mode 100644 index 0000000..06c42df --- /dev/null +++ b/Cargo.lock @@ -0,0 +1,437 @@ +# This file is automatically @generated by Cargo. +# It is not intended for manual editing. +version = 3 + +[[package]] +name = "anstream" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d" +dependencies = [ + "anstyle", + "anstyle-parse", + "anstyle-query", + "anstyle-wincon", + "colorchoice", + "is_terminal_polyfill", + "utf8parse", +] + +[[package]] +name = "anstyle" +version = "1.0.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000" + +[[package]] +name = "anstyle-parse" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e" +dependencies = [ + "utf8parse", +] + +[[package]] +name = "anstyle-query" +version = "1.1.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" +dependencies = [ + "windows-sys", +] + +[[package]] +name = "anstyle-wincon" +version = "3.0.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" +dependencies = [ + "anstyle", + "once_cell_polyfill", + "windows-sys", +] + +[[package]] +name = "anyhow" +version = "1.0.104" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" + +[[package]] +name = "autocfg" +version = "1.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53" + +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + +[[package]] +name = "bitvec" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837" +dependencies = [ + "funty", + "radium", + "tap", + "wyz", +] + +[[package]] +name = "cc" +version = "1.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80" +dependencies = [ + "find-msvc-tools", + "shlex", +] + +[[package]] +name = "cfg-if" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" + +[[package]] +name = "clap" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca" +dependencies = [ + "clap_builder", + "clap_derive", +] + +[[package]] +name = "clap_builder" +version = "4.6.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889" +dependencies = [ + "anstream", + "anstyle", + "clap_lex", + "strsim", +] + +[[package]] +name = "clap_derive" +version = "4.6.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061" +dependencies = [ + "heck", + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "clap_lex" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9" + +[[package]] +name = "colorchoice" +version = "1.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" + +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + +[[package]] +name = "evdev" +version = "0.12.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ab6055a93a963297befb0f4f6e18f314aec9767a4bbe88b151126df2433610a7" +dependencies = [ + "bitvec", + "cfg-if", + "libc", + "nix", + "thiserror", +] + +[[package]] +name = "find-msvc-tools" +version = "0.1.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d" + +[[package]] +name = "funty" +version = "2.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c" + +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + +[[package]] +name = "heck" +version = "0.5.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" + +[[package]] +name = "indexmap" +version = "2.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855" +dependencies = [ + "equivalent", + "hashbrown", +] + +[[package]] +name = "is_terminal_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695" + +[[package]] +name = "itoa" +version = "1.0.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" + +[[package]] +name = "libc" +version = "0.2.189" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2" + +[[package]] +name = "memoffset" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce" +dependencies = [ + "autocfg", +] + +[[package]] +name = "nix" +version = "0.23.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c" +dependencies = [ + "bitflags", + "cc", + "cfg-if", + "libc", + "memoffset", +] + +[[package]] +name = "once_cell_polyfill" +version = "1.70.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe" + +[[package]] +name = "proc-macro2" +version = "1.0.107" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9" +dependencies = [ + "unicode-ident", +] + +[[package]] +name = "quote" +version = "1.0.47" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001" +dependencies = [ + "proc-macro2", +] + +[[package]] +name = "radium" +version = "0.7.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09" + +[[package]] +name = "ryu" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f" + +[[package]] +name = "serde" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba" +dependencies = [ + "serde_core", + "serde_derive", +] + +[[package]] +name = "serde_core" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48" +dependencies = [ + "serde_derive", +] + +[[package]] +name = "serde_derive" +version = "1.0.229" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348" +dependencies = [ + "proc-macro2", + "quote", + "syn 3.0.5", +] + +[[package]] +name = "serde_yaml" +version = "0.9.34+deprecated" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47" +dependencies = [ + "indexmap", + "itoa", + "ryu", + "serde", + "unsafe-libyaml", +] + +[[package]] +name = "shell-words" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77" + +[[package]] +name = "shlex" +version = "2.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba" + +[[package]] +name = "socktop-swipe" +version = "2.0.0-dev" +dependencies = [ + "anyhow", + "clap", + "evdev", + "serde", + "serde_yaml", + "shell-words", +] + +[[package]] +name = "strsim" +version = "0.11.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" + +[[package]] +name = "syn" +version = "2.0.119" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "syn" +version = "3.0.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + +[[package]] +name = "tap" +version = "1.0.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369" + +[[package]] +name = "thiserror" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52" +dependencies = [ + "thiserror-impl", +] + +[[package]] +name = "thiserror-impl" +version = "1.0.69" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "unicode-ident" +version = "1.0.24" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75" + +[[package]] +name = "unsafe-libyaml" +version = "0.2.11" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861" + +[[package]] +name = "utf8parse" +version = "0.2.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" + +[[package]] +name = "windows-link" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" + +[[package]] +name = "windows-sys" +version = "0.61.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc" +dependencies = [ + "windows-link", +] + +[[package]] +name = "wyz" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed" +dependencies = [ + "tap", +] diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..0aec3df --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,55 @@ +[package] +name = "socktop-swipe" +version = "2.0.0-dev" +edition = "2021" +rust-version = "1.82" +description = "Swipe-to-zoom touchscreen navigation for a wall-mounted socktop dashboard" +repository = "https://gt.wittyoneoff.com/jason/socktop-swipe" +license = "Apache-2.0" +readme = "README.md" +keywords = ["tui", "touchscreen", "tmux", "dashboard", "socktop"] +categories = ["command-line-utilities"] + +[dependencies] +anyhow = "1" +clap = { version = "4", features = ["derive"] } +evdev = "0.12" +serde = { version = "1", features = ["derive"] } +serde_yaml = "0.9" +shell-words = "1" + +[profile.release] +opt-level = "s" +lto = true +codegen-units = 1 +strip = true +panic = "abort" + +# `cargo deb` produces a package with these paths. Not used yet -- see +# notes/PLAN-v2.md, distribution is source-only for v2.0. +[package.metadata.deb] +maintainer = "Jason Witty" +depends = "tmux" +recommends = "alacritty" +extended-description = """\ +Drives a grid of terminal dashboards on a touchscreen. Swipe between socktop \ +hosts, an Uptime Kuma status page, a unifly UniFi TUI or any command you like. \ +Reads the touch panel directly via evdev and lays the screens out with tmux.""" +assets = [ + ["target/release/socktop-swipe", "usr/bin/", "755"], + ["config.example.yaml", "usr/share/doc/socktop-swipe/", "644"], + ["README.md", "usr/share/doc/socktop-swipe/", "644"], + ["packaging/70-socktop-swipe.rules", "lib/udev/rules.d/", "644"], +] + +[lib] +name = "socktop_swipe" +path = "src/lib.rs" + +[[bin]] +name = "socktop-swipe" +path = "src/main.rs" + +[dev-dependencies] +shell-words = "1" +serde_yaml = "0.9" diff --git a/config.example.yaml b/config.example.yaml new file mode 100644 index 0000000..5eccb8e --- /dev/null +++ b/config.example.yaml @@ -0,0 +1,128 @@ +# socktop-swipe configuration. +# +# Copy to ~/.config/socktop-swipe/config.yaml and edit. After every change run +# +# socktop-swipe validate +# +# which resolves the grid, prints the map and checks that every program it would +# run actually exists. + +# --------------------------------------------------------------------------- +# Session +# --------------------------------------------------------------------------- + +# tmux session name. Change only if it collides with something else. +session: socktop-swipe + +# Terminal that `socktop-swipe run` opens the dashboard in. It is invoked as +# -e tmux attach -t +# Remove this line to attach in the current terminal instead. +terminal: alacritty + +# Show a position indicator in the tmux status line: what is above, where you +# are, what is below. Useful once the grid has more than one row. +indicator: false + +# Full paths to the monitor programs. i3 and non-login shells do not have +# ~/.cargo/bin on PATH, so setting them here saves repeating a path on every +# screen. A bare name is looked up on PATH. Leading ~/ is expanded. +binaries: + socktop: ~/.cargo/bin/socktop + uptime-kuma-status: ~/.cargo/bin/uptime-kuma-status + unifly: ~/Documents/GitHub/unifly/target/release-small/unifly + +# --------------------------------------------------------------------------- +# Touch panel +# --------------------------------------------------------------------------- + +touch: + # ALWAYS a /dev/input/by-id/ path -- eventN numbers get reshuffled on reboot + # or USB re-enumeration. `socktop-swipe doctor --list` shows the candidates. + device: /dev/input/by-id/usb-ILITEK_ILITEK-TOUCH-event-if00 + + # The PANEL's resolution, not the X screen. With a second monitor attached X + # reports the combined root window (e.g. 2304x720), which is not this. + width: 1280 + height: 720 + + # Take the panel exclusively, so X never sees the touches. Without this, one + # swipe reaches several consumers at once: the terminal reads 2-contact swipes + # as pinch-zoom and socktop turns on its own all-motion mouse reporting. Set + # false only if you also want touch to work in other applications, and then + # install the X ignore rule instead -- see the README. + grab: true + + # Pixels of travel before a drag counts as a swipe. Measured on the ILITEK + # panel: real swipes land at 150-260px, accidental contact under 70px. + threshold: 80 + + # Degrees off-axis tolerated, maximum 45. Real finger swipes came in 1-25 + # degrees off true horizontal, so 30 leaves comfortable margin. + leniency: 30 + + # Contact counts accepted for one logical swipe. + # + # THE SETTING MOST LIKELY TO NEED CHANGING ON NEW HARDWARE. Many multipoint + # panels report 2 or even 3 contacts for a physically one-finger swipe. If + # swipes are detected but nothing happens, `socktop-swipe doctor` will say so + # in as many words. + fingers: [1, 2, 3] + +# Swipe directions, named by finger motion. RL is right-to-left. +gestures: + forward: RL # move deeper into the grid + back: LR + up: DU + down: UD + +# --------------------------------------------------------------------------- +# The grid +# --------------------------------------------------------------------------- +# +# Each screen sits at "x". Row increases DOWNWARD, so "-1x0" is above +# "0x0" and "1x0" is below it. You start at "0x0". +# +# QUOTE THE COORDINATE. Unquoted, YAML reads 0x0 as the hexadecimal number 0. +# +# Coordinates are ordering, not slots: only their sort order matters, so "0x1" +# and "0x5" are interchangeable. You never have to count how many screens a +# socktop group produces in order to place the thing next to it. +# +# This example is the rack display: +# +# unifly -1x0 +# | +# 4 Pis <-> orangepi + trixie 0x0, 0x1 +# | +# uptime kuma 1x0 + +screens: + # Swipe UP from anywhere in row 0 to reach this. + - at: "-1x0" + type: unifly + + # One cell, five screens: the four Pis tiled, then each Pi zoomed in turn. + - at: "0x0" + type: socktop + socktop_group: [rpi-master, rpi-worker-1, rpi-worker-2, rpi-worker-3] + layout: tiled + + # Swipe forward past the last Pi to arrive here. socktop_group also accepts a + # comma-separated string, and layout takes any tmux layout name. + - at: "0x1" + type: socktop + socktop_group: "orangepi, trixie" + layout: even-vertical + + # Swipe DOWN from row 0. Swiping back up returns to the exact host you left. + - at: "1x0" + type: uptime-kuma-status + url: https://status.wittyoneoff.com/status/wittyoneoff + + # Anything else you want on a screen. `command` works on any type, as an + # override; for `generic` it is the whole definition. + # + # - at: "1x1" + # type: generic + # title: logs + # command: journalctl -f -u k3s-agent diff --git a/src/config/coord.rs b/src/config/coord.rs new file mode 100644 index 0000000..b02d09c --- /dev/null +++ b/src/config/coord.rs @@ -0,0 +1,143 @@ +//! Grid coordinates: `"x"`, 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 { + 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 \"x\", e.g. \"0x0\" or \"-1x0\""))?; + let parse = |part: &str, which: &str| -> Result { + part.trim().parse::().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: D) -> Result { + 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(self, v: &str) -> Result { + 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(self, v: i64) -> Result { + 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(self, v: u64) -> Result { + 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::().unwrap(), Coord::new(0, 0)); + assert_eq!("-1x0".parse::().unwrap(), Coord::new(-1, 0)); + assert_eq!("1x-2".parse::().unwrap(), Coord::new(1, -2)); + assert_eq!("-3x-4".parse::().unwrap(), Coord::new(-3, -4)); + assert_eq!(" 2x7 ".parse::().unwrap(), Coord::new(2, 7)); + } + + #[test] + fn rejects_malformed_coordinates() { + for bad in ["", "0", "0x", "x0", "0x0x0", "axb", "0.5x1"] { + assert!(bad.parse::().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::("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)]); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs new file mode 100644 index 0000000..8586376 --- /dev/null +++ b/src/config/mod.rs @@ -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, + + /// 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, +} + +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, + #[serde(rename = "uptime-kuma-status", alias = "uptime_kuma_status")] + pub uptime_kuma_status: Option, + pub unifly: Option, +} + +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, +} + +fn yes() -> bool { + true +} +fn default_threshold() -> u32 { + 80 +} +fn default_leniency() -> u32 { + 30 +} +fn default_fingers() -> Vec { + 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); + +impl<'de> Deserialize<'de> for HostList { + fn deserialize>(d: D) -> Result { + 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(self, v: &str) -> Result { + Ok(HostList( + v.split(',') + .map(str::trim) + .filter(|s| !s.is_empty()) + .map(str::to_owned) + .collect(), + )) + } + + fn visit_seq>(self, mut seq: A) -> Result { + let mut out = Vec::new(); + while let Some(s) = seq.next_element::()? { + 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, + #[serde(default)] + pub layout: Option, + + // -- uptime-kuma-status ---------------------------------------------- + #[serde(default)] + pub url: Option, + + // -- 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, + #[serde(default)] + pub controller: Option, + + // -- any type --------------------------------------------------------- + /// Replaces the generated command outright. Required for `generic`. + #[serde(default)] + pub command: Option, + /// Extra arguments appended to the generated command. + #[serde(default)] + pub args: Option>, + /// Overrides the pane border label. + #[serde(default)] + pub title: Option, +} + +impl Config { + /// Search order: `--config`, then XDG, then `/etc`. + pub fn search_paths() -> Vec { + 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 { + 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 . \ + A commented starting point ships as config.example.yaml.", + searched + .iter() + .map(|p| format!(" {}", p.display())) + .collect::>() + .join("\n") + ) + }) + } + + pub fn load(path: &Path) -> Result { + 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 = Vec::new(); + + if self.screens.is_empty() { + errs.push("no screens defined -- the grid would be empty".into()); + } + + let mut seen: BTreeSet = 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::>() + .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() +} diff --git a/src/control.rs b/src/control.rs new file mode 100644 index 0000000..b2d1e3c --- /dev/null +++ b/src/control.rs @@ -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), + /// 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 { + 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) -> Result { + 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 { + 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); + } +} diff --git a/src/doctor.rs b/src/doctor.rs new file mode 100644 index 0000000..c289e9f --- /dev/null +++ b/src/doctor.rs @@ -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 + }) +} diff --git a/src/grid.rs b/src/grid.rs new file mode 100644 index 0000000..14d3817 --- /dev/null +++ b/src/grid.rs @@ -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, + /// 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, + 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) -> Result { + if cells.is_empty() { + bail!("no screens defined -- the grid would be empty"); + } + + let mut row_numbers: Vec = cells.iter().map(|c| c.coord.row).collect(); + row_numbers.sort_unstable(); + row_numbers.dedup(); + + let rows: Vec = row_numbers + .into_iter() + .map(|r| { + let mut cells: Vec = + 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 { + 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| -> 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 { + 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)); + } +} diff --git a/src/input.rs b/src/input.rs new file mode 100644 index 0000000..ce8997e --- /dev/null +++ b/src/input.rs @@ -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 }, +} + +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 { + 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 = 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(¤t) { + 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 = + 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 { + // 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, 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) -> 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()); + } +} diff --git a/src/lib.rs b/src/lib.rs new file mode 100644 index 0000000..f4a5af4 --- /dev/null +++ b/src/lib.rs @@ -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; diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..f343b14 --- /dev/null +++ b/src/main.rs @@ -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, + + #[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}"))?; + 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) -> 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) -> 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, + }) +} diff --git a/src/monitor.rs b/src/monitor.rs new file mode 100644 index 0000000..b724477 --- /dev/null +++ b/src/monitor.rs @@ -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` 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, +} + +#[derive(Debug, Clone)] +pub struct Cell { + pub coord: Coord, + pub kind: MonitorType, + /// Human label for the position indicator. + pub label: String, + pub panes: Vec, + 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 { + 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 { + 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> { + cfg.screens.iter().map(|s| build_cell(s, &cfg.binaries)).collect() +} diff --git a/src/session/mod.rs b/src/session/mod.rs new file mode 100644 index 0000000..dab754e --- /dev/null +++ b/src/session/mod.rs @@ -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; + + /// Command line that attaches a client, for handing to a terminal emulator. + fn attach_argv(&self) -> Vec; +} diff --git a/src/session/tmux.rs b/src/session/tmux.rs new file mode 100644 index 0000000..1febf51 --- /dev/null +++ b/src/session/tmux.rs @@ -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, +} + +pub struct Tmux { + session: String, + indicator: bool, + placed: RefCell>, +} + +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 { + 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 { + Ok(self.run(&["display-message", "-p", "-t", window, "#{window_zoomed_flag}"])? == "1") + } + + fn place(&self, cell: &Cell, first: bool) -> Result { + 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 = 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 { + 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 { + let err = Command::new("tmux") + .args(["attach", "-t", &self.session]) + .exec(); + Err(err).context("cannot exec tmux attach") + } + + fn attach_argv(&self) -> Vec { + vec!["tmux".into(), "attach".into(), "-t".into(), self.session.clone()] + } +} diff --git a/tests/tmux_session.rs b/tests/tmux_session.rs new file mode 100644 index 0000000..a094c9d --- /dev/null +++ b/tests/tmux_session.rs @@ -0,0 +1,182 @@ +//! End-to-end against a real tmux server. +//! +//! Covers what the unit tests deliberately cannot: that the grid's idea of +//! "sub-screen 3 of the cell at 0x0" lands on the pane a person would expect, +//! zoomed. Skipped when tmux is not installed. + +use std::process::{Command, Stdio}; + +use socktop_swipe::config::Config; +use socktop_swipe::grid::{Grid, Move}; +use socktop_swipe::monitor; +use socktop_swipe::session::tmux::Tmux; +use socktop_swipe::session::Multiplexer; + +const SESSION: &str = "socktop-swipe-selftest"; + +fn have_tmux() -> bool { + Command::new("tmux") + .arg("-V") + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status() + .map(|s| s.success()) + .unwrap_or(false) +} + +fn tmux(args: &[&str]) -> String { + let out = Command::new("tmux").args(args).output().expect("tmux"); + String::from_utf8_lossy(&out.stdout).trim().to_owned() +} + +fn config() -> Config { + // `true` exits at once, which also exercises remain-on-exit keeping the + // pane addressable afterwards. + let yaml = format!( + r#" +session: {SESSION} +binaries: {{ socktop: /bin/echo, unifly: /bin/echo, uptime-kuma-status: /bin/echo }} +touch: {{ device: /dev/null, width: 1280, height: 720, grab: false }} +screens: + - at: "-1x0" + type: unifly + - at: "0x0" + type: socktop + socktop_group: [alpha, bravo, charlie, delta] + layout: tiled + - at: "0x5" + type: socktop + socktop_group: "echo1, foxtrot" + layout: even-vertical + - at: "1x0" + type: uptime-kuma-status + url: https://example.invalid/status +"# + ); + serde_yaml::from_str(&yaml).expect("test config should parse") +} + +/// (active window name, active pane title, is the window zoomed) +fn visible() -> (String, String, bool) { + let s = tmux(&[ + "display-message", + "-p", + "-t", + SESSION, + "#{window_name}\t#{pane_title}\t#{window_zoomed_flag}", + ]); + let f: Vec<&str> = s.split('\t').collect(); + (f[0].into(), f[1].into(), f[2] == "1") +} + +struct Cleanup; +impl Drop for Cleanup { + fn drop(&mut self) { + let _ = Command::new("tmux") + .args(["kill-session", "-t", SESSION]) + .stdout(Stdio::null()) + .stderr(Stdio::null()) + .status(); + } +} + +#[test] +fn session_layout_and_navigation_match_the_grid() { + if !have_tmux() { + eprintln!("skipping: tmux is not installed"); + return; + } + let _cleanup = Cleanup; + + let cfg = config(); + let mut grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap(); + let mux = Tmux::new(&cfg.session, false); + mux.build(&grid).expect("session should build"); + + // One window per cell, in grid order, named by coordinate. + let windows = tmux(&["list-windows", "-t", SESSION, "-F", "#{window_name} #{window_panes}"]); + assert_eq!( + windows.lines().collect::>(), + vec!["rm1c0 1", "r0c0 4", "r0c5 2", "r1c0 1"], + "windows should be one per cell, in grid order, with one pane per host" + ); + + // Starts at 0x0's overview: not zoomed, so all four Pis are visible. + let (win, _, zoomed) = visible(); + assert_eq!(win, "r0c0"); + assert!(!zoomed, "the overview must not be zoomed"); + + // Forward walks the sub-sequence, zooming each host in declaration order. + for expected in ["alpha", "bravo", "charlie", "delta"] { + let pos = grid.apply(Move::Forward); + mux.show(&grid, &pos).unwrap(); + let (win, title, zoomed) = visible(); + assert_eq!(win, "r0c0"); + assert_eq!(title, expected, "wrong host zoomed"); + assert!(zoomed, "{expected} should be zoomed full-screen"); + } + + // Past the last host, on to the next cell's overview. + let pos = grid.apply(Move::Forward); + mux.show(&grid, &pos).unwrap(); + let (win, _, zoomed) = visible(); + assert_eq!(win, "r0c5", "0x5 follows 0x0 despite the gap in column numbers"); + assert!(!zoomed); + + // Back must land on 0x0's LAST host, not its overview. + let pos = grid.apply(Move::Back); + mux.show(&grid, &pos).unwrap(); + let (win, title, zoomed) = visible(); + assert_eq!((win.as_str(), title.as_str(), zoomed), ("r0c0", "delta", true)); + + // Up to unifly: a single-pane cell, so nothing to zoom. + let pos = grid.apply(Move::Up); + mux.show(&grid, &pos).unwrap(); + let (win, _, zoomed) = visible(); + assert_eq!(win, "rm1c0"); + assert!(!zoomed, "a one-pane cell has nothing to zoom into"); + + // And back down to exactly the host we left. + let pos = grid.apply(Move::Down); + mux.show(&grid, &pos).unwrap(); + let (win, title, zoomed) = visible(); + assert_eq!( + (win.as_str(), title.as_str(), zoomed), + ("r0c0", "delta", true), + "returning must restore the host AND its zoom" + ); + + // Down twice: through row 0 to kuma. + let pos = grid.apply(Move::Down); + mux.show(&grid, &pos).unwrap(); + assert_eq!(visible().0, "r1c0"); +} + +#[test] +fn pane_commands_are_quoted_not_interpreted() { + if !have_tmux() { + return; + } + // A profile name containing shell metacharacters must reach the program as + // one argument, not be re-split or evaluated by the sh tmux runs it under. + let cfg: Config = serde_yaml::from_str( + r#" +session: socktop-swipe-quoting-test +binaries: { socktop: /bin/echo } +touch: { device: /dev/null, width: 1, height: 1, grab: false } +screens: + - at: "0x0" + type: socktop + socktop_group: ["a b; touch /tmp/socktop-swipe-pwned"] +"#, + ) + .unwrap(); + + let cells = monitor::build_cells(&cfg).unwrap(); + let argv = &cells[0].panes[0].command; + assert_eq!(argv, &["/bin/echo", "-P", "a b; touch /tmp/socktop-swipe-pwned"]); + + let quoted = shell_words::join(argv.iter().map(String::as_str)); + let reparsed = shell_words::split("ed).unwrap(); + assert_eq!(&reparsed, argv, "quoting must survive the round trip through sh"); +}