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