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"
|
||||
@@ -1,298 +1,350 @@
|
||||
# socktop-swipe
|
||||
|
||||
Swipe-to-zoom touchscreen navigation for a wall-mounted
|
||||
[socktop](https://crates.io/crates/socktop) dashboard.
|
||||
A rack-mounted touchscreen you swipe through to see what your machines are doing.
|
||||
|
||||
Hosts are arranged in **groups**. Each group is one tmux window showing its
|
||||
hosts tiled together. Swiping right-to-left on the panel zooms into each host
|
||||
full-screen, one at a time, then moves on to the next group's overview; swiping
|
||||
left-to-right walks back the same way.
|
||||
Terminal dashboards are arranged in a grid. Swipe left and right to move along a
|
||||
row; swipe up and down to move between rows. A group of hosts is one place in
|
||||
that grid: it shows them tiled together first, then each one full-screen as you
|
||||
keep swiping.
|
||||
|
||||
```
|
||||
group A -> A1 -> A2 -> A3 -> A4 -> group B -> B1 -> B2
|
||||
(quarters) zoomed zoomed zoomed zoomed (stacked) zoomed zoomed
|
||||
<------------------------- swipe left-to-right -------------------------
|
||||
```
|
||||
|
||||
With a single group this is simply overview -> each host -> back.
|
||||
|
||||
Optionally, swiping **down** from any screen shows a different TUI (an
|
||||
[Uptime Kuma](https://github.com/louislam/uptime-kuma) status page on the rack
|
||||
display), and swiping **up** returns to exactly the screen you left.
|
||||
|
||||
Built for a 10" touch panel on a server rack driving four Raspberry Pis, on a
|
||||
low-power x86 box running i3 on X11.
|
||||
|
||||
## What it looks like
|
||||
|
||||
The panel in place, showing the four-Pi group overview:
|
||||
|
||||

|
||||
|
||||
Swiping through the carousel ([media/swipe-demo.mp4](media/swipe-demo.mp4)):
|
||||

|
||||
|
||||
<video controls src="media/swipe-demo.mp4"></video>
|
||||
|
||||
## How it works, and why
|
||||
Built for a 9" panel bolted into a 19" server rack, driven by a low-power x86 box
|
||||
running i3 on X11.
|
||||
|
||||
**The carousel is tmux pane zoom, not extra socktop instances.** There is exactly
|
||||
one socktop process per host. Zooming calls `tmux resize-pane -Z`, which makes one
|
||||
pane fill the window and sends `SIGWINCH` so socktop repaints at the new size.
|
||||
## What you're looking at
|
||||
|
||||
That matters for three reasons:
|
||||
The photo is a 9" touchscreen in a 19" rack, showing four Raspberry Pis at once
|
||||
via [socktop](https://github.com/jasonwitty/socktop). It is a wall display: no
|
||||
keyboard, no mouse, nothing to log into. It comes back by itself after a power
|
||||
cut and it never blanks.
|
||||
|
||||
- **Half the processes.** Separate full-screen instances would mean N extra
|
||||
socktop processes and double the polling load on the monitored hosts. On a
|
||||
1.9 GB display host, that is the difference between comfortable and not.
|
||||
- **No reconnect delay.** Hidden panes keep running and stay connected, so
|
||||
swiping back to a host shows current data, not a stale snapshot.
|
||||
- **Constant load.** The monitored hosts see the same connections regardless of
|
||||
what is on screen.
|
||||
Everything on it is a terminal program. socktop-swipe does not draw anything
|
||||
itself — it decides what is on screen and reads your finger.
|
||||
|
||||
**Gestures come from lisgd, below the terminal.** No Linux terminal maps
|
||||
horizontal swipes to commands, so this cannot be done in terminal config.
|
||||
Critically, **libinput deliberately emits gesture events only for touchpads,
|
||||
never for touchscreens** — so `libinput-gestures` and similar tools cannot work
|
||||
here at all. [lisgd](https://git.sr.ht/~mil/lisgd) reads raw touch events and
|
||||
synthesises the swipes itself, reading `/dev/input` directly and bypassing X.
|
||||
```
|
||||
unifly -1x0
|
||||
(UniFi network)
|
||||
↕
|
||||
┌──────────────────────────────┐ ┌──────────────────┐
|
||||
│ 4 Raspberry Pis │→ │ 2 more machines │ row 0
|
||||
│ tiled, then each full-screen│ │ │
|
||||
└──────────────────────────────┘ └──────────────────┘
|
||||
0x0 0x1
|
||||
↕
|
||||
uptime kuma 1x0
|
||||
(service status)
|
||||
```
|
||||
|
||||
## Requirements
|
||||
Swipe forward through row 0 and you get: all four Pis tiled, then `rpi-master`
|
||||
full-screen, then each of the others, then on to the next group. Swipe up at any
|
||||
point for the UniFi network view; swipe back down and you are on the exact host
|
||||
you left.
|
||||
|
||||
- `tmux`, `socktop`, and a working `~/.config/socktop/profiles.json`
|
||||
- `lisgd` (the installer builds it)
|
||||
- A C toolchain plus `libinput` and `libX11` headers, to build lisgd
|
||||
- Membership of the `input` group (the installer adds you; needs a relogin)
|
||||
## Hardware
|
||||
|
||||
| Part | What was used | Status |
|
||||
| --- | --- | --- |
|
||||
| Display host | LattePanda (Atom x5-Z8350, 1.9 GB), Debian 11, i3 on X11 | in daily use |
|
||||
| Display host | Dell Wyse 3040 (Atom x5-Z8350, 2 GB), 8/16 GB eMMC, DP out | not yet tried |
|
||||
| Panel | GeeekPi 9" 1280x720 3U touchscreen, ILITEK USB touch | in daily use |
|
||||
| Mounting | [`cad/`](cad) — 19" rack adapter | printed and in the rack |
|
||||
|
||||
Any Linux box with a USB or DSI touchscreen will do. It is deliberately modest
|
||||
hardware: the whole point is a display that idles at a few watts.
|
||||
|
||||
The panel is sold as a **10-inch mini-rack** part. [`cad/`](cad) has a pair of
|
||||
side adapters — OpenSCAD source, STLs with tested print settings, and
|
||||
SendCutSend-ready DXFs — that mount it in a standard **19-inch** rack, with three
|
||||
keystone jack positions on each side. The geometry has been physically tested.
|
||||
|
||||
## Install
|
||||
|
||||
```sh
|
||||
curl -fsSL https://gt.wittyoneoff.com/jason/socktop-swipe/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
It checks everything first and prints the whole cost — packages, toolchain, disk
|
||||
— before changing anything. Then it asks, one thing at a time:
|
||||
|
||||
- which touch device to use (it looks for one and offers it)
|
||||
- how to grant access to it: a udev rule for touchscreens only, or the `input`
|
||||
group
|
||||
- whether to install socktop, uptime-kuma-status or unifly
|
||||
- whether to stop the screen blanking, sleeping and locking
|
||||
- whether to autostart on login
|
||||
- whether to log straight in at boot, so it comes back after a power cut
|
||||
|
||||
Every answer has a sensible default and nothing is required. Piping into `sh`
|
||||
leaves no keyboard on standard input, so the script reads your terminal directly;
|
||||
if there is no terminal at all it takes the defaults and says so. `--yes` skips
|
||||
the questions.
|
||||
|
||||
There are no prebuilt binaries yet — it builds from source, so a Rust toolchain
|
||||
is installed if you do not have one.
|
||||
|
||||
Measured on the LattePanda (Atom x5-Z8350, 4 cores, 1.9 GB RAM, no swap), with a
|
||||
toolchain already present: **108 seconds**, peaking at 1.1 GB of the 1.9 GB and
|
||||
leaving a 103 MB build directory. The 946 KB binary is the only thing installed.
|
||||
Add roughly 1.2 GB and a few minutes if rustup has to be fetched too. The
|
||||
installer checks free space first and tells you if it will not fit.
|
||||
|
||||
<details>
|
||||
<summary>From a checkout instead</summary>
|
||||
|
||||
```sh
|
||||
git clone https://gt.wittyoneoff.com/jason/socktop-swipe
|
||||
cd socktop-swipe
|
||||
./install.sh --xignore --i3
|
||||
./install.sh
|
||||
```
|
||||
</details>
|
||||
|
||||
Then **log out and back in** — the `input` group and the X rule both need a
|
||||
fresh session.
|
||||
## Your first config
|
||||
|
||||
| Flag | Effect |
|
||||
| --- | --- |
|
||||
| *(none)* | Build/install lisgd, install the three scripts and the default config |
|
||||
| `--xignore` | Also tell X to ignore the touch panel (see below — usually wanted) |
|
||||
| `--i3` | Also add i3 autostart lines (gesture daemon, fullscreen terminal, idle-pointer hiding via `unclutter-xfixes`), with a backup and `i3 -C` validation |
|
||||
| `--noblank` | Also stop the screen blanking / DPMS power-down (wall displays) |
|
||||
| `--autologin` | Also make lightdm log this user straight into i3 at boot (see [security note](#related-does-the-display-come-back-by-itself)) |
|
||||
The config lives at `~/.config/socktop-swipe/config.yaml`. The installer writes a
|
||||
starting point; this section builds one up from nothing.
|
||||
|
||||
Re-running is safe: an existing config file is never overwritten, and the i3
|
||||
edit is skipped if already present.
|
||||
|
||||
### Verify
|
||||
**Check every edit** with:
|
||||
|
||||
```sh
|
||||
tools/find-device.sh # confirm TOUCH_DEV is right
|
||||
tools/diag.sh # stage 1: device live? stage 2: what does lisgd see?
|
||||
tools/test-foreground.sh # run the real daemon, watch the display, swipe
|
||||
socktop-swipe validate
|
||||
```
|
||||
|
||||
## Configuration
|
||||
which resolves the grid, prints the map, and tells you if a program it would run
|
||||
is not actually there.
|
||||
|
||||
Everything lives in **`/usr/local/etc/socktop-swipe.env`**. Edit it there, not in
|
||||
the repo copy. After changing it, restart the daemon and rebuild the session:
|
||||
### One group of hosts
|
||||
|
||||
```sh
|
||||
socktop-gestures --replace & # restarts the daemon, running or not
|
||||
socktop-rack
|
||||
```yaml
|
||||
touch:
|
||||
device: /dev/input/by-id/usb-ILITEK_ILITEK-TOUCH-event-if00
|
||||
width: 1280
|
||||
height: 720
|
||||
|
||||
screens:
|
||||
- at: "0x0"
|
||||
type: socktop
|
||||
socktop_group: [rpi-master, rpi-worker-1, rpi-worker-2, rpi-worker-3]
|
||||
```
|
||||
|
||||
Use `--replace` rather than `pkill -x lisgd && socktop-gestures`: `pkill` exits
|
||||
non-zero when it matches nothing, so that one-liner silently starts **nothing**
|
||||
if no daemon was running — you would swipe, get no response, and reasonably
|
||||
blame the config change.
|
||||
That is five screens: the four hosts tiled, then each one full-screen.
|
||||
|
||||
| Option | Default | What it does |
|
||||
```
|
||||
0x0 tiled → rpi-master → rpi-worker-1 → rpi-worker-2 → rpi-worker-3
|
||||
```
|
||||
|
||||
> **Quote the coordinate.** Unquoted, YAML reads `0x0` as the hexadecimal number
|
||||
> zero. `socktop-swipe validate` will tell you so if you forget.
|
||||
|
||||
### Something above it
|
||||
|
||||
```yaml
|
||||
- at: "-1x0"
|
||||
type: unifly
|
||||
```
|
||||
|
||||
Row `-1` is above row `0`. Now swiping up from any of those five screens shows
|
||||
the UniFi TUI, and swiping down returns you to the exact host you were on.
|
||||
|
||||
### Something below, and more hosts alongside
|
||||
|
||||
```yaml
|
||||
- at: "0x1"
|
||||
type: socktop
|
||||
socktop_group: "orangepi, trixie"
|
||||
layout: even-vertical
|
||||
|
||||
- at: "1x0"
|
||||
type: uptime-kuma-status
|
||||
url: https://status.example.com/status/mine
|
||||
```
|
||||
|
||||
`0x1` sits to the right of `0x0`, so you reach it by swiping forward past the
|
||||
last Pi. `1x0` is below.
|
||||
|
||||
Full worked example with every option commented:
|
||||
[`config.example.yaml`](config.example.yaml).
|
||||
|
||||
## How the grid works
|
||||
|
||||
**Row increases downward.** `-1x0` is above `0x0`; `1x0` is below it. Column
|
||||
increases rightward. You start at `0x0`.
|
||||
|
||||
**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 group
|
||||
produces in order to place something next to it — add a host to a group and
|
||||
nothing else needs renumbering.
|
||||
|
||||
**A group of hosts is one place in the grid.** Swiping forward walks its screens
|
||||
— tiled overview, then each host — and only moves on to the next place after the
|
||||
last one. Coming back from the right lands you on that group's *last* host, not
|
||||
its overview, so the row reads as one continuous strip.
|
||||
|
||||
**Swiping up or down returns you to where you were.** Each row remembers its
|
||||
position. If you glance at the network view and come back, you are on the same
|
||||
host, still zoomed.
|
||||
|
||||
**Nothing wraps around.** Swiping past either end of a row does nothing. On a
|
||||
wall display, wrapping makes it impossible to tell where you are.
|
||||
|
||||
If a row has no screen in your current column, you land on the nearest one —
|
||||
`0x3` swiping up with only `-1x0` above goes there. That only happens the first
|
||||
time; after that the row remembers.
|
||||
|
||||
## Monitor types
|
||||
|
||||
Every type also accepts `command:` to replace the generated command outright, and
|
||||
`title:` to change the label on the pane border.
|
||||
|
||||
| `type:` | Parameters | What it runs |
|
||||
| --- | --- | --- |
|
||||
| `SOCKTOP_GROUPS` | Pis quartered `;` Orange Pi + Debian stacked | Groups separated by `;`, each a list of socktop profile names **in swipe order**, plus an optional `@layout` (`tiled` default, `even-vertical` stacks, `even-horizontal` side-by-side, `main-*` also work). Any count of groups and hosts works. |
|
||||
| `SOCKTOP_HOSTS` | empty | Legacy single-group form; used only when `SOCKTOP_GROUPS` is empty |
|
||||
| `SOCKTOP_AUX_CMD` | `uptime-kuma-status …` | Command for the swipe-down "aux" screen. Full path. Empty disables the feature and its gestures. |
|
||||
| `GESTURE_AUX_IN` / `GESTURE_AUX_OUT` | `UD` / `DU` | Swipe down shows aux, swipe up returns |
|
||||
| `SOCKTOP_SESSION` | `socktop4` | tmux session name |
|
||||
| `SOCKTOP_BIN` | `$HOME/.cargo/bin/socktop` | Full path — i3 does not have `~/.cargo/bin` on `PATH` |
|
||||
| `TOUCH_DEV` | ILITEK by-id path | Touch device. **Always use a `/dev/input/by-id/` path**; `eventN` numbers change on reboot. |
|
||||
| `SCREEN_W` / `SCREEN_H` | `1024` / `600` | The **panel's** resolution, not the X screen. See gotcha 2. |
|
||||
| `SWIPE_THRESHOLD` | `80` | Pixels of travel before a drag counts as a swipe |
|
||||
| `SWIPE_LENIENCY` | `30` | Degrees off-axis tolerated (max 45) |
|
||||
| `FINGER_COUNTS` | `1 2 3` | Contact counts accepted. **The setting most likely to need changing** — see gotcha 1. |
|
||||
| `GESTURE_IN` / `GESTURE_OUT` | `RL` / `LR` | Swap these to reverse swipe direction |
|
||||
| `socktop` | `socktop_group` (list, or comma-separated string), `layout` | one [socktop](https://github.com/jasonwitty/socktop) per host |
|
||||
| `uptime-kuma-status` | `url` | [uptime-kuma-status](https://github.com/jasonwitty/uptime-kuma-status) against a public [Uptime Kuma](https://github.com/louislam/uptime-kuma) status page |
|
||||
| `unifly` | *(none yet)* | [unifly](https://github.com/jasonwitty/unifly) `tui` |
|
||||
| `generic` | `command`, `title` | anything you like |
|
||||
|
||||
### Adding or removing hosts
|
||||
`layout` takes any tmux layout name: `tiled` (the default), `even-horizontal`,
|
||||
`even-vertical`, `main-horizontal`, `main-vertical`.
|
||||
|
||||
Edit `SOCKTOP_GROUPS` and run `socktop-rack`. Nothing else needs touching — the
|
||||
carousel derives its length from the windows and panes at runtime. Every host
|
||||
named must exist in `~/.config/socktop/profiles.json` on the display host.
|
||||
|
||||
```sh
|
||||
# four Pis as quarters, then two more boxes stacked one above the other
|
||||
SOCKTOP_GROUPS="rpi-master rpi-worker-1 rpi-worker-2 rpi-worker-3 ; orangepi trixie @even-vertical"
|
||||
```yaml
|
||||
- at: "1x1"
|
||||
type: generic
|
||||
title: k3s
|
||||
command: journalctl -f -u k3s-agent
|
||||
```
|
||||
|
||||
## Autostart
|
||||
## Configuration reference
|
||||
|
||||
`./install.sh --i3` appends to `~/.config/i3/config`. For systemd user sessions
|
||||
| Key | Default | What it does |
|
||||
| --- | --- | --- |
|
||||
| `session` | `socktop-swipe` | tmux session name |
|
||||
| `terminal` | *(none)* | Terminal that `run` opens the dashboard in. Unset attaches in the current one |
|
||||
| `indicator` | `false` | Show position — what is above, where you are, what is below — in the status line |
|
||||
| `binaries.socktop` etc. | looked up on `PATH` | Full path to each program. `~/` is expanded |
|
||||
| `touch.device` | — | **Always a `/dev/input/by-id/` path**; `eventN` numbers change on reboot |
|
||||
| `touch.width` / `.height` | — | The **panel's** resolution, not the X screen |
|
||||
| `touch.grab` | `true` | Take the panel exclusively so X never sees the touches |
|
||||
| `touch.threshold` | `80` | Pixels of travel before a drag counts as a swipe |
|
||||
| `touch.leniency` | `30` | Degrees off-axis tolerated, max 45 |
|
||||
| `touch.fingers` | `[1, 2, 3]` | Contact counts accepted. **The setting most likely to need changing** |
|
||||
| `gestures.forward` / `.back` | `RL` / `LR` | Named by finger motion. Swap to reverse |
|
||||
| `gestures.up` / `.down` | `DU` / `UD` | |
|
||||
|
||||
### Commands
|
||||
|
||||
| Command | |
|
||||
| --- | --- |
|
||||
| `socktop-swipe run` | Build the session, open it in a terminal, read the panel. The one thing to autostart |
|
||||
| `socktop-swipe validate` | Check the config and print the grid map |
|
||||
| `socktop-swipe doctor` | Swipe, and be told what the panel reported |
|
||||
| `socktop-swipe doctor --list` | List touch devices — works before any config exists |
|
||||
| `socktop-swipe forward` / `back` / `up` / `down` | Move a running instance. For keybindings, or a box with no panel |
|
||||
| `socktop-swipe build` / `attach` / `daemon` | The pieces of `run`, separately, for a systemd split |
|
||||
|
||||
## Unattended operation
|
||||
|
||||
A wall display has to survive a power cut with nobody in the room.
|
||||
|
||||
**Never blank.** The installer writes an Xorg `ServerFlags` snippet with all four
|
||||
timeouts at zero, which covers every session including the display manager's
|
||||
greeter and survives reboots, *and* adds an `xset` line to the session autostart,
|
||||
because a session can re-enable blanking after X starts. Both are needed. Check
|
||||
with `xset q | grep -A1 -E 'Screen Saver|DPMS'` — you want `timeout: 0`,
|
||||
`prefer blanking: no`, `DPMS is Disabled`.
|
||||
|
||||
**Come back by itself.** Without autologin, a reboot leaves the panel at a login
|
||||
prompt and nothing starts. The installer offers a lightdm drop-in. It is opt-in
|
||||
and defaults to no, because **anyone with physical access to the panel gets that
|
||||
user's session** — only do it where the account is nothing but the dashboard.
|
||||
Delete `/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf` to undo it.
|
||||
|
||||
**Autostart.** `socktop-swipe run` is a single process that builds the session,
|
||||
opens the terminal and reads the panel, so one line in your window manager's
|
||||
autostart is enough. For a systemd user session,
|
||||
[`packaging/socktop-swipe.service`](packaging/socktop-swipe.service) splits it up.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
Start here:
|
||||
|
||||
```sh
|
||||
socktop-swipe doctor
|
||||
```
|
||||
|
||||
It watches the panel and says what it saw in English — direction, contact count,
|
||||
and why a gesture was ignored.
|
||||
|
||||
### Swipes are detected but nothing happens
|
||||
|
||||
`doctor` will say `saw 2 contact(s), config accepts [1]`. Many multipoint panels
|
||||
report two or even three contacts for a physically one-finger swipe. Add them to
|
||||
`touch.fingers`. This is the single most common problem on new hardware.
|
||||
|
||||
### Nothing is detected at all
|
||||
|
||||
In order: is the device path right (`socktop-swipe doctor --list`)? Can you read
|
||||
it — is the udev rule installed, or are you in the `input` group and did you log
|
||||
out and back in? Is the panel plugged in?
|
||||
|
||||
### Swipes work, but the terminal also reacts — text zooming, panes resizing
|
||||
|
||||
X is delivering touch to whatever is on screen as well. `touch.grab: true` (the
|
||||
default) prevents this by taking the device exclusively. If you have set it to
|
||||
`false` because you want touch in other applications, tell X to ignore the panel
|
||||
instead:
|
||||
|
||||
```sh
|
||||
cp socktop-swipe.service ~/.config/systemd/user/
|
||||
systemctl --user enable --now socktop-swipe
|
||||
```
|
||||
|
||||
Either way, **run exactly one gesture daemon**. lisgd does not grab the input
|
||||
device exclusively, so two instances make every swipe fire twice.
|
||||
`socktop-gestures` enforces this: it exits non-zero if one is already running,
|
||||
unless given `--replace`.
|
||||
|
||||
## Stopping the screen blanking
|
||||
|
||||
A wall display must never blank. `./install.sh --noblank` applies two layers:
|
||||
|
||||
1. An Xorg `ServerFlags` snippet with all four timeouts at `0`, so it covers
|
||||
every X session including the display-manager greeter, and survives reboots.
|
||||
2. An `xset s off s noblank -dpms` line in the i3 autostart, because a session
|
||||
or DM can re-enable the screensaver after X has started.
|
||||
|
||||
Both are needed. `xset` alone does not survive a reboot, and — the trap worth
|
||||
knowing — **`xset` must run as the session user**. Running `sudo xset ...`, or a
|
||||
`sudo bash noblank.sh` wrapper, targets root's X connection and silently does
|
||||
nothing while appearing to succeed. Check the real state with:
|
||||
|
||||
```sh
|
||||
xset q | grep -A1 -E 'Screen Saver|DPMS'
|
||||
```
|
||||
|
||||
You want `timeout: 0`, `prefer blanking: no`, and `DPMS is Disabled`.
|
||||
|
||||
### Related: phantom outputs
|
||||
|
||||
Some boards report a display output that has no panel behind it. The LattePanda's
|
||||
onboard DSI header shows up as `DSI-1 connected 1024x600` with **zero EDID bytes**
|
||||
(`xrandr` prints `0mm x 0mm`), and X happily puts workspace 1 on it. Everything
|
||||
then looks healthy from ssh -- i3 has the window fullscreen on its primary
|
||||
output -- while the real panel shows an empty workspace. Check with:
|
||||
|
||||
```sh
|
||||
xrandr | grep ' connected' # a real panel has physical mm, phantoms say 0mm x 0mm
|
||||
for c in /sys/class/drm/card0-*; do echo "$c $(cat $c/status) edid=$(wc -c <$c/edid)"; done
|
||||
```
|
||||
|
||||
Tell X to ignore the phantom and mark the real panel primary (adjust the names):
|
||||
|
||||
```sh
|
||||
sudo tee /etc/X11/xorg.conf.d/20-outputs-socktop-swipe.conf >/dev/null <<'EOF'
|
||||
Section "Monitor"
|
||||
Identifier "DSI-1"
|
||||
Option "Ignore" "true"
|
||||
EndSection
|
||||
Section "Monitor"
|
||||
Identifier "HDMI-2"
|
||||
Option "Primary" "true"
|
||||
sudo tee /etc/X11/xorg.conf.d/99-ignore-touch-socktop-swipe.conf >/dev/null <<'EOF'
|
||||
Section "InputClass"
|
||||
Identifier "ignore touchscreen (socktop-swipe)"
|
||||
MatchProduct "ILITEK"
|
||||
MatchIsTouchscreen "on"
|
||||
Option "Ignore" "on"
|
||||
EndSection
|
||||
EOF
|
||||
```
|
||||
|
||||
Then set `SCREEN_W` / `SCREEN_H` in the config to the real panel's mode.
|
||||
Adjust `MatchProduct` to your panel, and restart X.
|
||||
|
||||
### Related: does the display come back by itself?
|
||||
### A pane shows `[... exited: status 1]`
|
||||
|
||||
Blanking is only half of unattended operation. If the display manager has no
|
||||
autologin configured, a reboot leaves the panel at a login prompt and nothing
|
||||
autostarts. Check with:
|
||||
That monitor program stopped. The pane is deliberately kept so the layout does
|
||||
not reshuffle and you can see what happened. Check the path under `binaries:`,
|
||||
and that the program works when you run it by hand.
|
||||
|
||||
```sh
|
||||
grep -E '^autologin-(user|session)' /etc/lightdm/lightdm.conf
|
||||
```
|
||||
### The dashboard is on a screen that isn't there
|
||||
|
||||
No output means no autologin. `./install.sh --autologin` writes
|
||||
`/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf` for the current
|
||||
user into the `i3` session. It is opt-in, not a default: **anyone with physical
|
||||
access to the panel gets that user's session**, so only do it where the display
|
||||
user is nothing but the dashboard. Delete the file to get the login prompt back.
|
||||
Some boards report an output with no panel behind it, and X will happily put the
|
||||
dashboard on it — everything looks fine over ssh while the real panel shows an
|
||||
empty desktop. See [`notes/HARDWARE-NOTES.md`](notes/HARDWARE-NOTES.md).
|
||||
|
||||
## Uninstall
|
||||
|
||||
```sh
|
||||
./uninstall.sh # scripts, config, X rule
|
||||
./uninstall.sh # binary, config, udev rule, X snippets, autologin
|
||||
./uninstall.sh --keep-config # keep your settings
|
||||
```
|
||||
|
||||
lisgd, the `input` group and your socktop profiles are deliberately left alone;
|
||||
the script prints how to remove each.
|
||||
The Rust toolchain, tmux, the monitor programs and your `input` group membership
|
||||
are left alone; the script prints how to remove each.
|
||||
|
||||
## Troubleshooting
|
||||
## Notes
|
||||
|
||||
Four gotchas account for nearly every failure. All were found the hard way.
|
||||
Design decisions and the reasoning behind them are in [`notes/`](notes):
|
||||
|
||||
### 1. Gestures are recognised but never fire
|
||||
- [`DESIGN.md`](notes/DESIGN.md) — why tmux, why evdev, why the grid works this way
|
||||
- [`HARDWARE-NOTES.md`](notes/HARDWARE-NOTES.md) — panel quirks, phantom outputs, the `sudo xset` trap
|
||||
- [`V1-BASH.md`](notes/V1-BASH.md) — the shell implementation this replaced, and how to migrate
|
||||
- [`TODO.md`](notes/TODO.md)
|
||||
|
||||
Run `tools/diag.sh` and read stage 2:
|
||||
## Related
|
||||
|
||||
```
|
||||
[swipe]: Cfg(f=1/s=3/e=0/d=0) <=> Evt(f=2/s=3/e=1/d=2)
|
||||
^ configured ^ what happened
|
||||
```
|
||||
- [socktop](https://github.com/jasonwitty/socktop) — the host monitor this was built around
|
||||
- [uptime-kuma-status](https://github.com/jasonwitty/uptime-kuma-status) — Uptime Kuma status pages in a terminal
|
||||
- [unifly](https://github.com/jasonwitty/unifly) — UniFi network TUI
|
||||
|
||||
Matching `s=` with differing `f=` means **your panel reports more contacts than
|
||||
you configured**. Many multipoint panels report 2 or even 3 contacts for a
|
||||
physically one-finger swipe. Add them to `FINGER_COUNTS`.
|
||||
## License
|
||||
|
||||
Direction enum: `0=DU`, `1=UD`, `2=LR`, `3=RL`.
|
||||
|
||||
### 2. Swipes register, but erratically — resizing panes, zooming text
|
||||
|
||||
X is also delivering touch to whatever is on screen, so a single swipe hits
|
||||
several consumers at once: tmux mouse mode drags pane borders, the terminal
|
||||
reads 2-contact swipes as pinch-zoom, and socktop enables its own all-motion
|
||||
mouse reporting (`?1003h`). Meanwhile lisgd fires too.
|
||||
|
||||
Fix with `./install.sh --xignore` and relog. lisgd is unaffected because it
|
||||
reads the device directly. Trade-off: no tap or pinch-zoom on that panel —
|
||||
terminal keyboard zoom (`Ctrl +` / `Ctrl -`) still works.
|
||||
|
||||
Also ensure tmux mouse mode is **off**; `socktop-rack` sets this.
|
||||
|
||||
### 3. Every swipe jumps two panes
|
||||
|
||||
Two lisgd instances are running. `pgrep -x lisgd` should show exactly one.
|
||||
|
||||
`socktop-gestures` refuses to start if a daemon is already running, so this
|
||||
should not happen via the normal path; `--replace` restarts cleanly. It can
|
||||
still occur if lisgd was launched by hand.
|
||||
|
||||
### 4. Swipes do nothing at all
|
||||
|
||||
In order: is the session running (`tmux ls`)? Is the daemon running
|
||||
(`pgrep -x lisgd`)? Does `tools/diag.sh` stage 1 read bytes? Are you in the
|
||||
`input` group (`id -nG | grep input`, needs a relogin)?
|
||||
|
||||
Note lisgd's verbose flag is **`-v`**, not `-D`.
|
||||
|
||||
### Multi-monitor note
|
||||
|
||||
If the display host has a second monitor, X reports the **combined** root window
|
||||
(e.g. 2304x720). lisgd would scale its maths to that, so `SCREEN_W`/`SCREEN_H`
|
||||
must describe the touch panel alone. `socktop-gestures` always passes them
|
||||
explicitly.
|
||||
|
||||
## Known limitations
|
||||
|
||||
- **~300 ms repaint on zoom-in.** Between tmux making the pane full-screen and
|
||||
socktop repainting, you briefly see the old small rendering anchored top-left.
|
||||
This is socktop's redraw latency, not the gesture path; nothing outside
|
||||
socktop can fix it. Zooming out has no visible artifact, since panes return to
|
||||
sizes they were already drawn at.
|
||||
- Deliberately **no wrap-around** at the ends of the carousel — on a wall display
|
||||
wrapping makes it impossible to tell where you are.
|
||||
- X11 only. The lisgd build disables its Wayland backend (`WITHOUT_WAYLAND=1`).
|
||||
|
||||
## Tested on
|
||||
|
||||
LattePanda (Atom x5-Z8350, 1.9 GB RAM, Cherry Trail graphics), 1024x600 ILITEK
|
||||
DSI touch panel, Debian 11, i3 on X11, Alacritty 0.16.1, lisgd from git,
|
||||
monitoring four Raspberry Pis.
|
||||
Apache-2.0.
|
||||
|
||||
+128
@@ -0,0 +1,128 @@
|
||||
# GeeekPi 9" Monitor to 19" Rack Adapter
|
||||
|
||||
A pair of side adapters for mounting the **GeeekPi 9-inch 1280x720 3U rack-mount touchscreen monitor** in a standard **19-inch equipment rack**.
|
||||
|
||||
Three sizes get mentioned here and they are three different things: the **screen**
|
||||
is 9 inches, the mount it ships for is the **10-inch mini-rack** standard, and
|
||||
these adapters convert that to a **19-inch** equipment rack.
|
||||
|
||||
The design also provides **three keystone-jack positions on each side of the display**.
|
||||
|
||||

|
||||
|
||||
## Release v1
|
||||
|
||||
Release v1 contains both:
|
||||
|
||||
- **3D-printable adapters** for left and right sides
|
||||
- **SendCutSend-ready DXF files** for left and right sides
|
||||
|
||||
The mounting geometry has been physically tested in a standard 19-inch rack.
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
cad/
|
||||
├── README.md this file
|
||||
├── geeekpi_rack_adapter_release_v1/ <- use this one
|
||||
│ ├── 3d-print/
|
||||
│ │ ├── geeekpi_19in_adapter_LEFT_release-v1.stl
|
||||
│ │ ├── geeekpi_19in_adapter_RIGHT_release-v1.stl
|
||||
│ │ ├── geeekpi_19in_adapter_LEFT_release-v1_AnycubicMegaPro.gcode
|
||||
│ │ └── geeekpi_19in_adapter_RIGHT_release-v1_AnycubicMegaPro.gcode
|
||||
│ ├── sendcutsend/
|
||||
│ │ ├── geeekpi_19in_adapter_LEFT_release-v1.dxf
|
||||
│ │ └── geeekpi_19in_adapter_RIGHT_release-v1.dxf
|
||||
│ ├── source/
|
||||
│ │ └── geeekpi_19in_adapter_release-v1.scad
|
||||
│ └── docs/
|
||||
│ └── installed.jpg
|
||||
└── geeekpi_v25_FINAL_candidate_package/ earlier candidate, superseded by v1
|
||||
```
|
||||
|
||||
Paths below are relative to `geeekpi_rack_adapter_release_v1/`.
|
||||
|
||||
## Key dimensions
|
||||
|
||||
| Feature | Dimension |
|
||||
| --- | ---: |
|
||||
| Adapter width | 132.3 mm |
|
||||
| Adapter height | 132.0 mm |
|
||||
| 3D-print thickness | 1.8 mm |
|
||||
| Monitor mounting holes | 4.5 mm diameter |
|
||||
| Keystone opening | 16.0 x 22.0 mm |
|
||||
| Keystone vertical centers | 31.5, 66.0, 100.5 mm |
|
||||
| Clear plastic/metal between keystone openings | 12.5 mm |
|
||||
|
||||
All three keystone openings are identical.
|
||||
|
||||
## 3D printing
|
||||
|
||||
The installed adapters shown above were printed in PLA and are strong enough to support the display when mounted.
|
||||
|
||||
Settings used for the tested print:
|
||||
|
||||
- **Material:** PLA
|
||||
- **Layer height:** 0.20 mm
|
||||
- **Overall part thickness:** 1.8 mm
|
||||
- **Infill:** 80%
|
||||
- **Pattern:** alternating horizontal/vertical rectilinear
|
||||
- **Bed:** 60 C
|
||||
- **Nozzle:** 205 C first layer, 200 C afterward
|
||||
- **Cooling fan:** off
|
||||
- **Brim:** approximately 15 mm
|
||||
|
||||
The included `.gcode` files were generated specifically for a **stock Anycubic Mega Pro with a 0.4 mm nozzle**. For another printer, use the STL files and slice them with your own printer profile.
|
||||
|
||||
Good bed leveling matters a lot for these parts because they are broad and very thin.
|
||||
|
||||
## SendCutSend / metal version
|
||||
|
||||
The `geeekpi_rack_adapter_release_v1/sendcutsend/` directory contains separate
|
||||
left and right DXF files.
|
||||
|
||||
A good configuration for the metal adapters is:
|
||||
|
||||
- **Material:** 5052-H32 aluminum
|
||||
- **Thickness:** 0.063 in / approximately 1.6 mm
|
||||
- **Quantity:** one left and one right
|
||||
|
||||
The DXFs use the same keystone opening size and placement as the working 3D-printed adapters:
|
||||
|
||||
- **16.0 mm wide**
|
||||
- **22.0 mm tall**
|
||||
- centers at **31.5, 66.0, and 100.5 mm**
|
||||
|
||||
The remaining rack and monitor mounting geometry is unchanged from the physically tested metal design.
|
||||
|
||||
Depending on the rack, display, and door clearance, small spacers or standoffs between the monitor ears and adapters may be useful to move the screen slightly forward.
|
||||
|
||||
## Assembly
|
||||
|
||||
1. Attach one adapter to each side of the GeeekPi monitor.
|
||||
2. Install any desired keystone jacks before rack installation if access will be easier.
|
||||
3. Bolt the assembled monitor and adapters into a 3U position in the 19-inch rack.
|
||||
4. Add spacers between the monitor and adapters if needed for front clearance.
|
||||
5. Route HDMI, USB, network, or other connections through the keystone positions as desired.
|
||||
|
||||
## Editing the design
|
||||
|
||||
The editable OpenSCAD source is in
|
||||
`geeekpi_rack_adapter_release_v1/source/`.
|
||||
|
||||
To generate a side, change:
|
||||
|
||||
```scad
|
||||
side = "left";
|
||||
```
|
||||
|
||||
to either `"left"` or `"right"`, then render/export the STL.
|
||||
|
||||
The DXF files in `geeekpi_rack_adapter_release_v1/sendcutsend/` are the intended
|
||||
files for sheet-metal cutting.
|
||||
|
||||
## Notes
|
||||
|
||||
This adapter was designed around the GeeekPi 9-inch 3U rack-mount display. Verify dimensions before ordering metal if your display or rack hardware differs.
|
||||
|
||||
The 3D-print version and metal version intentionally use different material thicknesses: **1.8 mm PLA** for the tested printed version and **approximately 1.6 mm aluminum** for the sheet-metal version.
|
||||
+11482
File diff suppressed because it is too large
Load Diff
+28799
File diff suppressed because it is too large
Load Diff
+11482
File diff suppressed because it is too large
Load Diff
+28807
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,117 @@
|
||||
# GeeekPi 9" Monitor to 19" Rack Adapter
|
||||
|
||||
A pair of side adapters for mounting the **GeeekPi 9-inch 1280x720 3U rack-mount touchscreen monitor** in a standard **19-inch equipment rack**.
|
||||
|
||||
The design also provides **three keystone-jack positions on each side of the display**.
|
||||
|
||||

|
||||
|
||||
## Release v1
|
||||
|
||||
Release v1 contains both:
|
||||
|
||||
- **3D-printable adapters** for left and right sides
|
||||
- **SendCutSend-ready DXF files** for left and right sides
|
||||
|
||||
The mounting geometry has been physically tested in a standard 19-inch rack.
|
||||
|
||||
## Files
|
||||
|
||||
```text
|
||||
.
|
||||
├── README.md
|
||||
├── 3d-print/
|
||||
│ ├── geeekpi_19in_adapter_LEFT_release-v1.stl
|
||||
│ ├── geeekpi_19in_adapter_RIGHT_release-v1.stl
|
||||
│ ├── geeekpi_19in_adapter_LEFT_release-v1_AnycubicMegaPro.gcode
|
||||
│ └── geeekpi_19in_adapter_RIGHT_release-v1_AnycubicMegaPro.gcode
|
||||
├── sendcutsend/
|
||||
│ ├── geeekpi_19in_adapter_LEFT_release-v1.dxf
|
||||
│ └── geeekpi_19in_adapter_RIGHT_release-v1.dxf
|
||||
├── source/
|
||||
│ └── geeekpi_19in_adapter_release-v1.scad
|
||||
└── docs/
|
||||
└── installed.jpg
|
||||
```
|
||||
|
||||
## Key dimensions
|
||||
|
||||
| Feature | Dimension |
|
||||
| --- | ---: |
|
||||
| Adapter width | 132.3 mm |
|
||||
| Adapter height | 132.0 mm |
|
||||
| 3D-print thickness | 1.8 mm |
|
||||
| Monitor mounting holes | 4.5 mm diameter |
|
||||
| Keystone opening | 16.0 x 22.0 mm |
|
||||
| Keystone vertical centers | 31.5, 66.0, 100.5 mm |
|
||||
| Clear plastic/metal between keystone openings | 12.5 mm |
|
||||
|
||||
All three keystone openings are identical.
|
||||
|
||||
## 3D printing
|
||||
|
||||
The installed adapters shown above were printed in PLA and are strong enough to support the display when mounted.
|
||||
|
||||
Settings used for the tested print:
|
||||
|
||||
- **Material:** PLA
|
||||
- **Layer height:** 0.20 mm
|
||||
- **Overall part thickness:** 1.8 mm
|
||||
- **Infill:** 80%
|
||||
- **Pattern:** alternating horizontal/vertical rectilinear
|
||||
- **Bed:** 60 C
|
||||
- **Nozzle:** 205 C first layer, 200 C afterward
|
||||
- **Cooling fan:** off
|
||||
- **Brim:** approximately 15 mm
|
||||
|
||||
The included `.gcode` files were generated specifically for a **stock Anycubic Mega Pro with a 0.4 mm nozzle**. For another printer, use the STL files and slice them with your own printer profile.
|
||||
|
||||
Good bed leveling matters a lot for these parts because they are broad and very thin.
|
||||
|
||||
## SendCutSend / metal version
|
||||
|
||||
The `sendcutsend/` directory contains separate left and right DXF files.
|
||||
|
||||
A good configuration for the metal adapters is:
|
||||
|
||||
- **Material:** 5052-H32 aluminum
|
||||
- **Thickness:** 0.063 in / approximately 1.6 mm
|
||||
- **Quantity:** one left and one right
|
||||
|
||||
The DXFs use the same keystone opening size and placement as the working 3D-printed adapters:
|
||||
|
||||
- **16.0 mm wide**
|
||||
- **22.0 mm tall**
|
||||
- centers at **31.5, 66.0, and 100.5 mm**
|
||||
|
||||
The remaining rack and monitor mounting geometry is unchanged from the physically tested metal design.
|
||||
|
||||
Depending on the rack, display, and door clearance, small spacers or standoffs between the monitor ears and adapters may be useful to move the screen slightly forward.
|
||||
|
||||
## Assembly
|
||||
|
||||
1. Attach one adapter to each side of the GeeekPi monitor.
|
||||
2. Install any desired keystone jacks before rack installation if access will be easier.
|
||||
3. Bolt the assembled monitor and adapters into a 3U position in the 19-inch rack.
|
||||
4. Add spacers between the monitor and adapters if needed for front clearance.
|
||||
5. Route HDMI, USB, network, or other connections through the keystone positions as desired.
|
||||
|
||||
## Editing the design
|
||||
|
||||
The editable OpenSCAD source is in `source/`.
|
||||
|
||||
To generate a side, change:
|
||||
|
||||
```scad
|
||||
side = "left";
|
||||
```
|
||||
|
||||
to either `"left"` or `"right"`, then render/export the STL.
|
||||
|
||||
The DXF files in `sendcutsend/` are the intended files for sheet-metal cutting.
|
||||
|
||||
## Notes
|
||||
|
||||
This adapter was designed around the GeeekPi 9-inch 3U rack-mount display. Verify dimensions before ordering metal if your display or rack hardware differs.
|
||||
|
||||
The 3D-print version and metal version intentionally use different material thicknesses: **1.8 mm PLA** for the tested printed version and **approximately 1.6 mm aluminum** for the sheet-metal version.
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 573 KiB |
+3288
File diff suppressed because it is too large
Load Diff
+3288
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,54 @@
|
||||
/*
|
||||
GeeekPi 9" 3U monitor to standard 19" rack adapter
|
||||
Release v1
|
||||
|
||||
Set side to "left" or "right" before rendering.
|
||||
*/
|
||||
side = "left";
|
||||
|
||||
part_w = 132.3;
|
||||
part_h = 132.0;
|
||||
thickness = 1.8;
|
||||
|
||||
y_bottom = 6.5;
|
||||
y_top = 125.5;
|
||||
|
||||
rack_x_from_outer = 8.7376;
|
||||
rack_slot_w = 4.0;
|
||||
rack_slot_h = 7.0;
|
||||
|
||||
monitor_hole_d = 4.5;
|
||||
monitor_x_left = 123.3;
|
||||
|
||||
key_x_left = 65.0;
|
||||
key_w = 16.0;
|
||||
key_h = 22.0;
|
||||
key_y = [31.5, 66.0, 100.5];
|
||||
|
||||
module wing(left=true) {
|
||||
rack_x = left ? rack_x_from_outer : part_w-rack_x_from_outer;
|
||||
monitor_x = left ? monitor_x_left : part_w-monitor_x_left;
|
||||
key_x = left ? key_x_left : part_w-key_x_left;
|
||||
|
||||
linear_extrude(height=thickness)
|
||||
difference() {
|
||||
square([part_w, part_h]);
|
||||
|
||||
// Rack mounting slots - preserved from the tested metal geometry.
|
||||
for (y=[y_bottom,y_top])
|
||||
translate([rack_x-rack_slot_w/2, y-rack_slot_h/2])
|
||||
square([rack_slot_w,rack_slot_h]);
|
||||
|
||||
// Monitor mounting holes.
|
||||
for (y=[y_bottom,y_top])
|
||||
translate([monitor_x,y])
|
||||
circle(d=monitor_hole_d,$fn=96);
|
||||
|
||||
// Three identical keystone openings.
|
||||
for (y=key_y)
|
||||
translate([key_x-key_w/2,y-key_h/2])
|
||||
square([key_w,key_h]);
|
||||
}
|
||||
}
|
||||
|
||||
wing(side=="left");
|
||||
@@ -0,0 +1,48 @@
|
||||
{
|
||||
"LEFT": {
|
||||
"non_keystone_geometry_unchanged": true,
|
||||
"keystones": [
|
||||
{
|
||||
"width": 16.0,
|
||||
"height": 22.0,
|
||||
"center_x": 65.0,
|
||||
"center_y": 31.5
|
||||
},
|
||||
{
|
||||
"width": 16.0,
|
||||
"height": 22.0,
|
||||
"center_x": 65.0,
|
||||
"center_y": 66.0
|
||||
},
|
||||
{
|
||||
"width": 16.0,
|
||||
"height": 22.0,
|
||||
"center_x": 65.0,
|
||||
"center_y": 100.5
|
||||
}
|
||||
]
|
||||
},
|
||||
"RIGHT": {
|
||||
"non_keystone_geometry_unchanged": true,
|
||||
"keystones": [
|
||||
{
|
||||
"width": 16.0,
|
||||
"height": 22.0,
|
||||
"center_x": 67.3,
|
||||
"center_y": 31.5
|
||||
},
|
||||
{
|
||||
"width": 16.0,
|
||||
"height": 22.0,
|
||||
"center_x": 67.3,
|
||||
"center_y": 66.0
|
||||
},
|
||||
{
|
||||
"width": 16.0,
|
||||
"height": 22.0,
|
||||
"center_x": 67.3,
|
||||
"center_y": 100.5
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1 @@
|
||||
V25 = V24 with only the keystone height reduced from 23 mm to 22 mm. Keystone width remains 16 mm and all three center positions remain exactly where they were in V24. All V24 print settings are unchanged.
|
||||
+11482
File diff suppressed because it is too large
Load Diff
+11482
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,23 @@
|
||||
part="left";
|
||||
part_w=132.3; part_h=132.0; thickness=1.8;
|
||||
y_bottom=6.5; y_top=125.5;
|
||||
rack_x_from_outer=8.7376; rack_slot_l=11.0; rack_slot_h=7.0;
|
||||
monitor_hole_d=4.5; monitor_x_left=123.3;
|
||||
key_x_left=65.0; key_w=16.0; key_h=22.0;
|
||||
key_y=[30.5,66.0,101.5];
|
||||
module pill2d(length,height) { hull() {
|
||||
translate([-(length-height)/2,0]) circle(d=height,$fn=96);
|
||||
translate([ +(length-height)/2,0]) circle(d=height,$fn=96);
|
||||
} }
|
||||
module wing(left=true) {
|
||||
rx=left ? rack_x_from_outer : part_w-rack_x_from_outer;
|
||||
mx=left ? monitor_x_left : part_w-monitor_x_left;
|
||||
kx=left ? key_x_left : part_w-key_x_left;
|
||||
linear_extrude(height=thickness) difference() {
|
||||
square([part_w,part_h]);
|
||||
for(y=[y_bottom,y_top]) translate([rx,y]) pill2d(rack_slot_l,rack_slot_h);
|
||||
for(y=[y_bottom,y_top]) translate([mx,y]) circle(d=monitor_hole_d,$fn=96);
|
||||
for(y=key_y) translate([kx-key_w/2,y-key_h/2]) square([key_w,key_h]);
|
||||
}
|
||||
}
|
||||
wing(part=="left");
|
||||
@@ -0,0 +1,49 @@
|
||||
{
|
||||
"change_from_v24": "Only keystone height reduced 23.0 -> 22.0 mm; centers unchanged",
|
||||
"part_mm": [
|
||||
132.3,
|
||||
132.0,
|
||||
1.8
|
||||
],
|
||||
"keystone_all_three_mm": [
|
||||
16.0,
|
||||
22.0
|
||||
],
|
||||
"keystone_centers_y_mm": [
|
||||
30.5,
|
||||
66.0,
|
||||
101.5
|
||||
],
|
||||
"clear_bridge_mm": 13.5,
|
||||
"print_profile": {
|
||||
"infill_percent": 80,
|
||||
"pattern": "alternating H/V rectilinear",
|
||||
"fan_percent": 0,
|
||||
"bed_c": 60,
|
||||
"nozzle_first_c": 205,
|
||||
"nozzle_after_c": 200,
|
||||
"brim_mm": 15.2
|
||||
},
|
||||
"mesh": {
|
||||
"LEFT": {
|
||||
"watertight": true,
|
||||
"extents_mm": [
|
||||
132.3,
|
||||
132.0,
|
||||
1.8
|
||||
]
|
||||
},
|
||||
"RIGHT": {
|
||||
"watertight": true,
|
||||
"extents_mm": [
|
||||
132.3,
|
||||
132.0,
|
||||
1.8
|
||||
]
|
||||
}
|
||||
},
|
||||
"retractions": {
|
||||
"LEFT": 1412,
|
||||
"RIGHT": 1412
|
||||
}
|
||||
}
|
||||
-91
@@ -1,91 +0,0 @@
|
||||
# socktop-swipe configuration.
|
||||
#
|
||||
# Installed to /usr/local/etc/socktop-swipe.env. Every script reads that file if
|
||||
# it exists, so edit it there on the display host -- not this copy in the repo.
|
||||
# After editing, restart the gesture daemon and rebuild the session (see README).
|
||||
#
|
||||
# Values use ${VAR:-default} so an environment variable of the same name always
|
||||
# wins over this file. That keeps the file editable in the obvious way while
|
||||
# still allowing one-off overrides, e.g. SOCKTOP_SESSION=test socktop-rack
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# What to monitor
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# What to show, as GROUPS separated by ";". Each group is one screen of the
|
||||
# carousel: a tiled overview of its hosts, then each host zoomed full-screen in
|
||||
# turn, then on to the next group. Host names are socktop profile names from
|
||||
# ~/.config/socktop/profiles.json.
|
||||
#
|
||||
# overview(A) -> A1 -> A2 -> ... -> overview(B) -> B1 -> B2 -> ...
|
||||
#
|
||||
# An optional "@layout" token in a group sets its tmux overview layout
|
||||
# (tiled, even-horizontal, even-vertical, main-horizontal, main-vertical).
|
||||
# Default is tiled: 4 hosts become quarters, 2 become side-by-side.
|
||||
# "@even-vertical" stacks the panes one above the other instead.
|
||||
#
|
||||
# The example below is the rack display: the four Pis quartered and zoomable,
|
||||
# then the Orange Pi and the Debian box stacked and zoomable.
|
||||
SOCKTOP_GROUPS="${SOCKTOP_GROUPS:-rpi-master rpi-worker-1 rpi-worker-2 rpi-worker-3 ; orangepi trixie @even-vertical}"
|
||||
|
||||
# Legacy single-group form. Used only when SOCKTOP_GROUPS is empty.
|
||||
SOCKTOP_HOSTS="${SOCKTOP_HOSTS:-}"
|
||||
|
||||
# Optional "aux" screen: a different TUI reached by swiping DOWN from any
|
||||
# carousel screen; swiping UP returns to exactly the screen you left. It runs in
|
||||
# its own tmux window, kept alive like the socktop panes, so switching is
|
||||
# instant. Leave empty to disable. Use full paths -- i3 has no ~/.cargo/bin.
|
||||
SOCKTOP_AUX_CMD="${SOCKTOP_AUX_CMD:-$HOME/.cargo/bin/uptime-kuma-status https://status.wittyoneoff.com/status/wittyoneoff}"
|
||||
|
||||
# tmux session name. Change only if it collides with something else.
|
||||
SOCKTOP_SESSION="${SOCKTOP_SESSION:-socktop4}"
|
||||
|
||||
# Absolute path to the socktop binary. i3 and non-login shells do not have
|
||||
# ~/.cargo/bin on PATH, so a full path is safer than relying on lookup.
|
||||
SOCKTOP_BIN="${SOCKTOP_BIN:-$HOME/.cargo/bin/socktop}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Touch panel
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# The touchscreen event device. ALWAYS prefer a /dev/input/by-id/ symlink --
|
||||
# /dev/input/eventN numbers get reshuffled on reboot or USB re-enumeration.
|
||||
# Find yours with: tools/find-device.sh
|
||||
TOUCH_DEV="${TOUCH_DEV:-/dev/input/by-id/usb-ILITEK_ILITEK-TOUCH-event-if00}"
|
||||
|
||||
# The touch panel's own resolution -- NOT the X screen size. lisgd otherwise
|
||||
# asks X, which reports the full root window across all monitors (e.g. 2304x720
|
||||
# with a second display attached) and skews its edge and distance maths.
|
||||
SCREEN_W="${SCREEN_W:-1024}"
|
||||
SCREEN_H="${SCREEN_H:-600}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Gesture tuning
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Pixels of travel before a drag counts as a swipe. Too low and stray contact
|
||||
# triggers it; too high and normal swipes are ignored. Measured on the ILITEK
|
||||
# panel: real swipes land at 150-260px, accidental ones under 70px.
|
||||
SWIPE_THRESHOLD="${SWIPE_THRESHOLD:-80}"
|
||||
|
||||
# Degrees of leniency off the axis, max 45. Real finger swipes came in 1-25
|
||||
# degrees off true horizontal, so 30 leaves comfortable margin.
|
||||
SWIPE_LENIENCY="${SWIPE_LENIENCY:-30}"
|
||||
|
||||
# Contact counts to accept for one logical swipe.
|
||||
#
|
||||
# THIS IS THE SETTING THAT MOST OFTEN NEEDS CHANGING ON NEW HARDWARE. Many
|
||||
# multipoint panels report 2 or even 3 contacts for what is physically a
|
||||
# one-finger swipe (palm, or ghost contacts). If gestures are recognised but
|
||||
# never fire, run tools/diag.sh and compare Cfg(f=N) against Evt(f=N).
|
||||
FINGER_COUNTS="${FINGER_COUNTS:-1 2 3}"
|
||||
|
||||
# Swipe directions. RL = right-to-left = zoom in; LR = left-to-right = zoom out.
|
||||
# Swap these two values to reverse the direction of travel.
|
||||
GESTURE_IN="${GESTURE_IN:-RL}"
|
||||
GESTURE_OUT="${GESTURE_OUT:-LR}"
|
||||
|
||||
# Aux screen swipes. UD = up-to-down (swipe down) shows it; DU = down-to-up
|
||||
# (swipe up) returns. Only bound when SOCKTOP_AUX_CMD is set.
|
||||
GESTURE_AUX_IN="${GESTURE_AUX_IN:-UD}"
|
||||
GESTURE_AUX_OUT="${GESTURE_AUX_OUT:-DU}"
|
||||
@@ -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
|
||||
+400
-198
@@ -1,238 +1,440 @@
|
||||
#!/bin/sh
|
||||
# socktop-swipe installer.
|
||||
#
|
||||
# ./install.sh scripts + config + lisgd (build if missing)
|
||||
# ./install.sh --xignore ...and tell X to ignore the touch panel
|
||||
# ./install.sh --i3 ...and add i3 autostart lines
|
||||
# ./install.sh --noblank ...and stop the screen blanking
|
||||
# ./install.sh --autologin ...and make lightdm log this user straight into i3
|
||||
# ./install.sh --xignore --i3 --noblank --autologin full wall-display setup
|
||||
# curl -fsSL https://gt.wittyoneoff.com/jason/socktop-swipe/raw/branch/main/install.sh | sh
|
||||
#
|
||||
# Safe to re-run: an existing config file is never overwritten, and the i3 edit
|
||||
# is skipped if already present.
|
||||
# or, from a checkout:
|
||||
#
|
||||
# ./install.sh
|
||||
#
|
||||
# Everything is checked BEFORE anything is installed, and the full cost is
|
||||
# printed once. Nothing touches the system before you answer the summary.
|
||||
#
|
||||
# --yes accept every default without asking
|
||||
# --uninstall hand over to uninstall.sh
|
||||
# --prefix DIR install to DIR/bin instead of /usr/local/bin
|
||||
set -eu
|
||||
|
||||
PREFIX=${PREFIX:-/usr/local}
|
||||
BIN="$PREFIX/bin"
|
||||
ETC="$PREFIX/etc"
|
||||
CONF="$ETC/socktop-swipe.env"
|
||||
XCONF=/etc/X11/xorg.conf.d/99-ignore-touch-socktop-swipe.conf
|
||||
BCONF=/etc/X11/xorg.conf.d/10-no-blanking-socktop-swipe.conf
|
||||
LCONF=/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf
|
||||
SRC="$HOME/src/lisgd"
|
||||
REPO=https://gt.wittyoneoff.com/jason/socktop-swipe
|
||||
PREFIX=/usr/local
|
||||
ASSUME_YES=no
|
||||
SRC=""
|
||||
|
||||
here=$(cd "$(dirname "$0")" && pwd)
|
||||
do_xignore=no
|
||||
do_i3=no
|
||||
do_noblank=no
|
||||
do_autologin=no
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--xignore) do_xignore=yes ;;
|
||||
--i3) do_i3=yes ;;
|
||||
--noblank) do_noblank=yes ;;
|
||||
--autologin) do_autologin=yes ;;
|
||||
--yes | -y) ASSUME_YES=yes ;;
|
||||
--prefix=*) PREFIX=${a#--prefix=} ;;
|
||||
--uninstall) exec sh -c "$(dirname "$0")/uninstall.sh" ;;
|
||||
-h | --help) sed -n '2,16p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown option: $a" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# --- lisgd ------------------------------------------------------------------
|
||||
# libinput deliberately emits gesture events only for touchpads, never for
|
||||
# touchscreens, so libinput-gestures and friends cannot work here. lisgd reads
|
||||
# raw touch events and synthesises the swipes itself.
|
||||
if command -v lisgd >/dev/null 2>&1; then
|
||||
echo "==> lisgd already installed: $(command -v lisgd)"
|
||||
else
|
||||
echo "==> building lisgd"
|
||||
if command -v apt-get >/dev/null 2>&1; then
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y build-essential pkg-config git libinput-dev libx11-dev
|
||||
else
|
||||
echo "!! Non-Debian system: ensure a C toolchain, libinput and libX11 headers"
|
||||
echo "!! are present, then re-run."
|
||||
BIN="$PREFIX/bin"
|
||||
CONF_DIR="$HOME/.config/socktop-swipe"
|
||||
CONF="$CONF_DIR/config.yaml"
|
||||
UDEV=/etc/udev/rules.d/70-socktop-swipe.rules
|
||||
XBLANK=/etc/X11/xorg.conf.d/10-no-blanking-socktop-swipe.conf
|
||||
LIGHTDM=/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Asking
|
||||
# --------------------------------------------------------------------------
|
||||
# Piping into sh leaves stdin holding the SCRIPT, not a keyboard, so prompts
|
||||
# must read the terminal directly. With no terminal at all we take defaults and
|
||||
# say so rather than silently guessing.
|
||||
if [ -r /dev/tty ] && [ -t 1 ]; then HAVE_TTY=yes; else HAVE_TTY=no; fi
|
||||
|
||||
say() { printf '%s\n' "$*"; }
|
||||
step() { printf '\n==> %s\n' "$*"; }
|
||||
warn() { printf ' !! %s\n' "$*" >&2; }
|
||||
|
||||
# ask "question" default(y|n) -> 0 for yes
|
||||
ask() {
|
||||
_q=$1
|
||||
_d=$2
|
||||
if [ "$ASSUME_YES" = yes ] || [ "$HAVE_TTY" = no ]; then
|
||||
[ "$_d" = y ] && return 0 || return 1
|
||||
fi
|
||||
[ "$_d" = y ] && _hint="[Y/n]" || _hint="[y/N]"
|
||||
while :; do
|
||||
printf ' %s %s ' "$_q" "$_hint" >/dev/tty
|
||||
read -r _a </dev/tty || _a=""
|
||||
[ -z "$_a" ] && _a=$_d
|
||||
case "$_a" in
|
||||
[Yy] | [Yy][Ee][Ss]) return 0 ;;
|
||||
[Nn] | [Nn][Oo]) return 1 ;;
|
||||
esac
|
||||
done
|
||||
}
|
||||
|
||||
# askval "question" "default" -> echoes the answer
|
||||
askval() {
|
||||
if [ "$ASSUME_YES" = yes ] || [ "$HAVE_TTY" = no ]; then
|
||||
printf '%s' "$2"
|
||||
return
|
||||
fi
|
||||
printf ' %s [%s] ' "$1" "$2" >/dev/tty
|
||||
read -r _a </dev/tty || _a=""
|
||||
[ -z "$_a" ] && _a=$2
|
||||
printf '%s' "$_a"
|
||||
}
|
||||
|
||||
have() { command -v "$1" >/dev/null 2>&1; }
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Preflight
|
||||
# --------------------------------------------------------------------------
|
||||
say "socktop-swipe installer"
|
||||
say "======================="
|
||||
|
||||
OS_NAME=$(. /etc/os-release 2>/dev/null && printf '%s' "${PRETTY_NAME:-unknown}" || true)
|
||||
[ -n "$OS_NAME" ] || OS_NAME=unknown
|
||||
ARCH=$(uname -m)
|
||||
|
||||
PKG=""
|
||||
PKG_INSTALL=""
|
||||
for p in apt-get dnf pacman zypper apk; do
|
||||
if have $p; then
|
||||
PKG=$p
|
||||
break
|
||||
fi
|
||||
done
|
||||
case "$PKG" in
|
||||
apt-get) PKG_INSTALL="sudo apt-get install -y" ;;
|
||||
dnf) PKG_INSTALL="sudo dnf install -y" ;;
|
||||
pacman) PKG_INSTALL="sudo pacman -S --needed --noconfirm" ;;
|
||||
zypper) PKG_INSTALL="sudo zypper install -y" ;;
|
||||
apk) PKG_INSTALL="sudo apk add" ;;
|
||||
esac
|
||||
|
||||
# Display manager and window manager, for the autostart and autologin steps.
|
||||
# NB: written as `if`, not `[ x ] && y`. Under `set -e` an AND-OR list that
|
||||
# ends up false aborts the script, so a box with no lightdm would exit here.
|
||||
DM=none
|
||||
if [ -d /etc/lightdm ]; then DM=lightdm
|
||||
elif [ -d /etc/gdm3 ] || [ -d /etc/gdm ]; then DM=gdm
|
||||
elif [ -f /etc/sddm.conf ] || [ -d /etc/sddm.conf.d ]; then DM=sddm
|
||||
fi
|
||||
|
||||
WM=none
|
||||
if have i3; then WM=i3; fi
|
||||
|
||||
TERMINAL=""
|
||||
for t in alacritty foot kitty xterm; do
|
||||
if have $t; then
|
||||
TERMINAL=$t
|
||||
break
|
||||
fi
|
||||
done
|
||||
|
||||
LOCKER=""
|
||||
for l in xss-lock light-locker xscreensaver gnome-screensaver; do
|
||||
if have $l; then
|
||||
LOCKER="$LOCKER $l"
|
||||
fi
|
||||
done
|
||||
|
||||
# Space for the toolchain and the build. Both land under $HOME unless
|
||||
# CARGO_HOME says otherwise, which is exactly the 8GB-of-eMMC case.
|
||||
avail_mb() { df -Pm "$1" 2>/dev/null | awk 'NR==2 {print $4}'; }
|
||||
HOME_FREE=$(avail_mb "$HOME")
|
||||
PREFIX_FREE=$(avail_mb "$(dirname "$PREFIX")")
|
||||
|
||||
step "Found"
|
||||
say " system $OS_NAME ($ARCH)"
|
||||
say " packages ${PKG:-none detected}"
|
||||
say " display mgr $DM"
|
||||
say " window mgr $WM"
|
||||
say " terminal ${TERMINAL:-none found}"
|
||||
say " tmux $(tmux -V 2>/dev/null || echo 'NOT INSTALLED')"
|
||||
# Not `cargo --version | cut ... || echo`: the pipeline's status is cut's, and
|
||||
# cut succeeds on empty input, so the fallback would never fire.
|
||||
if have cargo; then
|
||||
say " cargo $(cargo --version 2>/dev/null | cut -d' ' -f1-2)"
|
||||
else
|
||||
say " cargo NOT INSTALLED"
|
||||
fi
|
||||
say " git $(git --version 2>/dev/null || echo 'NOT INSTALLED')"
|
||||
say " free space ${HOME_FREE:-?} MB in \$HOME, ${PREFIX_FREE:-?} MB on $PREFIX"
|
||||
if [ -n "$LOCKER" ]; then say " screen locker $LOCKER"; fi
|
||||
|
||||
# What we are about to need.
|
||||
NEED_PKGS=""
|
||||
NEED_RUSTUP=no
|
||||
COST_MB=10
|
||||
|
||||
if ! have tmux; then NEED_PKGS="$NEED_PKGS tmux"; fi
|
||||
if ! have git; then NEED_PKGS="$NEED_PKGS git"; fi
|
||||
if ! have cargo; then
|
||||
NEED_RUSTUP=yes
|
||||
COST_MB=$((COST_MB + 1200))
|
||||
fi
|
||||
# The build itself. Measured on an Atom x5-Z8350: a 103 MB target directory,
|
||||
# 108 seconds, peaking at 1.1 GB of RAM. 250 leaves headroom.
|
||||
COST_MB=$((COST_MB + 250))
|
||||
|
||||
if [ -z "$TERMINAL" ]; then
|
||||
warn "no terminal emulator found. socktop-swipe can attach in an existing"
|
||||
warn "terminal, but unattended autostart needs one. alacritty is the tested choice."
|
||||
fi
|
||||
if [ -z "$PKG" ]; then
|
||||
warn "no supported package manager. Nothing will be installed for you;"
|
||||
warn "make sure tmux, git and a Rust toolchain are present."
|
||||
fi
|
||||
if [ -n "$HOME_FREE" ] && [ "$HOME_FREE" -lt "$COST_MB" ]; then
|
||||
warn "only ${HOME_FREE} MB free in \$HOME but about ${COST_MB} MB is needed."
|
||||
warn "A source build will not fit. Free space first, or set CARGO_HOME and"
|
||||
warn "CARGO_TARGET_DIR to somewhere with room."
|
||||
fi
|
||||
|
||||
step "This will"
|
||||
if [ -n "$NEED_PKGS" ]; then say " install packages:$NEED_PKGS"; fi
|
||||
if [ "$NEED_RUSTUP" = yes ]; then say " install the Rust toolchain via rustup (~1.2 GB)"; fi
|
||||
say " build socktop-swipe from source (~100 MB of build artifacts)"
|
||||
say " install the binary to $BIN"
|
||||
say " write a starter config to $CONF"
|
||||
say " ...then ask about the touch device, autostart, autologin and blanking."
|
||||
say ""
|
||||
say " About ${COST_MB} MB of disk. Nothing has been changed yet."
|
||||
say ""
|
||||
|
||||
if [ "$HAVE_TTY" = no ]; then
|
||||
say "No terminal available, so every question takes its default."
|
||||
say "Run the script directly for the interactive version:"
|
||||
say " curl -fsSLO $REPO/raw/branch/main/install.sh && sh install.sh"
|
||||
say ""
|
||||
fi
|
||||
|
||||
ask "Continue?" y || { say "Nothing done."; exit 0; }
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Dependencies
|
||||
# --------------------------------------------------------------------------
|
||||
if [ -n "$NEED_PKGS" ]; then
|
||||
step "Installing:$NEED_PKGS"
|
||||
if [ -z "$PKG_INSTALL" ]; then
|
||||
warn "no package manager -- install$NEED_PKGS yourself and re-run."
|
||||
exit 1
|
||||
fi
|
||||
if [ "$PKG" = apt-get ]; then sudo apt-get update; fi
|
||||
# shellcheck disable=SC2086
|
||||
$PKG_INSTALL $NEED_PKGS
|
||||
fi
|
||||
|
||||
if [ "$NEED_RUSTUP" = yes ]; then
|
||||
step "Installing the Rust toolchain"
|
||||
if ask "Install rustup now?" y; then
|
||||
curl -fsSL https://sh.rustup.rs | sh -s -- -y --profile minimal --no-modify-path
|
||||
# shellcheck disable=SC1091
|
||||
. "$HOME/.cargo/env"
|
||||
else
|
||||
say "Install Rust yourself, then re-run this script."
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
if ! have cargo; then warn "cargo still not on PATH -- open a new shell and re-run."; exit 1; fi
|
||||
|
||||
# --------------------------------------------------------------------------
|
||||
# Source
|
||||
# --------------------------------------------------------------------------
|
||||
here=$(CDPATH='' cd -- "$(dirname -- "$0")" 2>/dev/null && pwd || echo "")
|
||||
if [ -n "$here" ] && [ -f "$here/Cargo.toml" ]; then
|
||||
SRC=$here
|
||||
step "Building from this checkout: $SRC"
|
||||
else
|
||||
SRC="$HOME/.local/src/socktop-swipe"
|
||||
step "Fetching the source to $SRC"
|
||||
mkdir -p "$(dirname "$SRC")"
|
||||
if [ -d "$SRC/.git" ]; then git -C "$SRC" pull --ff-only; else
|
||||
git clone https://git.sr.ht/~mil/lisgd "$SRC"
|
||||
if [ -d "$SRC/.git" ]; then
|
||||
git -C "$SRC" pull --ff-only
|
||||
else
|
||||
git clone "$REPO" "$SRC"
|
||||
fi
|
||||
# WITHOUT_WAYLAND: lisgd builds both backends by default and would otherwise
|
||||
# need libwayland-dev for code that never runs on an X11-only host.
|
||||
make -C "$SRC" WITHOUT_WAYLAND=1
|
||||
sudo make -C "$SRC" install WITHOUT_WAYLAND=1 PREFIX="$PREFIX"
|
||||
fi
|
||||
|
||||
# --- scripts and config -----------------------------------------------------
|
||||
echo "==> installing scripts to $BIN"
|
||||
sudo install -d "$BIN" "$ETC"
|
||||
sudo install -m 755 "$here/socktop-rack" "$here/socktop-swipe" "$here/socktop-gestures" "$BIN/"
|
||||
step "Building (this is the slow part on a low-power box)"
|
||||
( cd "$SRC" && cargo build --release )
|
||||
sudo install -d "$BIN"
|
||||
sudo install -m 755 "$SRC/target/release/socktop-swipe" "$BIN/socktop-swipe"
|
||||
say " installed $BIN/socktop-swipe"
|
||||
|
||||
if [ -e "$CONF" ]; then
|
||||
echo "==> keeping existing $CONF (not overwritten)"
|
||||
# --------------------------------------------------------------------------
|
||||
# Touch device
|
||||
# --------------------------------------------------------------------------
|
||||
step "Touch panel"
|
||||
DEVICE=""
|
||||
# Prefer the by-id path: eventN numbers get reshuffled on reboot.
|
||||
for d in /dev/input/by-id/*event*; do
|
||||
[ -e "$d" ] || continue
|
||||
if udevadm info --query=property --name="$d" 2>/dev/null | grep -q '^ID_INPUT_TOUCHSCREEN=1'; then
|
||||
say " found touchscreen: $d"
|
||||
if [ -z "$DEVICE" ]; then DEVICE=$d; fi
|
||||
fi
|
||||
done
|
||||
if [ -z "$DEVICE" ]; then
|
||||
warn "no touchscreen found under /dev/input/by-id/."
|
||||
warn "If the panel is not plugged in yet, that is fine -- set touch.device"
|
||||
warn "in the config later. 'socktop-swipe doctor --list' will show it."
|
||||
DEVICE=/dev/input/by-id/CHANGE-ME
|
||||
else
|
||||
sudo install -m 644 "$here/config.env" "$CONF"
|
||||
echo "==> installed default config to $CONF"
|
||||
echo " EDIT IT before first run if your hardware differs."
|
||||
DEVICE=$(askval "Use which device?" "$DEVICE")
|
||||
fi
|
||||
|
||||
# --- input group ------------------------------------------------------------
|
||||
if id -nG | tr ' ' '\n' | grep -qx input; then
|
||||
echo "==> already in the 'input' group"
|
||||
relogin=no
|
||||
else
|
||||
echo "==> adding $(id -un) to the 'input' group (lisgd reads /dev/input directly)"
|
||||
step "Device access"
|
||||
say " Reading touch events needs permission on the device."
|
||||
say " A udev rule grants it for touchscreens only and takes effect at once."
|
||||
say " The 'input' group grants access to every input device, keyboard included,"
|
||||
say " and needs a full logout."
|
||||
RELOGIN=no
|
||||
if ask "Install the udev rule?" y; then
|
||||
sudo install -m 644 "$SRC/packaging/70-socktop-swipe.rules" "$UDEV"
|
||||
sudo udevadm control --reload
|
||||
sudo udevadm trigger --subsystem-match=input
|
||||
say " wrote $UDEV"
|
||||
if ! id -nG | tr ' ' '\n' | grep -qx input; then
|
||||
warn "the rule uses GROUP=input and you are not in it."
|
||||
if ask "Add $(id -un) to the 'input' group as well?" y; then
|
||||
sudo usermod -aG input "$(id -un)"
|
||||
RELOGIN=yes
|
||||
fi
|
||||
fi
|
||||
elif ask "Add $(id -un) to the 'input' group instead?" n; then
|
||||
sudo usermod -aG input "$(id -un)"
|
||||
relogin=yes
|
||||
RELOGIN=yes
|
||||
fi
|
||||
|
||||
# --- optional: make X ignore the panel --------------------------------------
|
||||
if [ "$do_xignore" = yes ]; then
|
||||
# shellcheck disable=SC1090
|
||||
. "$CONF"
|
||||
product=${XIGNORE_MATCH:-ILITEK}
|
||||
echo "==> telling X to ignore touch devices matching '$product'"
|
||||
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||
sudo tee "$XCONF" >/dev/null <<EOF
|
||||
# Installed by socktop-swipe.
|
||||
# The panel is driven by lisgd via /dev/input, NOT through X. Letting X also
|
||||
# deliver touch makes the terminal, tmux and socktop react to the same swipes
|
||||
# the gesture daemon is interpreting. MatchProduct is narrow on purpose so it
|
||||
# cannot match keyboards or other HID devices.
|
||||
Section "InputClass"
|
||||
Identifier "ignore touchscreen (socktop-swipe)"
|
||||
MatchProduct "$product"
|
||||
MatchIsTouchscreen "on"
|
||||
Option "Ignore" "on"
|
||||
EndSection
|
||||
EOF
|
||||
echo " wrote $XCONF (takes effect after X restarts)"
|
||||
relogin=yes
|
||||
# --------------------------------------------------------------------------
|
||||
# Config
|
||||
# --------------------------------------------------------------------------
|
||||
step "Configuration"
|
||||
mkdir -p "$CONF_DIR"
|
||||
if [ -e "$CONF" ]; then
|
||||
say " keeping the existing $CONF"
|
||||
else
|
||||
sed "s|^ device: .*| device: $DEVICE|" "$SRC/config.example.yaml" >"$CONF"
|
||||
say " wrote $CONF"
|
||||
say " EDIT IT: the example screens are the author's rack, not yours."
|
||||
fi
|
||||
|
||||
# --- optional: stop the screen blanking -------------------------------------
|
||||
if [ "$do_noblank" = yes ]; then
|
||||
echo "==> disabling screen blanking and DPMS"
|
||||
# Two layers on purpose:
|
||||
# 1. An Xorg ServerFlags snippet, so it applies to every X session including
|
||||
# the display manager greeter, and survives reboots.
|
||||
# 2. An xset call at i3 startup, because a session or DM can re-enable the
|
||||
# screensaver after X starts.
|
||||
# Note `xset` alone is NOT enough, and must run as the session user -- running
|
||||
# it under sudo targets root's X connection and silently does nothing.
|
||||
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||
sudo tee "$BCONF" >/dev/null <<'EOF'
|
||||
# Installed by socktop-swipe. This is a wall display: it must never blank.
|
||||
Section "ServerFlags"
|
||||
Option "BlankTime" "0"
|
||||
Option "StandbyTime" "0"
|
||||
Option "SuspendTime" "0"
|
||||
Option "OffTime" "0"
|
||||
EndSection
|
||||
EOF
|
||||
echo " wrote $BCONF"
|
||||
# --------------------------------------------------------------------------
|
||||
# Optional monitors
|
||||
# --------------------------------------------------------------------------
|
||||
step "Monitor programs (all optional -- socktop-swipe runs whatever you configure)"
|
||||
if ! have socktop && ask "Install socktop (the point of the project)?" y; then
|
||||
cargo install socktop
|
||||
fi
|
||||
if ! have uptime-kuma-status && ask "Install uptime-kuma-status?" n; then
|
||||
cargo install uptime-kuma-status
|
||||
fi
|
||||
if ! have unifly && ask "Build unifly (UniFi TUI) from source?" n; then
|
||||
U="$HOME/.local/src/unifly"
|
||||
mkdir -p "$(dirname "$U")"
|
||||
if [ -d "$U/.git" ]; then git -C "$U" pull --ff-only; else
|
||||
git clone https://github.com/jasonwitty/unifly "$U"
|
||||
fi
|
||||
( cd "$U" && cargo build --profile release-small -p unifly )
|
||||
say " built $U/target/release-small/unifly"
|
||||
say " put that path under 'binaries: unifly:' in $CONF"
|
||||
fi
|
||||
|
||||
# Apply immediately too, if a session is available.
|
||||
if [ -n "${DISPLAY:-}" ]; then
|
||||
# --------------------------------------------------------------------------
|
||||
# Unattended operation
|
||||
# --------------------------------------------------------------------------
|
||||
step "Wall-display behaviour"
|
||||
|
||||
if ask "Stop the screen blanking, sleeping and locking?" y; then
|
||||
sudo mkdir -p /etc/X11/xorg.conf.d
|
||||
sudo tee "$XBLANK" >/dev/null <<-'EOF'
|
||||
# Installed by socktop-swipe. This is a wall display: it must never blank.
|
||||
Section "ServerFlags"
|
||||
Option "BlankTime" "0"
|
||||
Option "StandbyTime" "0"
|
||||
Option "SuspendTime" "0"
|
||||
Option "OffTime" "0"
|
||||
EndSection
|
||||
EOF
|
||||
say " wrote $XBLANK"
|
||||
# The Xorg snippet covers every session including the greeter and survives
|
||||
# reboots; xset covers a session that re-enables blanking after X starts.
|
||||
# Both are needed, and xset MUST run as the session user -- under sudo it
|
||||
# targets root's X connection and silently does nothing.
|
||||
if [ -n "${DISPLAY:-}" ] && have xset; then
|
||||
xset s off s noblank -dpms 2>/dev/null || true
|
||||
echo " applied to the running session"
|
||||
say " applied to the running session"
|
||||
fi
|
||||
|
||||
i3conf="$HOME/.config/i3/config"
|
||||
if [ -e "$i3conf" ] && ! grep -q "xset s off" "$i3conf"; then
|
||||
bak="$i3conf.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$i3conf" "$bak"
|
||||
printf '\n# Wall display: never blank (socktop-swipe --noblank)\nexec --no-startup-id xset s off s noblank -dpms\n' >>"$i3conf"
|
||||
if i3 -C -c "$i3conf" >/dev/null 2>&1; then
|
||||
echo " added xset to i3 autostart (backup: $bak)"
|
||||
else
|
||||
cp "$bak" "$i3conf"
|
||||
echo "!! i3 rejected the config; restored $bak" >&2
|
||||
fi
|
||||
fi
|
||||
relogin=yes
|
||||
for l in $LOCKER; do
|
||||
warn "$l is installed and may still lock the screen. Disable it in your"
|
||||
warn "session's autostart."
|
||||
done
|
||||
fi
|
||||
|
||||
# --- optional: lightdm autologin --------------------------------------------
|
||||
if [ "$do_autologin" = yes ]; then
|
||||
# A wall display must come back by itself after a power cut. Without this a
|
||||
# reboot leaves the panel at the greeter with nothing autostarted.
|
||||
# SECURITY: anyone with physical access to the screen gets this user's
|
||||
# session. Only use it on a display whose user account is nothing but the
|
||||
# dashboard. Debian's lightdm-autologin PAM stack needs no extra group.
|
||||
if [ ! -d /etc/lightdm ]; then
|
||||
echo "!! /etc/lightdm not found; skipping autologin (not lightdm?)" >&2
|
||||
else
|
||||
session=i3
|
||||
[ -e /usr/share/xsessions/$session.desktop ] ||
|
||||
echo "!! /usr/share/xsessions/$session.desktop missing; check autologin-session in $LCONF" >&2
|
||||
echo "==> enabling lightdm autologin for $(id -un) into '$session'"
|
||||
if [ "$WM" = i3 ] && [ -e "$HOME/.config/i3/config" ]; then
|
||||
if ask "Add the i3 autostart lines?" y; then
|
||||
i3conf="$HOME/.config/i3/config"
|
||||
if grep -q socktop-swipe "$i3conf"; then
|
||||
say " already present; leaving it alone"
|
||||
else
|
||||
bak="$i3conf.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$i3conf" "$bak"
|
||||
{
|
||||
printf '\n# --- socktop-swipe ---\n'
|
||||
printf '# One process: builds the session, opens it in a terminal and reads the panel.\n'
|
||||
printf 'exec --no-startup-id %s/socktop-swipe run\n' "$BIN"
|
||||
printf '# Never blank (the Xorg snippet covers reboots; this covers this session).\n'
|
||||
printf 'exec --no-startup-id xset s off s noblank -dpms\n'
|
||||
if have unclutter; then
|
||||
printf '# With the panel grabbed, nothing ever moves the pointer, so hide it.\n'
|
||||
printf 'exec --no-startup-id unclutter --timeout 1 --ignore-scrolling\n'
|
||||
fi
|
||||
} >>"$i3conf"
|
||||
if i3 -C -c "$i3conf" >/dev/null 2>&1; then
|
||||
say " added the autostart (backup: $bak)"
|
||||
else
|
||||
cp "$bak" "$i3conf"
|
||||
warn "i3 rejected the config; restored $bak"
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
elif [ "$DM" != none ] || [ "$WM" != none ]; then
|
||||
say " no i3 config found. For a systemd user session instead:"
|
||||
say " cp $SRC/packaging/socktop-swipe.service ~/.config/systemd/user/"
|
||||
say " systemctl --user enable --now socktop-swipe"
|
||||
fi
|
||||
|
||||
if [ "$DM" = lightdm ]; then
|
||||
say ""
|
||||
say " Autologin makes the display come back by itself after a power cut."
|
||||
say " ANYONE WITH PHYSICAL ACCESS TO THE PANEL GETS THIS USER'S SESSION."
|
||||
say " Only do this where the account is nothing but the dashboard."
|
||||
if ask "Log $(id -un) straight into $WM at boot?" n; then
|
||||
sudo mkdir -p /etc/lightdm/lightdm.conf.d
|
||||
sudo tee "$LCONF" >/dev/null <<EOF
|
||||
# Installed by socktop-swipe (--autologin). Wall display: come back unattended
|
||||
# after a reboot. Remove this file to restore the login prompt.
|
||||
[Seat:*]
|
||||
autologin-user=$(id -un)
|
||||
autologin-user-timeout=0
|
||||
autologin-session=$session
|
||||
EOF
|
||||
echo " wrote $LCONF (takes effect at next boot)"
|
||||
sudo tee "$LIGHTDM" >/dev/null <<-EOF
|
||||
# Installed by socktop-swipe. Delete this file to restore the login prompt.
|
||||
[Seat:*]
|
||||
autologin-user=$(id -un)
|
||||
autologin-user-timeout=0
|
||||
autologin-session=$WM
|
||||
EOF
|
||||
say " wrote $LIGHTDM (takes effect at next boot)"
|
||||
fi
|
||||
elif [ "$DM" != none ]; then
|
||||
say " Autologin is only automated for lightdm; yours is $DM. Configure it there"
|
||||
say " if the display must come back unattended after a power cut."
|
||||
fi
|
||||
|
||||
# --- optional: i3 autostart -------------------------------------------------
|
||||
if [ "$do_i3" = yes ]; then
|
||||
i3conf="$HOME/.config/i3/config"
|
||||
if ! command -v unclutter >/dev/null 2>&1 && command -v apt-get >/dev/null 2>&1; then
|
||||
echo "==> installing unclutter-xfixes (hides the idle pointer on the wall display)"
|
||||
sudo apt-get install -y unclutter-xfixes || echo "!! unclutter-xfixes not installed; the pointer will stay visible" >&2
|
||||
fi
|
||||
if [ ! -e "$i3conf" ]; then
|
||||
echo "!! $i3conf not found; skipping i3 autostart" >&2
|
||||
elif grep -q "socktop-gestures" "$i3conf"; then
|
||||
echo "==> i3 autostart already present; leaving it alone"
|
||||
else
|
||||
bak="$i3conf.bak-$(date +%Y%m%d-%H%M%S)"
|
||||
cp "$i3conf" "$bak"
|
||||
cat >>"$i3conf" <<EOF
|
||||
|
||||
# --- socktop-swipe --------------------------------------------------------
|
||||
# Exactly ONE gesture daemon: lisgd does not grab the device exclusively, so a
|
||||
# second instance makes every swipe fire twice.
|
||||
exec --no-startup-id $BIN/socktop-gestures
|
||||
# Start the dashboard on the primary output and fullscreen (which also hides
|
||||
# the i3 bar), so no keyboard is needed after a reboot and a second monitor
|
||||
# cannot capture it. --class gives the window a distinct instance name for the
|
||||
# rule; alacritty syntax -- adapt for other terminals.
|
||||
for_window [instance="^socktop-rack\$"] move window to output primary, fullscreen enable
|
||||
exec --no-startup-id \$TERMINAL --class socktop-rack -e $BIN/socktop-rack
|
||||
# Hide the idle pointer: with X ignoring the touch panel nothing ever moves it,
|
||||
# so it would sit at screen centre forever. (apt install unclutter-xfixes)
|
||||
exec --no-startup-id unclutter --timeout 1 --ignore-scrolling
|
||||
EOF
|
||||
if i3 -C -c "$i3conf" >/dev/null 2>&1; then
|
||||
echo "==> added i3 autostart (backup: $bak)"
|
||||
echo " NOTE: set \$TERMINAL in your i3 config, or edit those lines"
|
||||
echo " to your terminal's full path."
|
||||
else
|
||||
cp "$bak" "$i3conf"
|
||||
echo "!! i3 rejected the config; restored $bak" >&2
|
||||
i3 -C -c "$i3conf" || true
|
||||
fi
|
||||
fi
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "=============================================================="
|
||||
echo "Installed."
|
||||
if [ "$relogin" = yes ]; then
|
||||
echo "LOG OUT and back in before use (group and/or X changes)."
|
||||
fi
|
||||
# --------------------------------------------------------------------------
|
||||
say ""
|
||||
say "=============================================================="
|
||||
say "Installed."
|
||||
if [ "$RELOGIN" = yes ]; then say "LOG OUT AND BACK IN before use (group membership changed)."; fi
|
||||
cat <<EOF
|
||||
|
||||
Start it by hand with:
|
||||
$BIN/socktop-gestures & # exactly one instance
|
||||
$BIN/socktop-rack # builds and attaches the session
|
||||
Next:
|
||||
1. Edit $CONF -- the screens are the author's, not yours.
|
||||
2. socktop-swipe validate check it, and see the grid map
|
||||
3. socktop-swipe doctor swipe, and see what the panel reports
|
||||
4. socktop-swipe run build the session and start swiping
|
||||
|
||||
Configuration: $CONF
|
||||
Diagnostics: tools/diag.sh
|
||||
EOF
|
||||
echo "=============================================================="
|
||||
say "=============================================================="
|
||||
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
# Design notes
|
||||
|
||||
Why the thing is built the way it is. The README says what to do; this says why,
|
||||
so neither has to carry both jobs.
|
||||
|
||||
## The carousel is tmux pane zoom, not extra processes
|
||||
|
||||
There is exactly one monitor process per host. Zooming calls
|
||||
`tmux resize-pane -Z`, which makes one pane fill the window and sends `SIGWINCH`
|
||||
so the program repaints at the new size.
|
||||
|
||||
Three reasons that matters:
|
||||
|
||||
- **Half the processes.** Separate full-screen instances would mean N extra
|
||||
processes and double the polling load on the monitored hosts. On a 1.9 GB
|
||||
display host that is the difference between comfortable and not.
|
||||
- **No reconnect delay.** Hidden panes keep running and stay connected, so
|
||||
swiping back shows current data, not a stale snapshot with a spinner.
|
||||
- **Constant load.** The monitored hosts see the same connections regardless of
|
||||
what is on screen.
|
||||
|
||||
## Why the grid is sparse ordinals
|
||||
|
||||
The obvious design is that a coordinate names a physical slot. It falls apart on
|
||||
the first socktop group: a group of four hosts is five screens, so putting
|
||||
anything to its right means writing `0x5`, and adding a fifth host means
|
||||
renumbering the rest of the row.
|
||||
|
||||
Making coordinates pure ordering removes that entirely. Only the sort order
|
||||
matters, so `0x1` and `0x5` are the same thing, and a group grows without
|
||||
disturbing its neighbours. The cost is that the file is not a literal map of the
|
||||
screen — which is what `socktop-swipe validate` is for.
|
||||
|
||||
## Why return memory beats spatial snapping
|
||||
|
||||
Vertical movement had two plausible rules and they disagree. Purely spatial: from
|
||||
`0x1`, up to `-1x0`, down again lands on `0x0`. Return memory: it lands back on
|
||||
`0x1`.
|
||||
|
||||
Return memory won because the thing you actually do with a wall display is glance
|
||||
away and glance back. Losing your place on every glance is the worse failure, and
|
||||
snapping is only ever needed to decide the *first* entry into a row. A layout
|
||||
where every row has a cell in the same column never snaps at all.
|
||||
|
||||
## Why evdev instead of lisgd
|
||||
|
||||
libinput deliberately emits gesture events only for touchpads, never for
|
||||
touchscreens, so `libinput-gestures` and everything built on it cannot work here
|
||||
at all. Something has to read raw touch events. v1 used
|
||||
[lisgd](https://git.sr.ht/~mil/lisgd); v2 does it in-process.
|
||||
|
||||
What that bought:
|
||||
|
||||
- **No C toolchain in the install path.** No `libinput-dev`, no `libX11-dev`, no
|
||||
`git clone` and `make`. On the Wyse's 8 GB of eMMC that is not a small thing.
|
||||
- **`grab: true` replaces the X ignore rule.** `EVIOCGRAB` takes the device
|
||||
exclusively, so X never sees the touches — which is what v1's
|
||||
`Option "Ignore"` InputClass was faking, except this needs no X restart and no
|
||||
logout. The X rule is still documented in the README as a fallback for anyone
|
||||
who wants touch to reach other applications.
|
||||
- **One process, so the double-instance bug cannot happen.** lisgd does not grab
|
||||
the device, so two copies made every swipe fire twice and the carousel appeared
|
||||
to skip. Now the second copy fails to grab and says so.
|
||||
- **The contact-count workaround became honest.** Instead of binding three
|
||||
separate lisgd gestures per direction, the peak contact count is a field on the
|
||||
detected swipe, and `doctor` prints it in English.
|
||||
|
||||
Averaging rather than summing the contacts' travel matters here: a panel
|
||||
reporting one physical finger as three contacts must not look like three times
|
||||
the displacement. There is a test for exactly that.
|
||||
|
||||
## Why panes are addressed by id, and kept alive
|
||||
|
||||
Pane *indices* renumber when a pane dies. Pane *ids* (`%12`) do not, so every
|
||||
lookup uses them.
|
||||
|
||||
That leaves the question of what happens when a monitor exits — a typo in a
|
||||
`generic` command, a socktop that cannot reach its agent. tmux destroys a window
|
||||
when its last pane goes, which during construction breaks the next
|
||||
`split-window` with a baffling "no current target", and afterwards silently
|
||||
reshuffles the display.
|
||||
|
||||
v1 used `remain-on-exit`, which cannot actually 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. v2 wraps each command instead:
|
||||
|
||||
```sh
|
||||
<command>; s=$?; printf '\n[%s exited: status %s]\n' <name> "$s"; while :; do sleep 86400; done
|
||||
```
|
||||
|
||||
The pane outlives the command, and the failure is visible *on the wall display*
|
||||
with its exit status — which is what a wall display is for. tmux already runs
|
||||
each command under `sh`, so this costs one shell that stays resident per pane
|
||||
rather than one that execs away.
|
||||
|
||||
## Why `at: "0x0"` must be quoted
|
||||
|
||||
YAML reads an unquoted `0x0` as the hexadecimal number 0. The nasty part is that
|
||||
`1x0` is *not* valid hex and arrives as a string, so only the row-0 entries break
|
||||
and the failure looks arbitrary. Deserialization catches the integer case and
|
||||
prints the fix rather than a type error.
|
||||
|
||||
## Multiplexer: tmux, with zellij shelved
|
||||
|
||||
`src/session/` is a `Multiplexer` trait with a tmux implementation behind it, so
|
||||
the question is cheap to reopen. It was shelved rather than rejected, for reasons
|
||||
worth recording so it is not re-litigated:
|
||||
|
||||
1. **"Available as a crate" is not an embedding API.** `zellij-server`,
|
||||
`zellij-client` and `zellij-utils` are published, but they are workspace
|
||||
crates for the binary, not a supported library surface. Realistic integration
|
||||
is the CLI or a WASM plugin — so it would still be a subprocess driven over a
|
||||
CLI, exactly like tmux.
|
||||
2. **Addressability is what we depend on.** The grid needs *"focus cell 0x1,
|
||||
sub-screen 3, zoomed"* as one deterministic call. tmux gives that directly
|
||||
(`select-pane -t %12`, `resize-pane -Z`). zellij's CLI is direction-oriented
|
||||
(`move-focus left`), which would mean counting relative moves and tracking
|
||||
state we cannot verify.
|
||||
3. **Footprint runs the wrong way.** zellij is a client/server async multiplexer
|
||||
with a wasmtime plugin runtime, heavier at idle than tmux's C implementation.
|
||||
On 2 GB boxes that is the binding constraint, and `apt install tmux` versus a
|
||||
zellij source build on an Atom is a much worse story for the install guide.
|
||||
|
||||
The one idea worth keeping on the shelf: running the navigation state machine as
|
||||
a **WASM plugin inside zellij** via `zellij-tile`, which would give real event
|
||||
subscriptions instead of driving a CLI. Revisit only if tmux becomes the
|
||||
bottleneck.
|
||||
@@ -0,0 +1,121 @@
|
||||
# Hardware notes
|
||||
|
||||
Things about specific hardware that cost time to work out. Kept out of the README
|
||||
because they are not install steps.
|
||||
|
||||
## Screen size and rack size are different measurements
|
||||
|
||||
Three numbers get confused because they all describe "how big":
|
||||
|
||||
- **9 inch** — the GeeekPi touchscreen itself, 1280x720.
|
||||
- **10 inch** — the *mini-rack* standard its bracket is made for.
|
||||
- **19 inch** — the standard equipment rack, which is what `cad/` adapts it to.
|
||||
|
||||
So "a 9-inch screen on a 10-inch mount, adapted to a 19-inch rack" is three
|
||||
correct numbers, not a contradiction. The README uses the screen size when
|
||||
talking about the panel and the rack size when talking about the adapter.
|
||||
|
||||
## The ILITEK panel reports 2-3 contacts for one finger
|
||||
|
||||
A physically one-finger swipe arrives as two, sometimes three, simultaneous
|
||||
contacts. In v1 this was the single most expensive failure: lisgd detected the
|
||||
direction correctly every time and then rejected it on
|
||||
`Cfg(f=1) <=> Evt(f=2)`, which reads as noise unless you know what it means.
|
||||
|
||||
`touch.fingers: [1, 2, 3]` accepts all three. `socktop-swipe doctor` now reports
|
||||
it in English, and the travel is *averaged* across contacts rather than summed —
|
||||
otherwise a ghost-contact panel looks like it swiped three times as far.
|
||||
|
||||
## Phantom display outputs (LattePanda DSI-1)
|
||||
|
||||
The LattePanda's onboard DSI header shows up as `DSI-1 connected 1024x600` with
|
||||
**zero EDID bytes** (`xrandr` reports `0mm x 0mm`), and X happily puts workspace 1
|
||||
on it. Everything then looks healthy over ssh — the window is fullscreen on the
|
||||
primary output — while the real panel shows an empty workspace.
|
||||
|
||||
```sh
|
||||
xrandr | grep ' connected' # a real panel has physical mm; phantoms say 0mm x 0mm
|
||||
for c in /sys/class/drm/card0-*; do echo "$c $(cat "$c"/status) edid=$(wc -c <"$c"/edid)"; done
|
||||
```
|
||||
|
||||
Fix, adjusting the names:
|
||||
|
||||
```sh
|
||||
sudo tee /etc/X11/xorg.conf.d/20-outputs-socktop-swipe.conf >/dev/null <<'EOF'
|
||||
Section "Monitor"
|
||||
Identifier "DSI-1"
|
||||
Option "Ignore" "true"
|
||||
EndSection
|
||||
Section "Monitor"
|
||||
Identifier "HDMI-2"
|
||||
Option "Primary" "true"
|
||||
EndSection
|
||||
EOF
|
||||
```
|
||||
|
||||
Then set `touch.width`/`touch.height` to the real panel's mode. On the LattePanda
|
||||
that is **1280x720**, not the 1024x600 the phantom claims.
|
||||
|
||||
## `sudo xset` silently does nothing
|
||||
|
||||
`xset` talks to the X connection of the user running it, so `sudo xset s off`
|
||||
targets *root's* X connection and succeeds while changing nothing for your
|
||||
session. A `sudo bash noblank.sh` wrapper looks like it worked and blanking stays
|
||||
armed. It must run as the session user.
|
||||
|
||||
Also: `xset` is per-session and dies on reboot. The durable fix is the Xorg
|
||||
`ServerFlags` snippet with all four timeouts at 0 — which also covers the display
|
||||
manager's greeter — *plus* an `xset` line in the session autostart, because a
|
||||
session can re-enable the screensaver after X starts. Both, not either.
|
||||
|
||||
Check the real state:
|
||||
|
||||
```sh
|
||||
xset q | grep -A1 -E 'Screen Saver|DPMS' # want timeout 0, prefer blanking no, DPMS Disabled
|
||||
```
|
||||
|
||||
## Panel resolution is not the X screen size
|
||||
|
||||
With a second display attached, X reports the combined root window (e.g.
|
||||
2304x720). `touch.width`/`touch.height` must describe the touch panel alone or
|
||||
the edge and distance maths is scaled to the wrong thing. v1 hit this because
|
||||
lisgd asks X when not told; v2 always requires the values in the config.
|
||||
|
||||
## LattePanda specifics
|
||||
|
||||
Atom x5-Z8350 @ 1.44 GHz, 1.9 GB RAM, Cherry Trail Gen8 graphics, Debian 11,
|
||||
i3 on X11. Binaries in `~/.cargo/bin`, which is **not** on the PATH that i3 or a
|
||||
non-login ssh gives you — hence the `binaries:` block in the config.
|
||||
|
||||
Memory is the binding constraint. Prefer the lightweight option and count
|
||||
processes; reach for tmux pane zoom rather than duplicate program instances. A
|
||||
Wayland compositor with native touch gestures is not an answer here: Hyprland is
|
||||
not packaged in Debian, so it means a source build on a 1.44 GHz Atom, and it
|
||||
would not help anyway — libinput emits no gesture events for touchscreens on any
|
||||
version, so a raw-touch reader is required regardless.
|
||||
|
||||
## Wyse 3040
|
||||
|
||||
Same silicon as the LattePanda (Atom x5-Z8350, Cherry Trail), so the stack is
|
||||
already validated. Differences: 8/16 GB soldered eMMC and no M.2, so a minimal
|
||||
Debian netinst and a careful eye on disk; DisplayPort out, so a DP-to-HDMI
|
||||
adapter for the panel; roughly 3-4 W idle, 101x101x28 mm, VESA holes.
|
||||
|
||||
The eMMC is why the installer checks free space before starting a build: a Rust
|
||||
toolchain plus a target directory is around 1.8 GB.
|
||||
|
||||
## Never log out of a keyboard-less wall display
|
||||
|
||||
lightdm autologin fires when a **seat starts**, not after a logout. `i3-msg exit`
|
||||
therefore drops the display to the greeter and leaves it there — and the rack
|
||||
panel has no keyboard, so nobody can log back in at the machine. Recovering it
|
||||
needs `sudo systemctl restart lightdm` or a reboot, and the LattePanda has no
|
||||
passwordless sudo.
|
||||
|
||||
So: **`i3-msg exit` does not test the autostart, it only strands the box.** The
|
||||
only honest test of the boot path is an actual reboot.
|
||||
|
||||
Related trap when handing over a root command: `ssh host 'sudo …'` allocates no
|
||||
TTY, so sudo has nowhere to prompt and fails. Use `ssh -t`. Chaining with `&&`
|
||||
then swallows the rest of the line, which is how a recovery command silently did
|
||||
nothing at all. Use `;` for recovery steps that must run regardless.
|
||||
@@ -0,0 +1,480 @@
|
||||
# socktop-swipe v2 — release plan
|
||||
|
||||
Status: **implemented and in service**, 2026-09-09. Milestones 1-7 done on branch
|
||||
`v2-rust` (PR #1); the LattePanda rack display has been running v2 since, and
|
||||
passed a physical swipe test and a cold boot. Milestone 8, the Wyse 3040 install
|
||||
following only the README, is the remaining acceptance gate.
|
||||
|
||||
Where the built thing differs from this plan, `notes/DESIGN.md` is authoritative.
|
||||
|
||||
v1 is three POSIX shell scripts driving lisgd and tmux. It works and is running on
|
||||
the rack display today, but it hard-codes a single linear carousel of socktop hosts,
|
||||
and every new kind of screen is a special case (`SOCKTOP_AUX_CMD` is the aux screen
|
||||
because there was nowhere else to put it). v2 makes the layout a first-class,
|
||||
user-authored thing and rewrites the logic in Rust.
|
||||
|
||||
---
|
||||
|
||||
## 1. Decisions locked in
|
||||
|
||||
| Question | Decision |
|
||||
| --- | --- |
|
||||
| Rust scope | One Rust binary owns config, the grid model, gesture input (evdev, replacing lisgd) and drives tmux. **tmux stays the pane engine**; zellij shelved (§4.3) |
|
||||
| Grid coordinates | Sparse ordinals — they define *order and relative position*, not physical slots |
|
||||
| socktop group | **One cell**, regardless of host count; expands into a sub-sequence internally |
|
||||
| Payload TUIs | Installer *offers* socktop / uptime-kuma-status / unifly, never requires them |
|
||||
| Distribution | **Source only for now.** Clone from Gitea, `cargo build --release`. No prebuilt binaries, no apt repo yet — revisit after feeling the pain on the Wyse |
|
||||
| v1 config | **Clean break.** No `.env` reader, no `migrate` command. Rack display's YAML gets hand-written once |
|
||||
| Canonical repo | `gt.wittyoneoff.com/jason/socktop-swipe` |
|
||||
|
||||
---
|
||||
|
||||
## 2. The grid model
|
||||
|
||||
### 2.1 Coordinates
|
||||
|
||||
Every screen declares `at: "<row>x<col>"`.
|
||||
|
||||
- **row** increases *downward*: `-1x0` is above `0x0`, `1x0` is below it.
|
||||
- **col** increases *rightward*: `0x1` is right of `0x0`.
|
||||
- `0x0` is where you start.
|
||||
|
||||
Coordinates are **sparse ordinals**. Only the ordering matters:
|
||||
|
||||
- Rows sort ascending. A vertical swipe moves to the next *defined* row in that
|
||||
direction, not literally `row ± 1`. Rows `-1, 0, 1` and rows `-7, 0, 42` behave
|
||||
identically.
|
||||
- Within a row, cells sort by column ascending. A horizontal swipe moves to the next
|
||||
*defined* cell. `0x1` and `0x5` are interchangeable as long as the ordering is right.
|
||||
|
||||
This is the "ezpz for users" property: you never have to count how many screens a
|
||||
socktop group will produce in order to place the thing next to it.
|
||||
|
||||
### 2.2 Horizontal movement
|
||||
|
||||
A socktop cell with four hosts is one cell containing a sub-sequence of five screens:
|
||||
|
||||
```
|
||||
cell 0x0 (socktop, 4 hosts) cell 0x1 (socktop, 2 hosts)
|
||||
┌───────────────────────────────────┐ ┌──────────────────────┐
|
||||
│ tiled → host1 → host2 → host3 → h4│→ │ tiled → host1 → host2│
|
||||
└───────────────────────────────────┘ └──────────────────────┘
|
||||
```
|
||||
|
||||
Swiping forward walks the sub-sequence, and only leaves the cell after its last
|
||||
sub-screen. Non-socktop cells have a sub-sequence of length 1, so they are entered
|
||||
and left in a single swipe.
|
||||
|
||||
**Entry direction sets the landing sub-screen:**
|
||||
|
||||
- entering a cell from the left → its **first** sub-screen (the tiled overview)
|
||||
- entering a cell from the right → its **last** sub-screen
|
||||
|
||||
So swiping back left from `0x1` lands you on `0x0`'s last host, not its overview.
|
||||
That is what v1 does today and it is what makes the carousel feel continuous.
|
||||
|
||||
No wrap-around at either end of a row — same reasoning as v1: on a wall display,
|
||||
wrapping makes it impossible to tell where you are.
|
||||
|
||||
### 2.3 Vertical movement
|
||||
|
||||
1. Find the next defined row in that direction. If there is none, do nothing.
|
||||
2. **If you have been in that row before, go back to exactly where you were** — same
|
||||
cell, same sub-screen. No calculation.
|
||||
3. Only if the row has never been visited: go to the cell at the same column if one
|
||||
exists, otherwise snap to the **nearest defined column** in that row. (Ties break
|
||||
toward the lower column — see open question Q1.)
|
||||
|
||||
Return memory always wins over snapping. Each row remembers its last position, and only
|
||||
a *horizontal* move within that row updates it. This is what makes "swipe up to unifly,
|
||||
swipe down, you're back on the same Pi" work — and it holds for wider grids too: from
|
||||
`0x1`, up to `-1x0`, then down returns you to `0x1`, not `0x0`.
|
||||
|
||||
Snapping therefore only runs on the first entry into a row, and a layout where every
|
||||
row has a cell in the same column never runs it at all.
|
||||
|
||||
### 2.4 The default layout, walked through
|
||||
|
||||
```
|
||||
┌──────────────┐
|
||||
row -1 │ unifly │ -1x0
|
||||
└──────────────┘
|
||||
↕
|
||||
┌───────────────────────────────────┐ ┌──────────────────────┐
|
||||
row 0 │ 4 Pis: tiled → each Pi zoomed │→ │ orangepi + trixie │
|
||||
└───────────────────────────────────┘ └──────────────────────┘
|
||||
0x0 0x1
|
||||
↕
|
||||
┌──────────────┐
|
||||
row 1 │ uptime-kuma │ 1x0
|
||||
└──────────────┘
|
||||
```
|
||||
|
||||
- Swipe **up** from any of the five `0x0` screens → unifly.
|
||||
- Left/right on unifly → nothing; it is the only cell in row -1.
|
||||
- Swipe **down** from unifly → back into `0x0`, on the exact Pi you left.
|
||||
- Swipe **down** again → uptime-kuma-status.
|
||||
|
||||
From `0x1` (orangepi + trixie) the same holds: up snaps to `-1x0` the first time, and
|
||||
down brings you back to `0x1` on the host you left.
|
||||
|
||||
---
|
||||
|
||||
## 3. Configuration
|
||||
|
||||
### 3.1 Format and location
|
||||
|
||||
YAML. Resolution order:
|
||||
|
||||
1. `--config <path>`
|
||||
2. `$XDG_CONFIG_HOME/socktop-swipe/config.yaml` (i.e. `~/.config/socktop-swipe/config.yaml`)
|
||||
3. `/etc/socktop-swipe/config.yaml`
|
||||
|
||||
Per-user first, because the display box runs as one dedicated user and that keeps the
|
||||
file editable without sudo. `/etc` stays supported so a future `.deb` has somewhere
|
||||
sensible to drop a default.
|
||||
|
||||
`socktop-swipe validate` parses the file, resolves the grid, checks every referenced
|
||||
binary exists, and prints the resulting map. It is the thing to run after every edit,
|
||||
and the README will say so.
|
||||
|
||||
### 3.2 Schema
|
||||
|
||||
```yaml
|
||||
# ── how the session is built ────────────────────────────────────────────────
|
||||
session: socktop-swipe # tmux session name
|
||||
terminal: alacritty # used only by the autostart the installer writes
|
||||
|
||||
# Full paths solve the "i3 has no ~/.cargo/bin on PATH" problem in one place.
|
||||
# ~ is expanded. Anything already on PATH can be given as a bare name.
|
||||
binaries:
|
||||
socktop: ~/.cargo/bin/socktop
|
||||
uptime-kuma-status: ~/.cargo/bin/uptime-kuma-status
|
||||
unifly: ~/Documents/GitHub/unifly/target/release-small/unifly
|
||||
|
||||
# ── touch panel ─────────────────────────────────────────────────────────────
|
||||
touch:
|
||||
device: /dev/input/by-id/usb-ILITEK_ILITEK-TOUCH-event-if00
|
||||
width: 1280 # the PANEL's resolution, not the X screen
|
||||
height: 720
|
||||
grab: true # exclusive grab; see §5
|
||||
threshold: 80 # px of travel before a drag counts as a swipe
|
||||
leniency: 30 # degrees off-axis tolerated
|
||||
fingers: [1, 2, 3] # contact counts accepted
|
||||
|
||||
gestures:
|
||||
forward: RL # right-to-left finger motion moves forward
|
||||
back: LR
|
||||
up: DU
|
||||
down: UD
|
||||
|
||||
# ── the grid ────────────────────────────────────────────────────────────────
|
||||
screens:
|
||||
- at: -1x0
|
||||
type: unifly
|
||||
|
||||
- at: 0x0
|
||||
type: socktop
|
||||
socktop_group: [rpi-master, rpi-worker-1, rpi-worker-2, rpi-worker-3]
|
||||
layout: tiled
|
||||
|
||||
- at: 0x1
|
||||
type: socktop
|
||||
socktop_group: "orangepi, trixie" # CSV string also accepted
|
||||
layout: even-vertical
|
||||
|
||||
- at: 1x0
|
||||
type: uptime-kuma-status
|
||||
url: https://status.wittyoneoff.com/status/wittyoneoff
|
||||
```
|
||||
|
||||
### 3.3 Monitor types
|
||||
|
||||
Each type knows how to turn its parameters into one or more tmux panes. Every type
|
||||
also accepts `command:` to override the generated command outright, and `title:` to
|
||||
override the pane border label — so nobody is ever blocked by a type not yet
|
||||
supporting a parameter they need.
|
||||
|
||||
| Type | Parameters | Panes | Generated command |
|
||||
| --- | --- | --- | --- |
|
||||
| `socktop` | `socktop_group` (list or CSV, **required**), `layout` (default `tiled`) | one per profile, plus the tiled overview as sub-screen 0 | `<socktop> -P <profile>` |
|
||||
| `uptime-kuma-status` | `url` | 1 | `<uptime-kuma-status> <url>` |
|
||||
| `unifly` | *(none yet)* — `site`/`controller` carved out for later | 1 | `<unifly> tui` |
|
||||
| `generic` | `command` (**required**), `title` | 1 | as given |
|
||||
|
||||
`layout` accepts the tmux layout names (`tiled`, `even-horizontal`, `even-vertical`,
|
||||
`main-horizontal`, `main-vertical`).
|
||||
|
||||
Only `socktop` produces a multi-screen sub-sequence today. The internal representation
|
||||
is a plain `Vec<Pane>` per cell plus an optional overview, so if a future type wants
|
||||
the same treatment it is a data change, not a redesign.
|
||||
|
||||
---
|
||||
|
||||
## 4. Architecture
|
||||
|
||||
One binary, one process at runtime.
|
||||
|
||||
```
|
||||
src/
|
||||
main.rs CLI dispatch
|
||||
config/
|
||||
mod.rs serde types, defaults, path resolution
|
||||
coord.rs Coord parsing ("0x-1"), Grid construction, neighbour lookup
|
||||
grid.rs navigation state machine — pure, no I/O, unit tested
|
||||
monitor.rs MonitorType → panes + command lines
|
||||
input.rs evdev reader → Swipe { direction, fingers }
|
||||
session/mod.rs Multiplexer trait — the ONLY module that knows tmux vs zellij (§4.3)
|
||||
session/tmux.rs build the session, select window/pane, zoom
|
||||
doctor.rs diagnostics (replaces tools/diag.sh and tools/find-device.sh)
|
||||
ui.rs optional on-screen position indicator (§8, cuttable)
|
||||
```
|
||||
|
||||
### 4.1 Why this is better than v1 even before the grid
|
||||
|
||||
v1 runs lisgd as a daemon which shells out to `socktop-swipe next` per gesture. That
|
||||
script has no memory, so on every single swipe it re-derives "where am I" by
|
||||
interrogating tmux (`list-windows`, `display-message`, `window_zoomed_flag`). v2 holds
|
||||
the cursor in memory: one gesture becomes two or three tmux calls instead of six, and
|
||||
the navigation logic becomes a pure function over a struct that can be unit tested
|
||||
without a display, a touch panel or a tmux server. That test suite is the main reason
|
||||
the grid model is worth attempting at all.
|
||||
|
||||
### 4.2 CLI
|
||||
|
||||
| Command | What it does |
|
||||
| --- | --- |
|
||||
| `socktop-swipe run` | Build the tmux session, spawn the terminal attached to it, run the gesture loop, exit when the session exits. **The single autostart line.** |
|
||||
| `socktop-swipe daemon` | Gesture loop only, against an existing session (for a systemd-user split) |
|
||||
| `socktop-swipe attach` | Build and attach the session only |
|
||||
| `socktop-swipe validate` | Parse config, resolve the grid, check binaries, print the map |
|
||||
| `socktop-swipe doctor` | Interactive touch diagnostics; list candidate devices; report what the panel actually sends |
|
||||
| `socktop-swipe next\|back\|up\|down` | Drive a running instance over its control socket — for keybindings and testing without a panel |
|
||||
|
||||
`run` as one process also kills the v1 double-instance footgun by construction: the
|
||||
second one fails to grab the device and exits with a clear message.
|
||||
|
||||
### 4.3 Multiplexer: tmux, with zellij shelved
|
||||
|
||||
**tmux stays.** `session/` is a `Multiplexer` trait with a tmux implementation behind
|
||||
it, so the question can be reopened cheaply, but it is not being evaluated for v2.
|
||||
|
||||
Shelved rather than rejected. The reasoning, recorded in `notes/DESIGN.md` so it is not
|
||||
re-litigated: "available as a crate" does not buy in-process integration —
|
||||
`zellij-server`/`zellij-utils` are workspace crates for the binary, not a library
|
||||
surface, so it would still be a subprocess driven over a CLI exactly like tmux. The
|
||||
blocking issue is addressing: the grid needs *"focus cell 0x1, sub-screen 3, zoomed"* as
|
||||
one deterministic call, which tmux gives directly as `select-pane -t session:window.3`
|
||||
plus `resize-pane -Z`, whereas zellij's CLI is direction-oriented (`move-focus left`).
|
||||
Footprint also runs the wrong way on 2 GB boxes, and a zellij source build on an Atom is
|
||||
a much worse story for the install guide than `apt install tmux`.
|
||||
|
||||
The one idea worth keeping on the shelf: running the navigation state machine as a
|
||||
**WASM plugin inside zellij** via `zellij-tile`, which would give real event
|
||||
subscriptions instead of CLI polling. Revisit only if tmux becomes the bottleneck.
|
||||
|
||||
---
|
||||
|
||||
## 5. Input: evdev instead of lisgd
|
||||
|
||||
`input.rs` opens the event device and reads multitouch **protocol B** (`ABS_MT_SLOT`,
|
||||
`ABS_MT_TRACKING_ID`, `ABS_MT_POSITION_X/Y`), tracking per-slot start and end points.
|
||||
A swipe fires when the dominant axis exceeds `threshold`, the off-axis angle is within
|
||||
`leniency`, and the peak simultaneous contact count is in `fingers`.
|
||||
|
||||
What this buys:
|
||||
|
||||
- **Drops the C toolchain from the install path.** No `libinput-dev`, no `libX11-dev`,
|
||||
no `git clone git.sr.ht/~mil/lisgd`, no `make`. On the Wyse's eMMC that is real.
|
||||
- **`grab: true` replaces `--xignore`.** `EVIOCGRAB` takes the device exclusively, so X
|
||||
never sees the touches at all — which is what the `Option "Ignore"` InputClass was
|
||||
faking. That removes an `/etc/X11/xorg.conf.d` file *and* the logout/login step it
|
||||
required. The X rule stays documented in `notes/` as a fallback for anyone who wants
|
||||
touch to reach X for other apps.
|
||||
- **Compositor-agnostic for free.** Reading `/dev/input` directly means nothing in the
|
||||
gesture path is X11-specific. This is not a Wayland port — the terminal and tmux
|
||||
still are what they are — but it removes one of the reasons v1 could not be one.
|
||||
- **The `fingers: [1,2,3]` workaround becomes honest.** Instead of binding three
|
||||
separate lisgd gestures per direction, the peak contact count is just a field on the
|
||||
detected swipe, and `doctor` can print it.
|
||||
|
||||
Still required: read access to the device. Installer offers the `input` group (needs a
|
||||
relogin) or a udev rule granting the display user access to that one device (no
|
||||
relogin). Prefer the udev rule and say why.
|
||||
|
||||
---
|
||||
|
||||
## 6. Installer
|
||||
|
||||
`install.sh` stays POSIX sh — it has to run before any Rust exists.
|
||||
|
||||
```sh
|
||||
curl -fsSL https://gt.wittyoneoff.com/jason/socktop-swipe/raw/branch/main/install.sh | sh
|
||||
```
|
||||
|
||||
Piping to `sh` leaves no stdin for prompts, so the script reads answers from
|
||||
`/dev/tty` explicitly. If there is no tty it falls back to defaults and says so.
|
||||
`--yes` takes every default non-interactively; `--no-<thing>` declines individually.
|
||||
|
||||
### 6.1 Dependency preflight
|
||||
|
||||
Every dependency is **checked before anything is installed**, and the whole report is
|
||||
printed at once so you can see the full cost up front rather than discovering it one
|
||||
prompt at a time. Each missing item comes with a concrete offered fix; anything with no
|
||||
known fix on the detected distro is reported as such rather than silently skipped, with
|
||||
the manual step spelled out.
|
||||
|
||||
| Checked | How | If missing |
|
||||
| --- | --- | --- |
|
||||
| distro + package manager | `/etc/os-release`, which of `apt-get`/`dnf`/`pacman`/`zypper` | unsupported → print manual package list and continue in degraded mode |
|
||||
| `tmux` (or `zellij`, per §4.3) | `command -v` + version | offer the distro package |
|
||||
| terminal emulator | probe `alacritty`, `foot`, `kitty`, `xterm` | offer to install one; explain that it must be GPU/GL-capable-ish and legible at panel size |
|
||||
| `cargo` / rustup | `command -v cargo` | offer rustup, stating the disk cost (~1.2 GB toolchain + target dir) before asking |
|
||||
| C toolchain | only if something still needs building | offer build-essential equivalent |
|
||||
| window manager | probe i3, detect a running WM | needed only for the `--i3`-equivalent autostart; skip that step cleanly if absent |
|
||||
| display manager | `/etc/lightdm`, `/etc/gdm3`, `/etc/sddm.conf` | autologin is only offered for the one detected; unknown DM → print the manual snippet |
|
||||
| screen locker | probe `xss-lock`, `light-locker`, `i3lock`, `xscreensaver` | offer to disable, naming the specific unit or process found |
|
||||
| touch device | enumerate `/dev/input/by-id/*event*` with touchscreen capabilities | none found → point at `socktop-swipe doctor` and stop before writing a broken config |
|
||||
| device read access | try opening the chosen device | offer udev rule (default) or `input` group |
|
||||
| `git` | `command -v` | needed only for the unifly source build |
|
||||
| disk space | `df` on `/usr/local`, `$HOME`, and the cargo target location | warn *before* starting a build that will not fit — this is the Wyse's 8 GB eMMC case specifically |
|
||||
|
||||
The report ends with a single summary — *"3 things will be installed, 2 files changed,
|
||||
~1.4 GB of disk used. Continue? [y/N]"* — and nothing before that point has modified
|
||||
the system.
|
||||
|
||||
### 6.2 Flow
|
||||
|
||||
1. **Preflight** (§6.1). Print the full report, get one confirmation.
|
||||
2. **Toolchain** — if no cargo: *"Rust is needed to build from source. Install rustup? [Y/n]"*
|
||||
3. **Build and install** socktop-swipe from source to `/usr/local/bin`.
|
||||
4. **Touch panel** — enumerate `/dev/input/by-id/*` touch devices, show them, ask which,
|
||||
write it into the config. Offer a live confirm ("swipe now") before committing.
|
||||
5. **Optional payload TUIs**, each a separate prompt, all skippable:
|
||||
- `socktop` — `cargo install socktop`
|
||||
- `uptime-kuma-status` — `cargo install uptime-kuma-status`
|
||||
- `unifly` — clone `github.com/jasonwitty/unifly`, `cargo build --profile release-small -p unifly`, record the resulting path in `binaries:`
|
||||
6. **Device access** — udev rule (default) or `input` group.
|
||||
7. **Autostart?** — i3 / systemd user unit / none.
|
||||
8. **Autologin?** — lightdm drop-in, with the physical-access warning stated at the
|
||||
prompt, not buried in the README.
|
||||
9. **Never blank, never sleep, never lock?** — Xorg `ServerFlags` snippet + `xset` in
|
||||
the autostart + disable any detected screen locker. (Keeping v1's hard-won note that
|
||||
`sudo xset` silently targets root's X connection and does nothing.)
|
||||
10. **Write a starter config** if none exists — one socktop group with a placeholder
|
||||
profile, commented, and a pointer to `socktop-swipe validate`.
|
||||
11. **Print exactly what changed**, file by file, and what to do next.
|
||||
|
||||
`uninstall.sh` reverses each of the above and, as in v1, prints what it deliberately
|
||||
left alone.
|
||||
|
||||
Cheap future-proofing: add `[package.metadata.deb]` to `Cargo.toml` now (paths
|
||||
`/usr/bin`, `/etc/socktop-swipe/`, `/lib/udev/rules.d/`). It costs about fifteen lines
|
||||
and means the apt-repo follow-up is `cargo deb` rather than a packaging project.
|
||||
|
||||
---
|
||||
|
||||
## 7. README rewrite
|
||||
|
||||
Written for someone who has just found the repo and does not know what socktop is.
|
||||
Current README leads with mechanism ("the carousel is tmux pane zoom, not extra
|
||||
instances") — correct, and it moves to `notes/DESIGN.md`.
|
||||
|
||||
New order:
|
||||
|
||||
1. **What this is** — one paragraph, then the rack photo and the swipe video.
|
||||
2. **What you're looking at** — annotated: the panel, the 19" adapter, what is on each
|
||||
screen and why.
|
||||
3. **Hardware** — tested table (LattePanda, Wyse 3040), the GeeekPi 9" panel, and a link
|
||||
across to [`cad/`](../cad) for the adapter that mounts it in a 19" rack.
|
||||
4. **Quick start** — the curl one-liner and what it will ask you.
|
||||
5. **Your first config** — build it up: one socktop group → add unifly above → add
|
||||
uptime-kuma above/below. Each step shows the YAML *and* the resulting map.
|
||||
6. **The grid** — §2 of this document, with the diagram.
|
||||
7. **Monitor types** — the §3.3 table.
|
||||
8. **Configuration reference** — full key table.
|
||||
9. **Unattended operation** — autologin, blanking, phantom outputs, does it come back
|
||||
after a power cut.
|
||||
10. **Troubleshooting** — lead with `socktop-swipe doctor`, then the specific failures.
|
||||
11. **Uninstall.**
|
||||
12. **Related projects** — socktop, uptime-kuma-status, unifly, Uptime Kuma.
|
||||
|
||||
The Wyse 3040 install is done by **following this README and nothing else**, and every
|
||||
place it is wrong or incomplete is a bug fixed before release.
|
||||
|
||||
---
|
||||
|
||||
## 8. Wayfinding indicator (proposed, cuttable)
|
||||
|
||||
A 2D grid is harder to stay oriented in than v1's single line, and there is no wrap to
|
||||
tell you you have reached an end. Proposal: a small persistent indicator in the tmux
|
||||
status line or pane border showing position, e.g. `unifly ▲ · rpi-worker-2 (3/5) · ▼ kuma`
|
||||
— the current screen plus what is above and below. Off by default, `indicator: true`
|
||||
to enable. Flagging it as a distinct decision rather than assuming it.
|
||||
|
||||
---
|
||||
|
||||
## 9. Repo layout after v2
|
||||
|
||||
```
|
||||
socktop-swipe/
|
||||
├── README.md rewritten (§7)
|
||||
├── Cargo.toml / Cargo.lock
|
||||
├── src/ §4
|
||||
├── config.example.yaml the default rack layout, heavily commented
|
||||
├── install.sh uninstall.sh
|
||||
├── packaging/
|
||||
│ ├── socktop-swipe.service systemd user unit
|
||||
│ └── 70-socktop-swipe.rules udev
|
||||
├── cad/ unchanged
|
||||
├── media/ unchanged
|
||||
└── notes/
|
||||
├── PLAN-v2.md this document
|
||||
├── DESIGN.md why tmux, why evdev, the grid model's reasoning
|
||||
├── HARDWARE-NOTES.md ILITEK contact counts, phantom DSI-1, the sudo-xset
|
||||
│ trap, multi-monitor SCREEN_W, LattePanda specifics,
|
||||
│ and: screen size and rack size are separate measurements
|
||||
│ (9" screen, 10" mini-rack mount, 19" rack via the adapter)
|
||||
├── V1-BASH.md what v1 did and where to find it (tag v1.2)
|
||||
└── TODO.md
|
||||
```
|
||||
|
||||
Tag the current tree `v1.2` before deleting the shell scripts, so the working bash
|
||||
version stays recoverable.
|
||||
|
||||
---
|
||||
|
||||
## 10. Work order
|
||||
|
||||
Each milestone ends somewhere testable.
|
||||
|
||||
| # | Milestone | Done when |
|
||||
| --- | --- | --- |
|
||||
| 1 | Tag `v1.2`. Scaffold the crate. Config types + `coord.rs` + `validate` | `socktop-swipe validate` prints the rack layout map from YAML |
|
||||
| 2 | `grid.rs` navigation state machine, no I/O | Unit tests cover: sparse columns, sub-sequence entry from left vs right, vertical snap, cursor persistence, both ends of every row |
|
||||
| 3 | `session/tmux.rs` + `attach` | Session builds correctly from YAML on the desktop; drive it with `next`/`back`/`up`/`down` by hand — no touch panel needed |
|
||||
| 4 | `input.rs` evdev + `doctor` | `doctor` correctly reports direction and contact count on the LattePanda's ILITEK panel |
|
||||
| 5 | `run` wires it together | LattePanda runs the full default grid by touch, unifly above and kuma below |
|
||||
| 6 | Installer rewrite | Clean-VM install works, prompts behave when piped from curl, `--yes` works |
|
||||
| 7 | README + notes/ split | Reads correctly to someone who has not seen the project |
|
||||
| 8 | **Wyse 3040 validation** | Debian minimal → working rack display, following only the README. Every stumble is a fix, then re-run |
|
||||
| 9 | Release | Tag v2.0, `config.example.yaml` matches the rack, `cargo clippy` clean with no `#[allow]` |
|
||||
|
||||
Milestone 5 is the point at which the LattePanda switches over. Its v1 install stays
|
||||
untouched until then — v2 uses a different config path and a different tmux session
|
||||
name, so both can be installed side by side during milestone 3–5 testing.
|
||||
|
||||
---
|
||||
|
||||
## 11. Open questions
|
||||
|
||||
**Q1 — column tie-break on snap.** From `0x2`, if the row above has cells at `-1x1`
|
||||
and `-1x3`, both are distance 1. Plan says lower column (left) wins. Fine? This only
|
||||
ever fires on the first entry into a row, so it is low-stakes.
|
||||
|
||||
**Q2 — the indicator (§8).** Build it, or cut it from v2?
|
||||
|
||||
**Q3 — repo visibility.** Distribution is Gitea-source-only for now, which is settled.
|
||||
But the README is being written for a public audience and the Wyse is reference
|
||||
hardware for a public guide. Is the Gitea repo publicly readable without a login? If
|
||||
not, the curl one-liner in the README will not work for anyone but you.
|
||||
@@ -0,0 +1,42 @@
|
||||
# TODO
|
||||
|
||||
## Before calling v2.0 done
|
||||
|
||||
- [ ] **Wyse 3040 validation.** Debian minimal to working rack display,
|
||||
following only the README. Every stumble is a README fix, then re-run.
|
||||
This is the acceptance gate, not a nice-to-have.
|
||||
- [x] **LattePanda switched over 2026-09-09.** v2 binary at `~/.local/bin`
|
||||
(no passwordless sudo on that box), config at
|
||||
`~/.config/socktop-swipe/config.yaml`, i3 autostart replaced with a single
|
||||
`socktop-swipe run`. All four screens verified live from the framebuffer.
|
||||
Build there: 108 s, peak 1.1 GB of 1.9 GB, 103 MB target dir.
|
||||
- [ ] Remove the v1 leftovers in `/usr/local/bin` on the LattePanda -- needs root:
|
||||
`sudo rm -f /usr/local/bin/socktop-rack /usr/local/bin/socktop-swipe /usr/local/bin/socktop-gestures /usr/local/etc/socktop-swipe.env`
|
||||
- [x] **Physically swipe-tested 2026-09-09** -- and it found a real bug the unit
|
||||
tests could not: every swipe classified as up-to-down, because a contact's
|
||||
start position was captured from the X event alone and recorded Y as zero.
|
||||
Fixed, with protocol-B decoding tests. QA passed on the retest.
|
||||
- [x] **Cold boot verified 2026-09-09.** Autologin, i3, and `socktop-swipe run`
|
||||
all come up unattended.
|
||||
- [ ] Add the tested-hardware table to the README once the Wyse is done.
|
||||
- [ ] Decide on the position indicator (`indicator: true`). Implemented; keep or
|
||||
cut based on whether it actually helps on the wall.
|
||||
|
||||
## Open questions from the plan
|
||||
|
||||
- **Snap tie-break.** From `0x2`, with cells at `-1x1` and `-1x3` both at
|
||||
distance 1, the lower column wins. Only fires on the first entry into a row.
|
||||
- **Indicator.** Off by default. See above.
|
||||
|
||||
## Later
|
||||
|
||||
- Prebuilt binaries. Deliberately not in v2.0: source-only until the Wyse install
|
||||
shows how bad a build on an Atom really is. `[package.metadata.deb]` is already
|
||||
in `Cargo.toml`, so `cargo deb` is the cheap next step if it turns out to hurt.
|
||||
- `unifly` parameters. `site` and `controller` are accepted by the config and
|
||||
passed as `--site` / `--controller`, but the fork does not implement them yet.
|
||||
Verify against the flags it actually grows.
|
||||
- A second monitor type that needs a multi-pane sub-sequence would prove the
|
||||
`Vec<Pane>` model generalises. Nothing needs it yet.
|
||||
- SIGTERM handling to remove the control socket on exit. Not urgent: a stale
|
||||
socket is detected and replaced on the next start, which is tested behaviour.
|
||||
@@ -0,0 +1,48 @@
|
||||
# What v1 was
|
||||
|
||||
v1 was three POSIX shell scripts plus a `.env` file, installed to
|
||||
`/usr/local/bin`. Tagged **`v1.2`** — `git show v1.2` has the whole thing, and it
|
||||
still works.
|
||||
|
||||
- `socktop-rack` built a tmux session: one window per group, one pane per host.
|
||||
- `socktop-swipe next|prev|down|up` walked a linear carousel, re-deriving its
|
||||
position from tmux on every single invocation.
|
||||
- `socktop-gestures` wrapped [lisgd](https://git.sr.ht/~mil/lisgd) with the right
|
||||
arguments and enforced a single instance.
|
||||
- `config.env` at `/usr/local/etc/socktop-swipe.env` held `SOCKTOP_GROUPS`,
|
||||
`SOCKTOP_AUX_CMD`, `TOUCH_DEV` and the gesture tuning.
|
||||
|
||||
It worked well for a year on the rack display. What made it worth replacing:
|
||||
|
||||
- **The layout was hard-coded as one line.** Groups ran left to right and that
|
||||
was the only shape available. The swipe-down "aux screen" existed because there
|
||||
was nowhere else to put a second kind of monitor — a special case bolted on,
|
||||
not a position in a layout.
|
||||
- **Every new monitor type was another special case.** `SOCKTOP_AUX_CMD` is a
|
||||
single command with no type, no parameters and no room for a second one.
|
||||
- **The navigation logic could not be tested.** It lived in a shell script that
|
||||
queried a live tmux server, so the only way to check a change was to install it
|
||||
on the display and swipe.
|
||||
|
||||
## Migration
|
||||
|
||||
There is no converter — deliberately, for a project with one known deployment.
|
||||
The mapping is direct:
|
||||
|
||||
| v1 | v2 |
|
||||
| --- | --- |
|
||||
| `SOCKTOP_GROUPS="a b ; c d @even-vertical"` | one `type: socktop` screen per group, at `"0x0"`, `"0x1"`, …, with `socktop_group` and `layout` |
|
||||
| `SOCKTOP_AUX_CMD="… uptime-kuma-status URL"` | a `type: uptime-kuma-status` screen at `"1x0"` |
|
||||
| `SOCKTOP_BIN` | `binaries: socktop:` |
|
||||
| `TOUCH_DEV` | `touch.device` |
|
||||
| `SCREEN_W` / `SCREEN_H` | `touch.width` / `touch.height` |
|
||||
| `SWIPE_THRESHOLD` / `SWIPE_LENIENCY` | `touch.threshold` / `touch.leniency` |
|
||||
| `FINGER_COUNTS="1 2 3"` | `touch.fingers: [1, 2, 3]` |
|
||||
| `GESTURE_IN` / `GESTURE_OUT` | `gestures.forward` / `gestures.back` |
|
||||
| `GESTURE_AUX_IN` / `GESTURE_AUX_OUT` | `gestures.down` / `gestures.up` |
|
||||
| the X `Option "Ignore"` rule | `touch.grab: true` |
|
||||
| `remain-on-exit` | the pane keep-alive wrapper (see DESIGN.md) |
|
||||
|
||||
Run `./uninstall.sh` from a v1 checkout first, or remove
|
||||
`/usr/local/bin/socktop-{rack,swipe,gestures}` and
|
||||
`/usr/local/etc/socktop-swipe.env` by hand.
|
||||
@@ -0,0 +1,13 @@
|
||||
# Installed by socktop-swipe.
|
||||
#
|
||||
# Reading touch events means reading /dev/input directly. The usual advice is
|
||||
# to join the 'input' group, but that grants access to EVERY input device --
|
||||
# including the keyboard, which is a keylogger's worth of access -- and it needs
|
||||
# a full logout to take effect.
|
||||
#
|
||||
# This grants access to touchscreens only, and applies the moment the device
|
||||
# appears. Change GROUP to a group the dashboard user is in if 'input' is not
|
||||
# suitable; the point is the narrower device match, not the group name.
|
||||
#
|
||||
# Reload with: sudo udevadm control --reload && sudo udevadm trigger
|
||||
SUBSYSTEM=="input", KERNEL=="event*", ENV{ID_INPUT_TOUCHSCREEN}=="1", GROUP="input", MODE="0640"
|
||||
@@ -0,0 +1,29 @@
|
||||
# systemd --user unit for the gesture daemon, as an alternative to starting
|
||||
# socktop-swipe from the window manager's autostart.
|
||||
#
|
||||
# cp packaging/socktop-swipe.service ~/.config/systemd/user/
|
||||
# systemctl --user enable --now socktop-swipe
|
||||
#
|
||||
# This runs the DAEMON only: it drives a session that something else built and
|
||||
# attached (see socktop-swipe build / attach). If you would rather have one unit
|
||||
# do everything, change ExecStart to `socktop-swipe run` and drop ExecStartPre --
|
||||
# but then the terminal is a child of the unit, and restarting the unit closes
|
||||
# the dashboard.
|
||||
[Unit]
|
||||
Description=socktop-swipe touchscreen gesture daemon
|
||||
Documentation=https://gt.wittyoneoff.com/jason/socktop-swipe
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# Only needed if `terminal:` is set and the terminal is an X client. The gesture
|
||||
# path itself reads evdev directly and needs no display connection at all.
|
||||
Environment=DISPLAY=:0
|
||||
ExecStartPre=/usr/local/bin/socktop-swipe build
|
||||
ExecStart=/usr/local/bin/socktop-swipe daemon
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -1,99 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Touch gesture daemon for the socktop display. Pass -v to log each detection.
|
||||
#
|
||||
# Single source of truth for the lisgd arguments -- referenced by the i3 autostart,
|
||||
# the systemd unit and tools/test-foreground.sh, so they cannot drift apart.
|
||||
#
|
||||
# IMPORTANT: run exactly ONE instance. lisgd does not grab the input device
|
||||
# exclusively, so two running copies make every swipe fire twice.
|
||||
set -eu
|
||||
|
||||
for c in /usr/local/etc/socktop-swipe.env "$(dirname "$0")/config.env"; do
|
||||
[ -r "$c" ] && . "$c" && break
|
||||
done
|
||||
|
||||
: "${TOUCH_DEV:?no TOUCH_DEV -- is the config installed?}"
|
||||
: "${SCREEN_W:=1024}"
|
||||
: "${SCREEN_H:=600}"
|
||||
: "${SWIPE_THRESHOLD:=80}"
|
||||
: "${SWIPE_LENIENCY:=30}"
|
||||
: "${FINGER_COUNTS:=1 2 3}"
|
||||
: "${GESTURE_IN:=RL}"
|
||||
: "${GESTURE_OUT:=LR}"
|
||||
: "${SOCKTOP_AUX_CMD:=}"
|
||||
: "${GESTURE_AUX_IN:=UD}"
|
||||
: "${GESTURE_AUX_OUT:=DU}"
|
||||
|
||||
SWIPE_CMD=${SWIPE_CMD:-$(dirname "$0")/socktop-swipe}
|
||||
[ -x /usr/local/bin/socktop-swipe ] && SWIPE_CMD=/usr/local/bin/socktop-swipe
|
||||
|
||||
verbose=
|
||||
replace=no
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
-v) verbose=-v ;;
|
||||
--replace) replace=yes ;;
|
||||
-h | --help)
|
||||
echo "usage: socktop-gestures [-v] [--replace]"
|
||||
echo " -v log each detected gesture"
|
||||
echo " --replace stop an already-running daemon first"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "socktop-gestures: unknown option '$a'" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Single-instance guard. lisgd does not grab the input device exclusively, so a
|
||||
# second copy makes every swipe fire twice -- the carousel appears to skip panes.
|
||||
# Enforced here rather than left to the caller: the obvious restart one-liner
|
||||
# `pkill -x lisgd && socktop-gestures` silently starts NOTHING when no daemon was
|
||||
# running, because pkill exits non-zero when it matches nothing.
|
||||
running=$(pgrep -x -u "$(id -u)" lisgd 2>/dev/null || true)
|
||||
if [ -n "$running" ]; then
|
||||
if [ "$replace" = yes ]; then
|
||||
pkill -x -u "$(id -u)" lisgd || true
|
||||
n=0
|
||||
while pgrep -x -u "$(id -u)" lisgd >/dev/null 2>&1 && [ "$n" -lt 30 ]; do
|
||||
n=$((n + 1))
|
||||
sleep 0.1
|
||||
done
|
||||
if pgrep -x -u "$(id -u)" lisgd >/dev/null 2>&1; then
|
||||
echo "socktop-gestures: existing lisgd would not exit" >&2
|
||||
exit 1
|
||||
fi
|
||||
else
|
||||
echo "socktop-gestures: lisgd is already running (pid: $(echo "$running" | tr '\n' ' '))" >&2
|
||||
echo " Two instances make every swipe fire twice. Use --replace to restart it." >&2
|
||||
exit 1
|
||||
fi
|
||||
fi
|
||||
|
||||
if [ ! -e "$TOUCH_DEV" ]; then
|
||||
echo "socktop-gestures: $TOUCH_DEV not present" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ ! -r "$TOUCH_DEV" ]; then
|
||||
echo "socktop-gestures: cannot read $TOUCH_DEV -- are you in the 'input' group?" >&2
|
||||
echo " sudo usermod -aG input $(id -un) then log out and back in" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Bind every configured contact count to the same action; see config.env for why.
|
||||
set --
|
||||
for f in $FINGER_COUNTS; do
|
||||
set -- "$@" -g "$f,$GESTURE_IN,*,*,R,$SWIPE_CMD next"
|
||||
set -- "$@" -g "$f,$GESTURE_OUT,*,*,R,$SWIPE_CMD prev"
|
||||
if [ -n "$SOCKTOP_AUX_CMD" ]; then
|
||||
set -- "$@" -g "$f,$GESTURE_AUX_IN,*,*,R,$SWIPE_CMD down"
|
||||
set -- "$@" -g "$f,$GESTURE_AUX_OUT,*,*,R,$SWIPE_CMD up"
|
||||
fi
|
||||
done
|
||||
|
||||
exec lisgd $verbose \
|
||||
-d "$TOUCH_DEV" \
|
||||
-w "$SCREEN_W" -h "$SCREEN_H" \
|
||||
-t "$SWIPE_THRESHOLD" -r "$SWIPE_LENIENCY" \
|
||||
"$@"
|
||||
@@ -1,91 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Build the socktop display session: one tmux window per GROUP, one pane per
|
||||
# host inside it. Window order and pane index order together are the swipe
|
||||
# order (see socktop-swipe).
|
||||
set -eu
|
||||
|
||||
# shellcheck source=config.env
|
||||
for c in /usr/local/etc/socktop-swipe.env "$(dirname "$0")/config.env"; do
|
||||
[ -r "$c" ] && . "$c" && break
|
||||
done
|
||||
|
||||
: "${SOCKTOP_GROUPS:=}"
|
||||
: "${SOCKTOP_HOSTS:=}"
|
||||
: "${SOCKTOP_SESSION:=socktop4}"
|
||||
: "${SOCKTOP_BIN:=socktop}"
|
||||
: "${SOCKTOP_AUX_CMD:=}"
|
||||
|
||||
# Legacy form: SOCKTOP_HOSTS alone is a single tiled group.
|
||||
if [ -z "$SOCKTOP_GROUPS" ]; then
|
||||
: "${SOCKTOP_HOSTS:?no SOCKTOP_GROUPS or SOCKTOP_HOSTS -- is the config installed?}"
|
||||
SOCKTOP_GROUPS=$SOCKTOP_HOSTS
|
||||
fi
|
||||
|
||||
tmux kill-session -t "$SOCKTOP_SESSION" 2>/dev/null || true
|
||||
|
||||
# Pane titles are set on the session, so do it up front; windows inherit.
|
||||
build_group() {
|
||||
# $1 = window name, remaining args = host names and an optional @layout
|
||||
name=$1
|
||||
shift
|
||||
layout=tiled
|
||||
hosts=
|
||||
for tok in "$@"; do
|
||||
case "$tok" in
|
||||
@*) layout=${tok#@} ;;
|
||||
*) hosts="$hosts $tok" ;;
|
||||
esac
|
||||
done
|
||||
# shellcheck disable=SC2086
|
||||
set -- $hosts
|
||||
[ $# -gt 0 ] || return 0
|
||||
|
||||
if ! tmux has-session -t "$SOCKTOP_SESSION" 2>/dev/null; then
|
||||
tmux new-session -d -s "$SOCKTOP_SESSION" -n "$name" "$SOCKTOP_BIN -P $1"
|
||||
else
|
||||
tmux new-window -d -t "$SOCKTOP_SESSION" -n "$name" "$SOCKTOP_BIN -P $1"
|
||||
fi
|
||||
win="$SOCKTOP_SESSION:$name"
|
||||
tmux select-pane -t "$win.0" -T "$1"
|
||||
shift
|
||||
for h in "$@"; do
|
||||
# Split the most recently created pane so creation order == index order.
|
||||
tmux split-window -t "$win" "$SOCKTOP_BIN -P $h"
|
||||
tmux select-pane -t "$win.$(tmux display-message -t "$win" -p '#{pane_index}')" -T "$h"
|
||||
done
|
||||
tmux select-layout -t "$win" "$layout"
|
||||
tmux select-pane -t "$win.0"
|
||||
}
|
||||
|
||||
n=0
|
||||
old_ifs=$IFS
|
||||
IFS=';'
|
||||
for group in $SOCKTOP_GROUPS; do
|
||||
IFS=$old_ifs
|
||||
# shellcheck disable=SC2086
|
||||
build_group "g$n" $group
|
||||
n=$((n + 1))
|
||||
IFS=';'
|
||||
done
|
||||
IFS=$old_ifs
|
||||
|
||||
# The aux window sits outside the carousel; socktop-swipe recognises it by name.
|
||||
if [ -n "$SOCKTOP_AUX_CMD" ]; then
|
||||
tmux new-window -d -t "$SOCKTOP_SESSION" -n aux "$SOCKTOP_AUX_CMD"
|
||||
fi
|
||||
|
||||
tmux select-window -t "$SOCKTOP_SESSION:g0"
|
||||
|
||||
tmux set-option -t "$SOCKTOP_SESSION" pane-border-status top
|
||||
tmux set-option -t "$SOCKTOP_SESSION" pane-border-format ' #{pane_title} '
|
||||
tmux set-option -t "$SOCKTOP_SESSION" status off
|
||||
|
||||
# Keep dead panes so pane indices stay stable if a socktop exits.
|
||||
tmux set-option -t "$SOCKTOP_SESSION" remain-on-exit on
|
||||
|
||||
# Mouse mode MUST stay off. With it on, every touch swipe is ALSO delivered to
|
||||
# tmux as a click-drag: dragging across a pane border resizes it and taps
|
||||
# reselect panes, which fights the gesture daemon. See README, "Why no tapping".
|
||||
tmux set-option -t "$SOCKTOP_SESSION" mouse off
|
||||
|
||||
exec tmux attach -t "$SOCKTOP_SESSION"
|
||||
-121
@@ -1,121 +0,0 @@
|
||||
#!/bin/sh
|
||||
# socktop-swipe next|prev|down|up
|
||||
#
|
||||
# Walks a linear "zoom carousel" over the socktop session. Each tmux window is
|
||||
# one group of hosts; within a group the line is the tiled overview, then each
|
||||
# pane zoomed full-screen in index order; then the next window's overview:
|
||||
#
|
||||
# overview(g0) <-> g0.0 <-> ... <-> g0.N <-> overview(g1) <-> g1.0 <-> ...
|
||||
#
|
||||
# "next" moves right and stops at the last pane of the last window. "prev"
|
||||
# moves back and stops at the first overview. Deliberately no wrap-around: on
|
||||
# a wall display, wrapping makes it impossible to tell where you are.
|
||||
#
|
||||
# "down" jumps to the optional aux window (see SOCKTOP_AUX_CMD) and remembers
|
||||
# where you were; "up" returns there. The aux window is not part of the
|
||||
# horizontal carousel, and horizontal swipes while on it do nothing.
|
||||
#
|
||||
# This is tmux pane zoom, NOT extra socktop instances. All panes keep running
|
||||
# and stay connected while hidden, so swiping back shows current data with no
|
||||
# reconnect, and the polling load on the monitored hosts is constant.
|
||||
set -eu
|
||||
|
||||
for c in /usr/local/etc/socktop-swipe.env "$(dirname "$0")/config.env"; do
|
||||
[ -r "$c" ] && . "$c" && break
|
||||
done
|
||||
: "${SOCKTOP_SESSION:=socktop4}"
|
||||
|
||||
dir=${1:?usage: socktop-swipe next|prev|down|up}
|
||||
|
||||
# Ordered window ids (@N) of the carousel windows, the aux window if any, and
|
||||
# the currently active window.
|
||||
all=$(tmux list-windows -t "$SOCKTOP_SESSION" -F '#{window_id} #{window_name}' 2>/dev/null) || exit 0
|
||||
[ -n "$all" ] || exit 0
|
||||
wins= aux=
|
||||
while read -r id name; do
|
||||
if [ "$name" = aux ]; then aux=$id; else wins="$wins $id"; fi
|
||||
done <<EOF
|
||||
$all
|
||||
EOF
|
||||
WIN=$(tmux display-message -t "$SOCKTOP_SESSION" -p '#{window_id}') || exit 0
|
||||
|
||||
case "$dir" in
|
||||
down)
|
||||
[ -n "$aux" ] || exit 0
|
||||
[ "$WIN" = "$aux" ] && exit 0
|
||||
tmux set-option -t "$SOCKTOP_SESSION" @return_to "$WIN"
|
||||
tmux select-window -t "$aux"
|
||||
exit 0
|
||||
;;
|
||||
up)
|
||||
[ "$WIN" = "$aux" ] || exit 0
|
||||
back=$(tmux show-option -qv -t "$SOCKTOP_SESSION" @return_to)
|
||||
[ -n "$back" ] && tmux has-session -t "$back" 2>/dev/null || back=${wins# }
|
||||
back=${back%% *}
|
||||
tmux select-window -t "$back"
|
||||
exit 0
|
||||
;;
|
||||
esac
|
||||
|
||||
# On the aux screen, horizontal swipes are deliberately inert.
|
||||
[ "$WIN" = "$aux" ] && exit 0
|
||||
|
||||
prev_win= next_win= seen=no
|
||||
for w in $wins; do
|
||||
if [ "$seen" = yes ]; then next_win=$w; break; fi
|
||||
if [ "$w" = "$WIN" ]; then seen=yes; else prev_win=$w; fi
|
||||
done
|
||||
|
||||
state=$(tmux display-message -t "$WIN" -p '#{window_zoomed_flag} #{pane_index} #{window_panes}') || exit 0
|
||||
zoomed=${state%% *}
|
||||
rest=${state#* }
|
||||
idx=${rest%% *}
|
||||
count=${rest##* }
|
||||
last=$((count - 1))
|
||||
|
||||
zoom_to() {
|
||||
# $1 = window id, $2 = pane index.
|
||||
# Selecting a different pane auto-unzooms, so zoom explicitly afterwards.
|
||||
tmux select-window -t "$1"
|
||||
tmux select-pane -t "$1.$2"
|
||||
tmux resize-pane -Z -t "$1.$2"
|
||||
}
|
||||
|
||||
overview() {
|
||||
# Show window $1 unzoomed.
|
||||
tmux select-window -t "$1"
|
||||
if [ "$(tmux display-message -t "$1" -p '#{window_zoomed_flag}')" = 1 ]; then
|
||||
tmux resize-pane -Z -t "$1"
|
||||
fi
|
||||
}
|
||||
|
||||
if [ "$zoomed" = 0 ]; then
|
||||
case "$dir" in
|
||||
next) zoom_to "$WIN" 0 ;;
|
||||
prev)
|
||||
# Back out of this group's overview onto the previous group's last pane.
|
||||
if [ -n "$prev_win" ]; then
|
||||
pl=$(tmux display-message -t "$prev_win" -p '#{window_panes}')
|
||||
zoom_to "$prev_win" $((pl - 1))
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
else
|
||||
case "$dir" in
|
||||
next)
|
||||
if [ "$idx" -lt "$last" ]; then
|
||||
zoom_to "$WIN" $((idx + 1))
|
||||
elif [ -n "$next_win" ]; then
|
||||
tmux resize-pane -Z -t "$WIN.$idx" # unzoom before leaving
|
||||
overview "$next_win"
|
||||
fi
|
||||
;;
|
||||
prev)
|
||||
if [ "$idx" -gt 0 ]; then
|
||||
zoom_to "$WIN" $((idx - 1))
|
||||
else
|
||||
tmux resize-pane -Z -t "$WIN.$idx" # unzoom -> this group's overview
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
@@ -1,17 +0,0 @@
|
||||
[Unit]
|
||||
Description=socktop-swipe touchscreen gesture daemon
|
||||
Documentation=https://gt.wittyoneoff.com/jason/socktop-swipe
|
||||
After=graphical-session.target
|
||||
PartOf=graphical-session.target
|
||||
|
||||
[Service]
|
||||
Type=simple
|
||||
# lisgd queries X for screen geometry unless -w/-h are given (socktop-gestures
|
||||
# always passes them), but keep DISPLAY set so it starts cleanly either way.
|
||||
Environment=DISPLAY=:0
|
||||
ExecStart=/usr/local/bin/socktop-gestures
|
||||
Restart=always
|
||||
RestartSec=2
|
||||
|
||||
[Install]
|
||||
WantedBy=graphical-session.target
|
||||
@@ -0,0 +1,166 @@
|
||||
//! 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,464 @@
|
||||
//! 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()
|
||||
}
|
||||
+125
@@ -0,0 +1,125 @@
|
||||
//! 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,75 @@
|
||||
//! 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
|
||||
})
|
||||
}
|
||||
+512
@@ -0,0 +1,512 @@
|
||||
//! 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));
|
||||
}
|
||||
}
|
||||
+650
@@ -0,0 +1,650 @@
|
||||
//! 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,
|
||||
}
|
||||
|
||||
/// One contact's journey.
|
||||
///
|
||||
/// The start position is per axis and `Option`, NOT a `(0, 0)` sentinel. X and Y
|
||||
/// arrive as SEPARATE events, so a contact's opening frame is `POSITION_X` then
|
||||
/// `POSITION_Y`: capturing "the start" on the first of those records a Y of
|
||||
/// zero, and every later comparison then measures from the top edge of the panel
|
||||
/// rather than from the finger. That made `dy` enormous and positive, so every
|
||||
/// swipe -- horizontal ones included -- classified as up-to-down.
|
||||
#[derive(Debug, Default, Clone, Copy)]
|
||||
struct Slot {
|
||||
start_x: Option<i32>,
|
||||
start_y: Option<i32>,
|
||||
last: (i32, i32),
|
||||
active: bool,
|
||||
}
|
||||
|
||||
impl Slot {
|
||||
/// An axis that never reported a position contributes no displacement.
|
||||
fn track(&self) -> Track {
|
||||
(
|
||||
(
|
||||
self.start_x.unwrap_or(self.last.0),
|
||||
self.start_y.unwrap_or(self.last.1),
|
||||
),
|
||||
self.last,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/// The parts of a multitouch protocol-B stream we care about. Kept separate from
|
||||
/// evdev's own types so the slot bookkeeping is testable without a device --
|
||||
/// which is precisely the code the above bug lived in, untested.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum Touched {
|
||||
/// `ABS_MT_SLOT`: subsequent events apply to this slot.
|
||||
Slot(i32),
|
||||
/// `ABS_MT_TRACKING_ID`: a new contact when >= 0, a lift when -1.
|
||||
TrackingId(i32),
|
||||
X(i32),
|
||||
Y(i32),
|
||||
}
|
||||
|
||||
/// Slot bookkeeping for multitouch protocol B.
|
||||
#[derive(Debug, Default)]
|
||||
pub struct SlotTracker {
|
||||
slots: HashMap<i32, Slot>,
|
||||
current: i32,
|
||||
peak: usize,
|
||||
}
|
||||
|
||||
impl SlotTracker {
|
||||
pub fn feed(&mut self, ev: Touched) {
|
||||
match ev {
|
||||
Touched::Slot(n) => self.current = n,
|
||||
Touched::TrackingId(id) => {
|
||||
if id < 0 {
|
||||
if let Some(s) = self.slots.get_mut(&self.current) {
|
||||
s.active = false;
|
||||
}
|
||||
} else {
|
||||
self.slots.insert(
|
||||
self.current,
|
||||
Slot {
|
||||
active: true,
|
||||
..Slot::default()
|
||||
},
|
||||
);
|
||||
self.peak = self.peak.max(self.active());
|
||||
}
|
||||
}
|
||||
Touched::X(x) => {
|
||||
let s = self.slot();
|
||||
s.last.0 = x;
|
||||
s.start_x.get_or_insert(x);
|
||||
}
|
||||
Touched::Y(y) => {
|
||||
let s = self.slot();
|
||||
s.last.1 = y;
|
||||
s.start_y.get_or_insert(y);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn slot(&mut self) -> &mut Slot {
|
||||
self.slots.entry(self.current).or_insert(Slot {
|
||||
active: true,
|
||||
..Slot::default()
|
||||
})
|
||||
}
|
||||
|
||||
fn active(&self) -> usize {
|
||||
self.slots.values().filter(|s| s.active).count()
|
||||
}
|
||||
|
||||
/// A gesture is over when every contact seen has lifted.
|
||||
pub fn complete(&self) -> bool {
|
||||
!self.slots.is_empty() && self.active() == 0
|
||||
}
|
||||
|
||||
pub fn tracks(&self) -> Vec<Track> {
|
||||
self.slots.values().map(Slot::track).collect()
|
||||
}
|
||||
|
||||
/// Peak simultaneous contacts, which is what `touch.fingers` matches.
|
||||
pub fn peak(&self) -> usize {
|
||||
self.peak
|
||||
}
|
||||
|
||||
pub fn reset(&mut self) {
|
||||
self.slots.clear();
|
||||
self.peak = 0;
|
||||
}
|
||||
}
|
||||
|
||||
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 tracker = SlotTracker::default();
|
||||
|
||||
loop {
|
||||
for ev in self.device.fetch_events().context("reading touch events")? {
|
||||
let touched = match ev.kind() {
|
||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
|
||||
Touched::Slot(ev.value())
|
||||
}
|
||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
|
||||
Touched::TrackingId(ev.value())
|
||||
}
|
||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
|
||||
Touched::X(ev.value())
|
||||
}
|
||||
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => {
|
||||
Touched::Y(ev.value())
|
||||
}
|
||||
_ => continue,
|
||||
};
|
||||
tracker.feed(touched);
|
||||
}
|
||||
|
||||
// The gesture ends when the last contact lifts.
|
||||
if tracker.complete() {
|
||||
if let Some(ev) = classify(&self.cfg, &tracker.tracks(), tracker.peak()) {
|
||||
if !on_event(ev) {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
tracker.reset();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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,
|
||||
}))
|
||||
}
|
||||
|
||||
/// 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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
// -- slot tracking: real protocol-B event streams -----------------------
|
||||
//
|
||||
// These exist because the classifier tests above build Track tuples by hand
|
||||
// and so never exercised the decoding. The bug that shipped to the rack
|
||||
// display lived exactly here: every swipe came out as up-to-down, and the
|
||||
// display got stuck on the bottom row because "down" from there is a no-op.
|
||||
|
||||
/// One contact moving from `from` to `to`, reported the way the kernel does:
|
||||
/// tracking id, then X and Y as separate events, then a lift.
|
||||
fn contact(t: &mut SlotTracker, slot: i32, from: (i32, i32), to: (i32, i32), steps: i32) {
|
||||
t.feed(Touched::Slot(slot));
|
||||
t.feed(Touched::TrackingId(slot + 1));
|
||||
for i in 0..=steps {
|
||||
let x = from.0 + (to.0 - from.0) * i / steps;
|
||||
let y = from.1 + (to.1 - from.1) * i / steps;
|
||||
t.feed(Touched::Slot(slot));
|
||||
t.feed(Touched::X(x));
|
||||
t.feed(Touched::Y(y));
|
||||
}
|
||||
}
|
||||
|
||||
fn lift(t: &mut SlotTracker, slot: i32) {
|
||||
t.feed(Touched::Slot(slot));
|
||||
t.feed(Touched::TrackingId(-1));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_horizontal_swipe_low_on_the_panel_is_not_read_as_downward() {
|
||||
// THE REGRESSION. A right-to-left swipe at y=360 on a 720-tall panel.
|
||||
// With a (0,0) start sentinel, Y was captured as 0 and dy became +360,
|
||||
// dwarfing dx and classifying this as UD.
|
||||
let mut t = SlotTracker::default();
|
||||
contact(&mut t, 0, (900, 360), (700, 362), 10);
|
||||
lift(&mut t, 0);
|
||||
assert!(t.complete());
|
||||
|
||||
match classify(&touch(80, 30, vec![1]), &t.tracks(), t.peak()) {
|
||||
Some(Event::Swipe(s)) => {
|
||||
assert_eq!(
|
||||
s.direction,
|
||||
Direction::RL,
|
||||
"a left swipe must not read as down"
|
||||
);
|
||||
assert_eq!(s.fingers, 1);
|
||||
}
|
||||
other => panic!("expected an RL swipe, got {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn start_is_captured_per_axis() {
|
||||
// X arrives before Y, so a start captured on the first event alone would
|
||||
// record y=0 and report the finger travelling the height of the panel.
|
||||
let mut t = SlotTracker::default();
|
||||
t.feed(Touched::Slot(0));
|
||||
t.feed(Touched::TrackingId(1));
|
||||
t.feed(Touched::X(900));
|
||||
t.feed(Touched::Y(360));
|
||||
t.feed(Touched::X(700));
|
||||
t.feed(Touched::Y(360));
|
||||
lift(&mut t, 0);
|
||||
assert_eq!(t.tracks(), vec![((900, 360), (700, 360))]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn all_four_directions_survive_decoding() {
|
||||
let cfg = touch(80, 30, vec![1]);
|
||||
// Centre of a 1280x720 panel, 200px each way.
|
||||
for (to, want) in [
|
||||
((440, 360), Direction::RL),
|
||||
((840, 360), Direction::LR),
|
||||
((640, 160), Direction::DU),
|
||||
((640, 560), Direction::UD),
|
||||
] {
|
||||
let mut t = SlotTracker::default();
|
||||
contact(&mut t, 0, (640, 360), to, 10);
|
||||
lift(&mut t, 0);
|
||||
match classify(&cfg, &t.tracks(), t.peak()) {
|
||||
Some(Event::Swipe(s)) => assert_eq!(s.direction, want, "moving to {to:?}"),
|
||||
other => panic!("moving to {to:?}: {other:?}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ghost_contacts_decode_as_one_swipe_of_the_right_length() {
|
||||
// The ILITEK panel reports one finger as two or three contacts.
|
||||
let mut t = SlotTracker::default();
|
||||
contact(&mut t, 0, (900, 300), (700, 300), 8);
|
||||
contact(&mut t, 1, (903, 305), (703, 305), 8);
|
||||
contact(&mut t, 2, (897, 295), (697, 295), 8);
|
||||
lift(&mut t, 0);
|
||||
lift(&mut t, 1);
|
||||
lift(&mut t, 2);
|
||||
|
||||
assert_eq!(t.peak(), 3, "peak contacts drive the touch.fingers match");
|
||||
match classify(&touch(150, 30, vec![1, 2, 3]), &t.tracks(), t.peak()) {
|
||||
Some(Event::Swipe(s)) => {
|
||||
assert_eq!(s.direction, Direction::RL);
|
||||
assert_eq!(s.fingers, 3);
|
||||
}
|
||||
other => panic!("expected one RL swipe: {other:?}"),
|
||||
}
|
||||
// 200px of travel, not 600.
|
||||
match classify(&touch(250, 30, vec![1, 2, 3]), &t.tracks(), t.peak()) {
|
||||
Some(Event::Discarded(_, Rejected::TooShort { travel, .. })) => {
|
||||
assert!((travel - 200.0).abs() < 2.0, "travel was {travel}");
|
||||
}
|
||||
other => panic!("expected 200px of travel: {other:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_gesture_is_only_complete_once_every_contact_lifts() {
|
||||
let mut t = SlotTracker::default();
|
||||
assert!(!t.complete(), "nothing has been touched yet");
|
||||
contact(&mut t, 0, (900, 300), (800, 300), 4);
|
||||
assert!(!t.complete(), "still down");
|
||||
contact(&mut t, 1, (500, 300), (400, 300), 4);
|
||||
lift(&mut t, 0);
|
||||
assert!(!t.complete(), "one contact is still down");
|
||||
lift(&mut t, 1);
|
||||
assert!(t.complete());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn reset_clears_the_previous_gesture() {
|
||||
let mut t = SlotTracker::default();
|
||||
contact(&mut t, 0, (900, 300), (700, 300), 4);
|
||||
lift(&mut t, 0);
|
||||
t.reset();
|
||||
assert!(!t.complete());
|
||||
assert_eq!(t.peak(), 0);
|
||||
assert!(t.tracks().is_empty());
|
||||
|
||||
// A second swipe must measure from its own origin, not the first one's.
|
||||
contact(&mut t, 0, (300, 300), (500, 300), 4);
|
||||
lift(&mut t, 0);
|
||||
match classify(&touch(80, 30, vec![1]), &t.tracks(), t.peak()) {
|
||||
Some(Event::Swipe(s)) => assert_eq!(s.direction, Direction::LR),
|
||||
other => panic!("{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;
|
||||
+386
@@ -0,0 +1,386 @@
|
||||
//! 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}"))?;
|
||||
// Same tilde expansion as `binaries:`. A window manager's PATH
|
||||
// rarely includes ~/.cargo/bin, so a full path is the usual answer
|
||||
// here and it should not have to be spelled out longhand.
|
||||
let prog = config::expand_tilde(&argv.remove(0));
|
||||
Command::new(&prog)
|
||||
.args(argv)
|
||||
.arg("-e")
|
||||
.args(&attach)
|
||||
.spawn()
|
||||
.with_context(|| format!("cannot start terminal {prog:?}"))?
|
||||
}
|
||||
None => Command::new(&attach[0])
|
||||
.args(&attach[1..])
|
||||
.spawn()
|
||||
.context("cannot attach to the session")?,
|
||||
};
|
||||
|
||||
// Closing the dashboard window is the ordinary way this ends.
|
||||
let quit = tx.clone();
|
||||
std::thread::spawn(move || {
|
||||
let _ = child.wait();
|
||||
let _ = quit.send(Ctl::Quit);
|
||||
});
|
||||
|
||||
if !no_touch {
|
||||
// Deliberately not fatal. The dashboard is already on the wall by this
|
||||
// point; exiting because the panel is missing would replace a display
|
||||
// you cannot swipe with no display at all. Say so loudly and carry on
|
||||
// serving the control socket, which is still a way to drive it.
|
||||
if let Err(e) = spawn_panel(cfg, tx) {
|
||||
eprintln!("socktop-swipe: touch gestures are NOT active: {e:#}");
|
||||
eprintln!(
|
||||
"socktop-swipe: the dashboard is up; drive it with `socktop-swipe forward` etc."
|
||||
);
|
||||
}
|
||||
}
|
||||
drive(&tmux, &mut grid, rx)
|
||||
}
|
||||
|
||||
fn daemon(cfg: &Config, no_touch: bool) -> Result<()> {
|
||||
let (tmux, mut grid) = build(cfg)?;
|
||||
if !tmux.is_running() {
|
||||
anyhow::bail!(
|
||||
"no session named {:?}. Start one with `socktop-swipe attach`, \
|
||||
or use `socktop-swipe run` to do both.",
|
||||
cfg.session
|
||||
);
|
||||
}
|
||||
tmux.adopt(&grid)?;
|
||||
|
||||
let (tx, rx) = mpsc::channel();
|
||||
let _socket = control::listen(&cfg.session, tx.clone())?;
|
||||
let _guard = control::SocketGuard(control::socket_path(&cfg.session));
|
||||
if !no_touch {
|
||||
spawn_panel(cfg, tx)?;
|
||||
} else {
|
||||
// Without a panel there is nothing else to hold the channel open.
|
||||
drop(tx);
|
||||
}
|
||||
drive(&tmux, &mut grid, rx)
|
||||
}
|
||||
|
||||
/// Open the touch panel, retrying briefly.
|
||||
///
|
||||
/// At boot the autostart can win the race against USB enumeration, so the
|
||||
/// device is simply not there yet. Ten seconds covers that without making a
|
||||
/// genuinely wrong device path take ten seconds to report.
|
||||
fn open_panel(cfg: &Config) -> Result<Touchpanel> {
|
||||
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
|
||||
loop {
|
||||
match Touchpanel::open(&cfg.touch) {
|
||||
Ok(p) => return Ok(p),
|
||||
Err(e) if std::time::Instant::now() < deadline => {
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
let _ = e;
|
||||
}
|
||||
Err(e) => return Err(e),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// 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 = open_panel(cfg)?;
|
||||
let gestures = cfg.gestures.clone();
|
||||
std::thread::spawn(move || {
|
||||
let result = panel.run(|ev| {
|
||||
let Event::Swipe(s) = ev else { return true };
|
||||
match binding(&gestures, s.direction) {
|
||||
Some(m) => tx.send(Ctl::Go(m, None)).is_ok(),
|
||||
None => true,
|
||||
}
|
||||
});
|
||||
if let Err(e) = result {
|
||||
let _ = tx.send(Ctl::Failed(e));
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn drive(tmux: &Tmux, grid: &mut Grid, rx: mpsc::Receiver<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,
|
||||
})
|
||||
}
|
||||
+181
@@ -0,0 +1,181 @@
|
||||
//! 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,382 @@
|
||||
//! 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"
|
||||
)
|
||||
}
|
||||
|
||||
/// Name a pane for the border.
|
||||
///
|
||||
/// NOT `select-pane -T`, which sets the pane *title* -- a value the program
|
||||
/// running in the pane can overwrite at any time with an OSC escape. unifly
|
||||
/// probes for Kitty graphics support on startup and tmux consumed part of
|
||||
/// that probe as a title change, so the border read
|
||||
/// `Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA` instead of "unifly". A pane-scoped user
|
||||
/// option is ours alone and no escape sequence can reach it.
|
||||
fn label(&self, pane: &str, title: &str) -> Result<()> {
|
||||
self.run(&["set-option", "-p", "-t", pane, "@socktop_label", title])?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
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,
|
||||
])?
|
||||
};
|
||||
|
||||
// These are all WINDOW options, and new windows do not inherit them, so
|
||||
// they must be set per window rather than once on the session. Setting
|
||||
// a window option with `set-option -t <session>` silently applies it to
|
||||
// whichever window happens to be current -- the same trap that makes
|
||||
// `remain-on-exit` useless here, and the reason v1's pane borders only
|
||||
// ever appeared on one of its windows.
|
||||
//
|
||||
// allow-rename: a window created with -n has automatic-rename off, but a
|
||||
// program can still rename it with an escape sequence.
|
||||
for (opt, val) in [
|
||||
("allow-rename", "off"),
|
||||
("pane-border-status", "top"),
|
||||
(
|
||||
"pane-border-format",
|
||||
// Fall back to the pane title if the label is somehow unset, so
|
||||
// a pane is never nameless.
|
||||
" #{?#{@socktop_label},#{@socktop_label},#{pane_title}} ",
|
||||
),
|
||||
] {
|
||||
self.run(&["set-option", "-w", "-t", &window, opt, val])?;
|
||||
}
|
||||
|
||||
let first_pane = self.run(&["display-message", "-p", "-t", &window, "#{pane_id}"])?;
|
||||
self.label(&first_pane, &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.label(&id, &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;
|
||||
|
||||
// Only SESSION options below; window options are set per window in
|
||||
// place(), for the reason given there.
|
||||
let s = &self.session;
|
||||
// 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,252 @@
|
||||
//! 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;
|
||||
|
||||
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(session: &str) -> 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 label, is the window zoomed)
|
||||
fn visible(session: &str) -> (String, String, bool) {
|
||||
let s = tmux(&[
|
||||
"display-message",
|
||||
"-p",
|
||||
"-t",
|
||||
session,
|
||||
"#{window_name}\t#{@socktop_label}\t#{window_zoomed_flag}",
|
||||
]);
|
||||
let f: Vec<&str> = s.split('\t').collect();
|
||||
(f[0].into(), f[1].into(), f[2] == "1")
|
||||
}
|
||||
|
||||
/// Each test uses its own session name: cargo runs tests in parallel and they
|
||||
/// would otherwise tear down each other's tmux server state.
|
||||
struct Cleanup(&'static str);
|
||||
impl Drop for Cleanup {
|
||||
fn drop(&mut self) {
|
||||
let _ = Command::new("tmux")
|
||||
.args(["kill-session", "-t", self.0])
|
||||
.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;
|
||||
}
|
||||
const SESSION: &str = "socktop-swipe-selftest-nav";
|
||||
let _cleanup = Cleanup(SESSION);
|
||||
|
||||
let cfg = config(SESSION);
|
||||
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(SESSION);
|
||||
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(SESSION);
|
||||
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(SESSION);
|
||||
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(SESSION);
|
||||
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(SESSION);
|
||||
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(SESSION);
|
||||
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(SESSION).0, "r1c0");
|
||||
}
|
||||
|
||||
/// Window options do not propagate from the session, and new windows do not
|
||||
/// inherit them. Setting one with `set-option -t <session>` quietly applies it
|
||||
/// to whichever window is current, which is how v1 ended up with pane borders
|
||||
/// on only one of its three windows. Assert EVERY window got them.
|
||||
#[test]
|
||||
fn window_options_are_set_on_every_window() {
|
||||
if !have_tmux() {
|
||||
return;
|
||||
}
|
||||
const SESSION: &str = "socktop-swipe-selftest-opts";
|
||||
let _cleanup = Cleanup(SESSION);
|
||||
|
||||
let cfg = config(SESSION);
|
||||
let grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
|
||||
Tmux::new(&cfg.session, false).build(&grid).unwrap();
|
||||
|
||||
for (window, want_labels) in [
|
||||
("rm1c0", vec!["unifly"]),
|
||||
("r0c0", vec!["alpha", "bravo", "charlie", "delta"]),
|
||||
("r0c5", vec!["echo1", "foxtrot"]),
|
||||
("r1c0", vec!["uptime kuma"]),
|
||||
] {
|
||||
let target = format!("{SESSION}:{window}");
|
||||
for (opt, want) in [("pane-border-status", "top"), ("allow-rename", "off")] {
|
||||
let got = tmux(&["show-options", "-w", "-t", &target, "-v", opt]);
|
||||
assert_eq!(got, want, "{window} is missing the {opt} window option");
|
||||
}
|
||||
assert!(
|
||||
tmux(&[
|
||||
"show-options",
|
||||
"-w",
|
||||
"-t",
|
||||
&target,
|
||||
"-v",
|
||||
"pane-border-format"
|
||||
])
|
||||
.contains("@socktop_label"),
|
||||
"{window} is missing the pane-border-format"
|
||||
);
|
||||
|
||||
// Labels live in a pane-scoped user option precisely so the program in
|
||||
// the pane cannot overwrite them with a title escape sequence.
|
||||
let labels = tmux(&["list-panes", "-t", &target, "-F", "#{@socktop_label}"]);
|
||||
assert_eq!(
|
||||
labels.lines().collect::<Vec<_>>(),
|
||||
want_labels,
|
||||
"{window} has the wrong pane labels"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[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"
|
||||
);
|
||||
}
|
||||
@@ -1,56 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Gesture diagnostic. Two stages, each answering exactly one question.
|
||||
#
|
||||
# Stage 1: is the panel delivering events to us at all?
|
||||
# Stage 2: which direction and how many contacts does lisgd see?
|
||||
#
|
||||
# Stage 2 is the important one. Each line reads:
|
||||
# [swipe]: Cfg(f=1/s=3/e=0/d=0) <=> Evt(f=2/s=3/e=1/d=2)
|
||||
# ^ what you configured ^ what actually happened
|
||||
# f=fingers, s=direction, e=edge, d=distance. Direction enum:
|
||||
# 0=DU (down-to-up) 1=UD 2=LR (left-to-right) 3=RL (right-to-left)
|
||||
#
|
||||
# If Cfg and Evt agree on s= but differ on f=, your panel reports more contacts
|
||||
# than you configured -- add them to FINGER_COUNTS in the config.
|
||||
set -eu
|
||||
|
||||
for c in /usr/local/etc/socktop-swipe.env "$(dirname "$0")/../config.env"; do
|
||||
[ -r "$c" ] && . "$c" && break
|
||||
done
|
||||
: "${TOUCH_DEV:?no TOUCH_DEV configured}"
|
||||
: "${SCREEN_W:=1024}"
|
||||
: "${SCREEN_H:=600}"
|
||||
: "${SWIPE_THRESHOLD:=80}"
|
||||
: "${SWIPE_LENIENCY:=30}"
|
||||
|
||||
echo "=============================================================="
|
||||
echo "STAGE 1 -- is the panel delivering events at all?"
|
||||
echo "Touch and drag on the TOUCHSCREEN for the next 6 seconds..."
|
||||
echo "=============================================================="
|
||||
bytes=$(timeout 6 cat "$TOUCH_DEV" 2>/dev/null | wc -c)
|
||||
echo
|
||||
echo " read $bytes bytes from $TOUCH_DEV"
|
||||
if [ "$bytes" -eq 0 ]; then
|
||||
cat <<-EOF
|
||||
-> NOTHING. Stage 2 cannot work. Check, in order:
|
||||
* are you in the 'input' group? (id -nG | grep input; needs a relogin)
|
||||
* is TOUCH_DEV the right device? (tools/find-device.sh)
|
||||
EOF
|
||||
exit 1
|
||||
fi
|
||||
echo " -> device is live."
|
||||
echo
|
||||
|
||||
echo "=============================================================="
|
||||
echo "STAGE 2 -- what does lisgd actually see?"
|
||||
echo "All four directions are bound, for 1, 2 and 3 contacts."
|
||||
echo "Swipe LEFT, RIGHT, UP, DOWN. Ctrl-C when done."
|
||||
echo "=============================================================="
|
||||
set --
|
||||
for f in 1 2 3; do
|
||||
for g in RL LR DU UD; do
|
||||
set -- "$@" -g "$f,$g,*,*,R,echo \" >>> FIRED: ${f}-contact $g\""
|
||||
done
|
||||
done
|
||||
exec lisgd -v -d "$TOUCH_DEV" -w "$SCREEN_W" -h "$SCREEN_H" \
|
||||
-t "$SWIPE_THRESHOLD" -r "$SWIPE_LENIENCY" "$@"
|
||||
@@ -1,36 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Identify the touchscreen and print the stable by-id path for config.env.
|
||||
set -eu
|
||||
|
||||
echo "=== input devices reporting a touch-ish name ==="
|
||||
found=
|
||||
for ev in /dev/input/event*; do
|
||||
name=$(cat "/sys/class/input/$(basename "$ev")/device/name" 2>/dev/null || true)
|
||||
case "$name" in
|
||||
*[Tt]ouch* | *TOUCH*)
|
||||
found=yes
|
||||
echo
|
||||
echo " device: $ev"
|
||||
echo " name: $name"
|
||||
for l in /dev/input/by-id/* /dev/input/by-path/*; do
|
||||
[ -e "$l" ] || continue
|
||||
if [ "$(readlink -f "$l")" = "$(readlink -f "$ev")" ]; then
|
||||
echo " stable: $l"
|
||||
fi
|
||||
done
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
if [ -z "$found" ]; then
|
||||
echo " (none matched by name)"
|
||||
echo
|
||||
echo "Fall back to listing everything:"
|
||||
for ev in /dev/input/event*; do
|
||||
printf ' %-22s %s\n' "$ev" "$(cat "/sys/class/input/$(basename "$ev")/device/name" 2>/dev/null)"
|
||||
done
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Put the 'stable:' path (prefer /dev/input/by-id/) into TOUCH_DEV."
|
||||
echo "Event numbers change across reboots; by-id symlinks do not."
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/sh
|
||||
# Run the real gesture daemon in the foreground so you can watch the display
|
||||
# while you swipe. Ctrl-C to stop.
|
||||
#
|
||||
# Expect: swipe right-to-left -> zooms in one host at a time
|
||||
# swipe left-to-right -> walks back out to the overview
|
||||
# "Execute ..." in the output means a gesture matched and fired.
|
||||
#
|
||||
# Pass --replace to take over from an already-running daemon; without it,
|
||||
# socktop-gestures refuses to start a second instance (which would make every
|
||||
# swipe fire twice).
|
||||
set -eu
|
||||
exec "$(dirname "$0")/../socktop-gestures" -v "$@"
|
||||
+61
-41
@@ -1,56 +1,76 @@
|
||||
#!/bin/sh
|
||||
# Remove socktop-swipe. Leaves lisgd, the 'input' group membership and your
|
||||
# socktop profiles alone -- those are useful independently and removing them
|
||||
# could break other things.
|
||||
# Remove socktop-swipe.
|
||||
#
|
||||
# ./uninstall.sh remove scripts, config and the X ignore rule
|
||||
# ./uninstall.sh --keep-config leave the config file in place
|
||||
# ./uninstall.sh binary, config, udev rule, X snippets, autologin
|
||||
# ./uninstall.sh --keep-config leave the config file alone
|
||||
#
|
||||
# The Rust toolchain, tmux, socktop and friends, and your 'input' group
|
||||
# membership are all left in place: they are useful independently, and removing
|
||||
# them could break something else. What was skipped is printed at the end.
|
||||
set -eu
|
||||
|
||||
PREFIX=${PREFIX:-/usr/local}
|
||||
BIN="$PREFIX/bin"
|
||||
CONF="$PREFIX/etc/socktop-swipe.env"
|
||||
XCONF=/etc/X11/xorg.conf.d/99-ignore-touch-socktop-swipe.conf
|
||||
BCONF=/etc/X11/xorg.conf.d/10-no-blanking-socktop-swipe.conf
|
||||
LCONF=/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf
|
||||
BIN="$PREFIX/bin/socktop-swipe"
|
||||
CONF_DIR="$HOME/.config/socktop-swipe"
|
||||
CONF="$CONF_DIR/config.yaml"
|
||||
UDEV=/etc/udev/rules.d/70-socktop-swipe.rules
|
||||
XBLANK=/etc/X11/xorg.conf.d/10-no-blanking-socktop-swipe.conf
|
||||
XIGNORE=/etc/X11/xorg.conf.d/99-ignore-touch-socktop-swipe.conf
|
||||
LIGHTDM=/etc/lightdm/lightdm.conf.d/50-autologin-socktop-swipe.conf
|
||||
UNIT="$HOME/.config/systemd/user/socktop-swipe.service"
|
||||
|
||||
keep_config=no
|
||||
[ "${1:-}" = "--keep-config" ] && keep_config=yes
|
||||
for a in "$@"; do
|
||||
case "$a" in
|
||||
--keep-config) keep_config=yes ;;
|
||||
-h | --help) sed -n '2,10p' "$0" | sed 's/^# \{0,1\}//'; exit 0 ;;
|
||||
*) echo "unknown option: $a" >&2; exit 2 ;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Stop anything running. -x matches the exact process name; -f would also match
|
||||
# this script's own command line and kill the shell running it.
|
||||
pkill -x lisgd 2>/dev/null || true
|
||||
say() { printf '%s\n' "$*"; }
|
||||
|
||||
for f in socktop-rack socktop-swipe socktop-gestures; do
|
||||
if [ -e "$BIN/$f" ]; then
|
||||
sudo rm -f "$BIN/$f"
|
||||
echo "removed $BIN/$f"
|
||||
# Stop it first, so nothing is holding the touch device or the tmux session.
|
||||
if command -v systemctl >/dev/null 2>&1 && [ -e "$UNIT" ]; then
|
||||
systemctl --user disable --now socktop-swipe 2>/dev/null || true
|
||||
rm -f "$UNIT"
|
||||
systemctl --user daemon-reload 2>/dev/null || true
|
||||
say "removed $UNIT"
|
||||
fi
|
||||
pkill -x socktop-swipe 2>/dev/null || true
|
||||
|
||||
# Session name comes from the config, which may be about to be deleted.
|
||||
if [ -r "$CONF" ] && command -v tmux >/dev/null 2>&1; then
|
||||
session=$(sed -n 's/^session:[[:space:]]*//p' "$CONF" | head -1)
|
||||
[ -n "${session:-}" ] || session=socktop-swipe
|
||||
tmux kill-session -t "$session" 2>/dev/null || true
|
||||
fi
|
||||
|
||||
for f in "$BIN" "$UDEV" "$XBLANK" "$XIGNORE" "$LIGHTDM"; do
|
||||
if [ -e "$f" ]; then
|
||||
sudo rm -f "$f"
|
||||
say "removed $f"
|
||||
fi
|
||||
done
|
||||
|
||||
if [ -e "$UDEV" ] || command -v udevadm >/dev/null 2>&1; then
|
||||
sudo udevadm control --reload 2>/dev/null || true
|
||||
fi
|
||||
|
||||
if [ "$keep_config" = no ] && [ -e "$CONF" ]; then
|
||||
sudo rm -f "$CONF"
|
||||
echo "removed $CONF"
|
||||
rm -f "$CONF"
|
||||
rmdir "$CONF_DIR" 2>/dev/null || true
|
||||
say "removed $CONF"
|
||||
fi
|
||||
|
||||
if [ -e "$XCONF" ]; then
|
||||
sudo rm -f "$XCONF"
|
||||
echo "removed $XCONF (touch returns to X after the next X restart)"
|
||||
fi
|
||||
|
||||
if [ -e "$BCONF" ]; then
|
||||
sudo rm -f "$BCONF"
|
||||
echo "removed $BCONF (screen blanking returns after the next X restart)"
|
||||
fi
|
||||
|
||||
if [ -e "$LCONF" ]; then
|
||||
sudo rm -f "$LCONF"
|
||||
echo "removed $LCONF (login prompt returns at next boot)"
|
||||
fi
|
||||
|
||||
echo
|
||||
echo "Left in place on purpose:"
|
||||
echo " * lisgd -- remove with: sudo make -C ~/src/lisgd uninstall"
|
||||
echo " * 'input' group -- remove with: sudo gpasswd -d $(id -un) input"
|
||||
echo " * i3 autostart -- delete the 'socktop-swipe' block in ~/.config/i3/config"
|
||||
echo " * socktop profiles -- ~/.config/socktop/profiles.json"
|
||||
say ""
|
||||
say "Left in place on purpose:"
|
||||
say " * the Rust toolchain -- remove with: rustup self uninstall"
|
||||
say " * tmux -- your package manager installed it"
|
||||
say " * socktop and friends -- cargo uninstall socktop uptime-kuma-status"
|
||||
say " * the source checkout -- rm -rf ~/.local/src/socktop-swipe"
|
||||
say " * 'input' group -- sudo gpasswd -d $(id -un) input"
|
||||
say " * i3 autostart -- delete the 'socktop-swipe' block in ~/.config/i3/config"
|
||||
say ""
|
||||
say "Screen blanking and the login prompt come back after the next X restart"
|
||||
say "or reboot."
|
||||
|
||||
Reference in New Issue
Block a user