Rust core: YAML grid config, navigation state machine, evdev input
Replaces the three shell scripts' logic with one binary. tmux stays the pane engine; src/session/ is the only module that knows that. The grid model: coordinates are sparse ordinals, so only their sort order matters and a socktop group is one cell however many hosts it holds. Horizontal movement walks a cell's sub-sequence (tiled overview, then each host zoomed) and leaves only after the last one; entry direction decides whether you land on the first or last sub-screen. Vertical movement returns to where you were in that row, and snaps to the nearest column only on the first visit. Notable details found while building: * Unquoted "at: 0x0" is hexadecimal 0 to YAML, and "1x0" is not valid hex, so only the row-0 entries would break. Deserialization catches the integer case and names the fix. * Panes are addressed by tmux id, never index, and each command is wrapped so the pane outlives it. v1's remain-on-exit cannot do this: it is a per-window option that new windows do not inherit, so a monitor that exits during construction destroys its window and the next split fails with "no current target". Now a dead monitor stays on screen with its status. * Contact-count averaging, not summing: a panel reporting one finger as three contacts must not look like three times the travel. * Movement subcommands wait for the move to happen and report where they landed, so they are scriptable rather than fire-and-forget. 36 tests: the grid model, the gesture classifier and an end-to-end pass against a real tmux server. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1 @@
|
|||||||
|
/target
|
||||||
Generated
+437
@@ -0,0 +1,437 @@
|
|||||||
|
# This file is automatically @generated by Cargo.
|
||||||
|
# It is not intended for manual editing.
|
||||||
|
version = 3
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstream"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "824a212faf96e9acacdbd09febd34438f8f711fb84e09a8916013cd7815ca28d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"anstyle-parse",
|
||||||
|
"anstyle-query",
|
||||||
|
"anstyle-wincon",
|
||||||
|
"colorchoice",
|
||||||
|
"is_terminal_polyfill",
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle"
|
||||||
|
version = "1.0.14"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "940b3a0ca603d1eade50a4846a2afffd5ef57a9feac2c0e2ec2e14f9ead76000"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-parse"
|
||||||
|
version = "1.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "52ce7f38b242319f7cabaa6813055467063ecdc9d355bbb4ce0c68908cd8130e"
|
||||||
|
dependencies = [
|
||||||
|
"utf8parse",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-query"
|
||||||
|
version = "1.1.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anstyle-wincon"
|
||||||
|
version = "3.0.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d"
|
||||||
|
dependencies = [
|
||||||
|
"anstyle",
|
||||||
|
"once_cell_polyfill",
|
||||||
|
"windows-sys",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "anyhow"
|
||||||
|
version = "1.0.104"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "autocfg"
|
||||||
|
version = "1.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f2032f911046de80f0a198e0901378627c33f59ea0ac00e363d481118bd70a53"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitflags"
|
||||||
|
version = "1.3.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "bitvec"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ddcec3d12c579d40898fe0a9a358a803c23e9c52ca3c425707f81c9436211837"
|
||||||
|
dependencies = [
|
||||||
|
"funty",
|
||||||
|
"radium",
|
||||||
|
"tap",
|
||||||
|
"wyz",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cc"
|
||||||
|
version = "1.4.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "005ec2760ca554fae18df7a11195552ec576cd665632a881bc011d5bb2fd4d80"
|
||||||
|
dependencies = [
|
||||||
|
"find-msvc-tools",
|
||||||
|
"shlex",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "cfg-if"
|
||||||
|
version = "1.0.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap"
|
||||||
|
version = "4.6.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "473c7e07f409a8d772161724aa8db6a765a2532a70f9667eeb7b49d3d02fbdca"
|
||||||
|
dependencies = [
|
||||||
|
"clap_builder",
|
||||||
|
"clap_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_builder"
|
||||||
|
version = "4.6.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7b48fea5a88e9ae728a2dcbedbfc0e730f7d60da42e1cb049a83c9fb8b789889"
|
||||||
|
dependencies = [
|
||||||
|
"anstream",
|
||||||
|
"anstyle",
|
||||||
|
"clap_lex",
|
||||||
|
"strsim",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_derive"
|
||||||
|
version = "4.6.4"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d012d2b9d65aca7f18f4d9878a045bc17899bba951561ba5ec3c2ba1eed9a061"
|
||||||
|
dependencies = [
|
||||||
|
"heck",
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "clap_lex"
|
||||||
|
version = "1.1.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c8d4a3bb8b1e0c1050499d1815f5ab16d04f0959b233085fb31653fbfc9d98f9"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "colorchoice"
|
||||||
|
version = "1.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "equivalent"
|
||||||
|
version = "1.0.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "evdev"
|
||||||
|
version = "0.12.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ab6055a93a963297befb0f4f6e18f314aec9767a4bbe88b151126df2433610a7"
|
||||||
|
dependencies = [
|
||||||
|
"bitvec",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"nix",
|
||||||
|
"thiserror",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "find-msvc-tools"
|
||||||
|
version = "0.1.12"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3e0f1c7c3a72c66fd80abe965175f7523475c0489a87d3ff9d6e8c87d87a9d2d"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "funty"
|
||||||
|
version = "2.0.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6d5a32815ae3f33302d95fdcb2ce17862f8c65363dcfd29360480ba1001fc9c"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "hashbrown"
|
||||||
|
version = "0.17.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "heck"
|
||||||
|
version = "0.5.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "indexmap"
|
||||||
|
version = "2.14.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "cc4e190f5d26ca7051642629da2c52fc03bde85a03197c99408dcd291734c855"
|
||||||
|
dependencies = [
|
||||||
|
"equivalent",
|
||||||
|
"hashbrown",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "is_terminal_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "a6cb138bb79a146c1bd460005623e142ef0181e3d0219cb493e02f7d08a35695"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "itoa"
|
||||||
|
version = "1.0.18"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "libc"
|
||||||
|
version = "0.2.189"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "3eaf3ede3fee6db1a4c2ee091bf8a8b4dccdc6d17f656fb07896ee72867612f2"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "memoffset"
|
||||||
|
version = "0.6.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "5aa361d4faea93603064a027415f07bd8e1d5c88c9fbf68bf56a285428fd79ce"
|
||||||
|
dependencies = [
|
||||||
|
"autocfg",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "nix"
|
||||||
|
version = "0.23.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "8f3790c00a0150112de0f4cd161e3d7fc4b2d8a5542ffc35f099a2562aecb35c"
|
||||||
|
dependencies = [
|
||||||
|
"bitflags",
|
||||||
|
"cc",
|
||||||
|
"cfg-if",
|
||||||
|
"libc",
|
||||||
|
"memoffset",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "once_cell_polyfill"
|
||||||
|
version = "1.70.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "384b8ab6d37215f3c5301a95a4accb5d64aa607f1fcb26a11b5303878451b4fe"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "proc-macro2"
|
||||||
|
version = "1.0.107"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "985e7ec9bb745e6ce6535b544d84d6cd6f7ad8bd711c398938ae983b91a766d9"
|
||||||
|
dependencies = [
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "quote"
|
||||||
|
version = "1.0.47"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "1fbf4db142a473a8d80c26bbf18454ed458bf8d26c8219c331daecfdbd079001"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "radium"
|
||||||
|
version = "0.7.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc33ff2d4973d518d823d61aa239014831e521c75da58e3df4840d3f47749d09"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ryu"
|
||||||
|
version = "1.0.23"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "9774ba4a74de5f7b1c1451ed6cd5285a32eddb5cccb8cc655a4e50009e06477f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4148590afebada386688f18773da617792bf2ef03ffc1e4cbd2b1d45b023e0ba"
|
||||||
|
dependencies = [
|
||||||
|
"serde_core",
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_core"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "67dca2c9c51e58a4791a4b1ed58308b39c64224d349a935ab5039aa360942a48"
|
||||||
|
dependencies = [
|
||||||
|
"serde_derive",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_derive"
|
||||||
|
version = "1.0.229"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e7a5d71263a5a7d47b41f6b3f06ba276f10cc18b0931f1799f710578e2309348"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 3.0.5",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "serde_yaml"
|
||||||
|
version = "0.9.34+deprecated"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "6a8b1a1a2ebf674015cc02edccce75287f1a0130d394307b36743c2f5d504b47"
|
||||||
|
dependencies = [
|
||||||
|
"indexmap",
|
||||||
|
"itoa",
|
||||||
|
"ryu",
|
||||||
|
"serde",
|
||||||
|
"unsafe-libyaml",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shell-words"
|
||||||
|
version = "1.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "dc6fe69c597f9c37bfeeeeeb33da3530379845f10be461a66d16d03eca2ded77"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "shlex"
|
||||||
|
version = "2.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8fadd59c855ef2080decdef8ff161eb6661b86933c9d82e5ba29dc602a55aba"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "socktop-swipe"
|
||||||
|
version = "2.0.0-dev"
|
||||||
|
dependencies = [
|
||||||
|
"anyhow",
|
||||||
|
"clap",
|
||||||
|
"evdev",
|
||||||
|
"serde",
|
||||||
|
"serde_yaml",
|
||||||
|
"shell-words",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "strsim"
|
||||||
|
version = "0.11.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "2.0.119"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "872831b642d1a07999a962a351ed35b955ea2cfc8f3862091e2a240a84f17297"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "syn"
|
||||||
|
version = "3.0.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "12df2e0110f65b775f769bb17ef989067a1d931b2eb822bd4346631eeada89f9"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"unicode-ident",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "tap"
|
||||||
|
version = "1.0.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "55937e1799185b12863d447f42597ed69d9928686b8d88a1df17376a097d8369"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b6aaf5339b578ea85b50e080feb250a3e8ae8cfcdff9a461c9ec2904bc923f52"
|
||||||
|
dependencies = [
|
||||||
|
"thiserror-impl",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "thiserror-impl"
|
||||||
|
version = "1.0.69"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4fee6c4efc90059e10f81e6d42c60a18f76588c3d74cb83a0b242a2b6c7504c1"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn 2.0.119",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unicode-ident"
|
||||||
|
version = "1.0.24"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "e6e4313cd5fcd3dad5cafa179702e2b244f760991f45397d14d4ebf38247da75"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "unsafe-libyaml"
|
||||||
|
version = "0.2.11"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "673aac59facbab8a9007c7f6108d11f63b603f7cabff99fabf650fea5c32b861"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "utf8parse"
|
||||||
|
version = "0.2.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-link"
|
||||||
|
version = "0.2.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "windows-sys"
|
||||||
|
version = "0.61.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ae137229bcbd6cdf0f7b80a31df61766145077ddf49416a728b02cb3921ff3fc"
|
||||||
|
dependencies = [
|
||||||
|
"windows-link",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "wyz"
|
||||||
|
version = "0.5.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "05f360fc0b24296329c78fda852a1e9ae82de9cf7b27dae4b7f62f118f77b9ed"
|
||||||
|
dependencies = [
|
||||||
|
"tap",
|
||||||
|
]
|
||||||
+55
@@ -0,0 +1,55 @@
|
|||||||
|
[package]
|
||||||
|
name = "socktop-swipe"
|
||||||
|
version = "2.0.0-dev"
|
||||||
|
edition = "2021"
|
||||||
|
rust-version = "1.82"
|
||||||
|
description = "Swipe-to-zoom touchscreen navigation for a wall-mounted socktop dashboard"
|
||||||
|
repository = "https://gt.wittyoneoff.com/jason/socktop-swipe"
|
||||||
|
license = "Apache-2.0"
|
||||||
|
readme = "README.md"
|
||||||
|
keywords = ["tui", "touchscreen", "tmux", "dashboard", "socktop"]
|
||||||
|
categories = ["command-line-utilities"]
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
anyhow = "1"
|
||||||
|
clap = { version = "4", features = ["derive"] }
|
||||||
|
evdev = "0.12"
|
||||||
|
serde = { version = "1", features = ["derive"] }
|
||||||
|
serde_yaml = "0.9"
|
||||||
|
shell-words = "1"
|
||||||
|
|
||||||
|
[profile.release]
|
||||||
|
opt-level = "s"
|
||||||
|
lto = true
|
||||||
|
codegen-units = 1
|
||||||
|
strip = true
|
||||||
|
panic = "abort"
|
||||||
|
|
||||||
|
# `cargo deb` produces a package with these paths. Not used yet -- see
|
||||||
|
# notes/PLAN-v2.md, distribution is source-only for v2.0.
|
||||||
|
[package.metadata.deb]
|
||||||
|
maintainer = "Jason Witty"
|
||||||
|
depends = "tmux"
|
||||||
|
recommends = "alacritty"
|
||||||
|
extended-description = """\
|
||||||
|
Drives a grid of terminal dashboards on a touchscreen. Swipe between socktop \
|
||||||
|
hosts, an Uptime Kuma status page, a unifly UniFi TUI or any command you like. \
|
||||||
|
Reads the touch panel directly via evdev and lays the screens out with tmux."""
|
||||||
|
assets = [
|
||||||
|
["target/release/socktop-swipe", "usr/bin/", "755"],
|
||||||
|
["config.example.yaml", "usr/share/doc/socktop-swipe/", "644"],
|
||||||
|
["README.md", "usr/share/doc/socktop-swipe/", "644"],
|
||||||
|
["packaging/70-socktop-swipe.rules", "lib/udev/rules.d/", "644"],
|
||||||
|
]
|
||||||
|
|
||||||
|
[lib]
|
||||||
|
name = "socktop_swipe"
|
||||||
|
path = "src/lib.rs"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "socktop-swipe"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dev-dependencies]
|
||||||
|
shell-words = "1"
|
||||||
|
serde_yaml = "0.9"
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
# socktop-swipe configuration.
|
||||||
|
#
|
||||||
|
# Copy to ~/.config/socktop-swipe/config.yaml and edit. After every change run
|
||||||
|
#
|
||||||
|
# socktop-swipe validate
|
||||||
|
#
|
||||||
|
# which resolves the grid, prints the map and checks that every program it would
|
||||||
|
# run actually exists.
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Session
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
# tmux session name. Change only if it collides with something else.
|
||||||
|
session: socktop-swipe
|
||||||
|
|
||||||
|
# Terminal that `socktop-swipe run` opens the dashboard in. It is invoked as
|
||||||
|
# <terminal> -e tmux attach -t <session>
|
||||||
|
# Remove this line to attach in the current terminal instead.
|
||||||
|
terminal: alacritty
|
||||||
|
|
||||||
|
# Show a position indicator in the tmux status line: what is above, where you
|
||||||
|
# are, what is below. Useful once the grid has more than one row.
|
||||||
|
indicator: false
|
||||||
|
|
||||||
|
# Full paths to the monitor programs. i3 and non-login shells do not have
|
||||||
|
# ~/.cargo/bin on PATH, so setting them here saves repeating a path on every
|
||||||
|
# screen. A bare name is looked up on PATH. Leading ~/ is expanded.
|
||||||
|
binaries:
|
||||||
|
socktop: ~/.cargo/bin/socktop
|
||||||
|
uptime-kuma-status: ~/.cargo/bin/uptime-kuma-status
|
||||||
|
unifly: ~/Documents/GitHub/unifly/target/release-small/unifly
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Touch panel
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
touch:
|
||||||
|
# ALWAYS a /dev/input/by-id/ path -- eventN numbers get reshuffled on reboot
|
||||||
|
# or USB re-enumeration. `socktop-swipe doctor --list` shows the candidates.
|
||||||
|
device: /dev/input/by-id/usb-ILITEK_ILITEK-TOUCH-event-if00
|
||||||
|
|
||||||
|
# The PANEL's resolution, not the X screen. With a second monitor attached X
|
||||||
|
# reports the combined root window (e.g. 2304x720), which is not this.
|
||||||
|
width: 1280
|
||||||
|
height: 720
|
||||||
|
|
||||||
|
# Take the panel exclusively, so X never sees the touches. Without this, one
|
||||||
|
# swipe reaches several consumers at once: the terminal reads 2-contact swipes
|
||||||
|
# as pinch-zoom and socktop turns on its own all-motion mouse reporting. Set
|
||||||
|
# false only if you also want touch to work in other applications, and then
|
||||||
|
# install the X ignore rule instead -- see the README.
|
||||||
|
grab: true
|
||||||
|
|
||||||
|
# Pixels of travel before a drag counts as a swipe. Measured on the ILITEK
|
||||||
|
# panel: real swipes land at 150-260px, accidental contact under 70px.
|
||||||
|
threshold: 80
|
||||||
|
|
||||||
|
# Degrees off-axis tolerated, maximum 45. Real finger swipes came in 1-25
|
||||||
|
# degrees off true horizontal, so 30 leaves comfortable margin.
|
||||||
|
leniency: 30
|
||||||
|
|
||||||
|
# Contact counts accepted for one logical swipe.
|
||||||
|
#
|
||||||
|
# THE SETTING MOST LIKELY TO NEED CHANGING ON NEW HARDWARE. Many multipoint
|
||||||
|
# panels report 2 or even 3 contacts for a physically one-finger swipe. If
|
||||||
|
# swipes are detected but nothing happens, `socktop-swipe doctor` will say so
|
||||||
|
# in as many words.
|
||||||
|
fingers: [1, 2, 3]
|
||||||
|
|
||||||
|
# Swipe directions, named by finger motion. RL is right-to-left.
|
||||||
|
gestures:
|
||||||
|
forward: RL # move deeper into the grid
|
||||||
|
back: LR
|
||||||
|
up: DU
|
||||||
|
down: UD
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# The grid
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
#
|
||||||
|
# Each screen sits at "<row>x<col>". Row increases DOWNWARD, so "-1x0" is above
|
||||||
|
# "0x0" and "1x0" is below it. You start at "0x0".
|
||||||
|
#
|
||||||
|
# QUOTE THE COORDINATE. Unquoted, YAML reads 0x0 as the hexadecimal number 0.
|
||||||
|
#
|
||||||
|
# Coordinates are ordering, not slots: only their sort order matters, so "0x1"
|
||||||
|
# and "0x5" are interchangeable. You never have to count how many screens a
|
||||||
|
# socktop group produces in order to place the thing next to it.
|
||||||
|
#
|
||||||
|
# This example is the rack display:
|
||||||
|
#
|
||||||
|
# unifly -1x0
|
||||||
|
# |
|
||||||
|
# 4 Pis <-> orangepi + trixie 0x0, 0x1
|
||||||
|
# |
|
||||||
|
# uptime kuma 1x0
|
||||||
|
|
||||||
|
screens:
|
||||||
|
# Swipe UP from anywhere in row 0 to reach this.
|
||||||
|
- at: "-1x0"
|
||||||
|
type: unifly
|
||||||
|
|
||||||
|
# One cell, five screens: the four Pis tiled, then each Pi zoomed in turn.
|
||||||
|
- at: "0x0"
|
||||||
|
type: socktop
|
||||||
|
socktop_group: [rpi-master, rpi-worker-1, rpi-worker-2, rpi-worker-3]
|
||||||
|
layout: tiled
|
||||||
|
|
||||||
|
# Swipe forward past the last Pi to arrive here. socktop_group also accepts a
|
||||||
|
# comma-separated string, and layout takes any tmux layout name.
|
||||||
|
- at: "0x1"
|
||||||
|
type: socktop
|
||||||
|
socktop_group: "orangepi, trixie"
|
||||||
|
layout: even-vertical
|
||||||
|
|
||||||
|
# Swipe DOWN from row 0. Swiping back up returns to the exact host you left.
|
||||||
|
- at: "1x0"
|
||||||
|
type: uptime-kuma-status
|
||||||
|
url: https://status.wittyoneoff.com/status/wittyoneoff
|
||||||
|
|
||||||
|
# Anything else you want on a screen. `command` works on any type, as an
|
||||||
|
# override; for `generic` it is the whole definition.
|
||||||
|
#
|
||||||
|
# - at: "1x1"
|
||||||
|
# type: generic
|
||||||
|
# title: logs
|
||||||
|
# command: journalctl -f -u k3s-agent
|
||||||
@@ -0,0 +1,143 @@
|
|||||||
|
//! Grid coordinates: `"<row>x<col>"`, e.g. `"0x0"`, `"-1x0"`, `"1x-2"`.
|
||||||
|
//!
|
||||||
|
//! Row increases *downward* (`-1x0` is above `0x0`); column increases rightward.
|
||||||
|
//! Coordinates are sparse ordinals -- only their ordering matters, so `0x1` and
|
||||||
|
//! `0x5` are interchangeable as long as they sort the same way. See
|
||||||
|
//! notes/PLAN-v2.md section 2.
|
||||||
|
|
||||||
|
use std::fmt;
|
||||||
|
|
||||||
|
use serde::de::{self, Deserializer, Visitor};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
|
||||||
|
pub struct Coord {
|
||||||
|
/// Sorts first, and ascending row means descending on screen.
|
||||||
|
pub row: i32,
|
||||||
|
pub col: i32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Coord {
|
||||||
|
pub fn new(row: i32, col: i32) -> Self {
|
||||||
|
Self { row, col }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A tmux-safe window name. tmux treats `:` and `.` as target separators, so
|
||||||
|
/// negative coordinates use `m` ("minus") rather than a sign character.
|
||||||
|
pub fn window_name(&self) -> String {
|
||||||
|
fn part(prefix: char, v: i32) -> String {
|
||||||
|
if v < 0 {
|
||||||
|
format!("{prefix}m{}", v.unsigned_abs())
|
||||||
|
} else {
|
||||||
|
format!("{prefix}{v}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
format!("{}{}", part('r', self.row), part('c', self.col))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for Coord {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
write!(f, "{}x{}", self.row, self.col)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::str::FromStr for Coord {
|
||||||
|
type Err = String;
|
||||||
|
|
||||||
|
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||||
|
let t = s.trim();
|
||||||
|
// Split on the separator 'x', which cannot be part of either number.
|
||||||
|
let (row, col) = t
|
||||||
|
.split_once('x')
|
||||||
|
.ok_or_else(|| format!("{t:?} is not a coordinate -- expected \"<row>x<col>\", e.g. \"0x0\" or \"-1x0\""))?;
|
||||||
|
let parse = |part: &str, which: &str| -> Result<i32, String> {
|
||||||
|
part.trim().parse::<i32>().map_err(|_| {
|
||||||
|
format!("{t:?} is not a coordinate -- the {which} {part:?} is not a whole number")
|
||||||
|
})
|
||||||
|
};
|
||||||
|
Ok(Coord::new(parse(row, "row")?, parse(col, "column")?))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for Coord {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
struct V;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for V {
|
||||||
|
type Value = Coord;
|
||||||
|
|
||||||
|
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
f.write_str("a quoted coordinate such as \"0x0\" or \"-1x0\"")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<Coord, E> {
|
||||||
|
v.parse().map_err(de::Error::custom)
|
||||||
|
}
|
||||||
|
|
||||||
|
// YAML reads an unquoted `0x0` as the HEXADECIMAL number 0, so the
|
||||||
|
// coordinate never reaches us as a string at all. `1x0` is not valid
|
||||||
|
// hex and does arrive as a string, which makes the failure look
|
||||||
|
// arbitrary -- only the row-0 entries break. Say exactly that.
|
||||||
|
fn visit_i64<E: de::Error>(self, v: i64) -> Result<Coord, E> {
|
||||||
|
Err(de::Error::custom(format!(
|
||||||
|
"YAML read this coordinate as the hexadecimal number {v}, not as text. \
|
||||||
|
Unquoted `0x0` is hex for 0. Quote it: at: \"0x0\""
|
||||||
|
)))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_u64<E: de::Error>(self, v: u64) -> Result<Coord, E> {
|
||||||
|
self.visit_i64(v as i64)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.deserialize_any(V)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn parses_signed_coordinates() {
|
||||||
|
assert_eq!("0x0".parse::<Coord>().unwrap(), Coord::new(0, 0));
|
||||||
|
assert_eq!("-1x0".parse::<Coord>().unwrap(), Coord::new(-1, 0));
|
||||||
|
assert_eq!("1x-2".parse::<Coord>().unwrap(), Coord::new(1, -2));
|
||||||
|
assert_eq!("-3x-4".parse::<Coord>().unwrap(), Coord::new(-3, -4));
|
||||||
|
assert_eq!(" 2x7 ".parse::<Coord>().unwrap(), Coord::new(2, 7));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rejects_malformed_coordinates() {
|
||||||
|
for bad in ["", "0", "0x", "x0", "0x0x0", "axb", "0.5x1"] {
|
||||||
|
assert!(bad.parse::<Coord>().is_err(), "{bad:?} should not parse");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn window_names_are_tmux_safe() {
|
||||||
|
assert_eq!(Coord::new(0, 0).window_name(), "r0c0");
|
||||||
|
assert_eq!(Coord::new(-1, 0).window_name(), "rm1c0");
|
||||||
|
assert_eq!(Coord::new(1, -2).window_name(), "r1cm2");
|
||||||
|
for c in [Coord::new(0, 0), Coord::new(-1, -1), Coord::new(9, 9)] {
|
||||||
|
let n = c.window_name();
|
||||||
|
assert!(!n.contains(':') && !n.contains('.') && !n.contains('-'), "{n}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unquoted_hex_coordinate_gets_a_useful_error() {
|
||||||
|
// This is what YAML actually hands us for `at: 0x0`.
|
||||||
|
let err = serde_yaml::from_str::<Coord>("0x0").unwrap_err().to_string();
|
||||||
|
assert!(err.contains("hexadecimal"), "unhelpful error: {err}");
|
||||||
|
assert!(err.contains("at: \"0x0\""), "error should show the fix: {err}");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sorts_by_row_then_column() {
|
||||||
|
let mut v = vec![Coord::new(1, 0), Coord::new(-1, 5), Coord::new(0, 2), Coord::new(0, -1)];
|
||||||
|
v.sort();
|
||||||
|
assert_eq!(v, vec![Coord::new(-1, 5), Coord::new(0, -1), Coord::new(0, 2), Coord::new(1, 0)]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,466 @@
|
|||||||
|
//! Config file types, defaults and path resolution.
|
||||||
|
//!
|
||||||
|
//! The screen entry is deliberately a flat struct rather than a tagged enum:
|
||||||
|
//! every monitor type accepts `command:` and `title:` overrides, future types
|
||||||
|
//! add optional fields without restructuring, and validation errors can name
|
||||||
|
//! the offending screen by coordinate instead of surfacing as a serde variant
|
||||||
|
//! mismatch.
|
||||||
|
|
||||||
|
pub mod coord;
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::fmt;
|
||||||
|
use std::path::{Path, PathBuf};
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use serde::de::{self, Deserializer, SeqAccess, Visitor};
|
||||||
|
use serde::Deserialize;
|
||||||
|
|
||||||
|
pub use coord::Coord;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Config {
|
||||||
|
#[serde(default = "default_session")]
|
||||||
|
pub session: String,
|
||||||
|
|
||||||
|
/// Terminal used by the autostart the installer writes, and by `run`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub terminal: Option<String>,
|
||||||
|
|
||||||
|
/// Show a position indicator in the tmux status line.
|
||||||
|
#[serde(default)]
|
||||||
|
pub indicator: bool,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub binaries: Binaries,
|
||||||
|
|
||||||
|
pub touch: Touch,
|
||||||
|
|
||||||
|
#[serde(default)]
|
||||||
|
pub gestures: Gestures,
|
||||||
|
|
||||||
|
pub screens: Vec<Screen>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_session() -> String {
|
||||||
|
"socktop-swipe".into()
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Full paths to the monitor programs. i3 and non-login shells do not have
|
||||||
|
/// `~/.cargo/bin` on PATH, so resolving this in one place stops every screen
|
||||||
|
/// entry from needing an absolute path.
|
||||||
|
#[derive(Debug, Clone, Default, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Binaries {
|
||||||
|
pub socktop: Option<String>,
|
||||||
|
#[serde(rename = "uptime-kuma-status", alias = "uptime_kuma_status")]
|
||||||
|
pub uptime_kuma_status: Option<String>,
|
||||||
|
pub unifly: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Binaries {
|
||||||
|
pub fn get(&self, kind: MonitorType) -> String {
|
||||||
|
let (configured, fallback) = match kind {
|
||||||
|
MonitorType::Socktop => (&self.socktop, "socktop"),
|
||||||
|
MonitorType::UptimeKumaStatus => (&self.uptime_kuma_status, "uptime-kuma-status"),
|
||||||
|
MonitorType::Unifly => (&self.unifly, "unifly"),
|
||||||
|
MonitorType::Generic => (&None, ""),
|
||||||
|
};
|
||||||
|
expand_tilde(configured.as_deref().unwrap_or(fallback))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Touch {
|
||||||
|
pub device: String,
|
||||||
|
|
||||||
|
/// The PANEL's resolution, not the X screen. With a second monitor attached
|
||||||
|
/// X reports the combined root window, which skews edge and distance maths.
|
||||||
|
pub width: u32,
|
||||||
|
pub height: u32,
|
||||||
|
|
||||||
|
/// Take the device exclusively (EVIOCGRAB) so X never sees the touches.
|
||||||
|
#[serde(default = "yes")]
|
||||||
|
pub grab: bool,
|
||||||
|
|
||||||
|
/// Pixels of travel before a drag counts as a swipe.
|
||||||
|
#[serde(default = "default_threshold")]
|
||||||
|
pub threshold: u32,
|
||||||
|
|
||||||
|
/// Degrees off-axis tolerated, max 45.
|
||||||
|
#[serde(default = "default_leniency")]
|
||||||
|
pub leniency: u32,
|
||||||
|
|
||||||
|
/// Contact counts accepted for one logical swipe. Many panels report 2 or 3
|
||||||
|
/// contacts for a physically one-finger swipe.
|
||||||
|
#[serde(default = "default_fingers")]
|
||||||
|
pub fingers: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn yes() -> bool {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
fn default_threshold() -> u32 {
|
||||||
|
80
|
||||||
|
}
|
||||||
|
fn default_leniency() -> u32 {
|
||||||
|
30
|
||||||
|
}
|
||||||
|
fn default_fingers() -> Vec<usize> {
|
||||||
|
vec![1, 2, 3]
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
pub enum Direction {
|
||||||
|
/// Right-to-left finger motion.
|
||||||
|
RL,
|
||||||
|
/// Left-to-right finger motion.
|
||||||
|
LR,
|
||||||
|
/// Down-to-up finger motion.
|
||||||
|
DU,
|
||||||
|
/// Up-to-down finger motion.
|
||||||
|
UD,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Gestures {
|
||||||
|
#[serde(default = "default_forward")]
|
||||||
|
pub forward: Direction,
|
||||||
|
#[serde(default = "default_back")]
|
||||||
|
pub back: Direction,
|
||||||
|
#[serde(default = "default_up")]
|
||||||
|
pub up: Direction,
|
||||||
|
#[serde(default = "default_down")]
|
||||||
|
pub down: Direction,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn default_forward() -> Direction {
|
||||||
|
Direction::RL
|
||||||
|
}
|
||||||
|
fn default_back() -> Direction {
|
||||||
|
Direction::LR
|
||||||
|
}
|
||||||
|
fn default_up() -> Direction {
|
||||||
|
Direction::DU
|
||||||
|
}
|
||||||
|
fn default_down() -> Direction {
|
||||||
|
Direction::UD
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for Gestures {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
forward: default_forward(),
|
||||||
|
back: default_back(),
|
||||||
|
up: default_up(),
|
||||||
|
down: default_down(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum MonitorType {
|
||||||
|
Socktop,
|
||||||
|
#[serde(alias = "uptime_kuma_status", alias = "kuma")]
|
||||||
|
UptimeKumaStatus,
|
||||||
|
Unifly,
|
||||||
|
Generic,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl fmt::Display for MonitorType {
|
||||||
|
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||||
|
f.write_str(match self {
|
||||||
|
Self::Socktop => "socktop",
|
||||||
|
Self::UptimeKumaStatus => "uptime-kuma-status",
|
||||||
|
Self::Unifly => "unifly",
|
||||||
|
Self::Generic => "generic",
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tmux layout for a cell's overview.
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
|
||||||
|
#[serde(rename_all = "kebab-case")]
|
||||||
|
pub enum Layout {
|
||||||
|
#[default]
|
||||||
|
Tiled,
|
||||||
|
EvenHorizontal,
|
||||||
|
EvenVertical,
|
||||||
|
MainHorizontal,
|
||||||
|
MainVertical,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Layout {
|
||||||
|
pub fn as_tmux(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Self::Tiled => "tiled",
|
||||||
|
Self::EvenHorizontal => "even-horizontal",
|
||||||
|
Self::EvenVertical => "even-vertical",
|
||||||
|
Self::MainHorizontal => "main-horizontal",
|
||||||
|
Self::MainVertical => "main-vertical",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A list of socktop profile names, written either as a YAML sequence or as a
|
||||||
|
/// comma-separated string.
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub struct HostList(pub Vec<String>);
|
||||||
|
|
||||||
|
impl<'de> Deserialize<'de> for HostList {
|
||||||
|
fn deserialize<D: Deserializer<'de>>(d: D) -> Result<Self, D::Error> {
|
||||||
|
struct V;
|
||||||
|
|
||||||
|
impl<'de> Visitor<'de> for V {
|
||||||
|
type Value = HostList;
|
||||||
|
|
||||||
|
fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
|
||||||
|
f.write_str("a list of host names, or a comma-separated string")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_str<E: de::Error>(self, v: &str) -> Result<HostList, E> {
|
||||||
|
Ok(HostList(
|
||||||
|
v.split(',')
|
||||||
|
.map(str::trim)
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect(),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn visit_seq<A: SeqAccess<'de>>(self, mut seq: A) -> Result<HostList, A::Error> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
while let Some(s) = seq.next_element::<String>()? {
|
||||||
|
let s = s.trim().to_owned();
|
||||||
|
if !s.is_empty() {
|
||||||
|
out.push(s);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(HostList(out))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
d.deserialize_any(V)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Deserialize)]
|
||||||
|
#[serde(deny_unknown_fields)]
|
||||||
|
pub struct Screen {
|
||||||
|
pub at: Coord,
|
||||||
|
|
||||||
|
#[serde(rename = "type")]
|
||||||
|
pub kind: MonitorType,
|
||||||
|
|
||||||
|
// -- socktop ---------------------------------------------------------
|
||||||
|
#[serde(default)]
|
||||||
|
pub socktop_group: Option<HostList>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub layout: Option<Layout>,
|
||||||
|
|
||||||
|
// -- uptime-kuma-status ----------------------------------------------
|
||||||
|
#[serde(default)]
|
||||||
|
pub url: Option<String>,
|
||||||
|
|
||||||
|
// -- unifly: no parameters yet. `site` and `controller` are carved out
|
||||||
|
// here so adding them later is not a breaking config change.
|
||||||
|
#[serde(default)]
|
||||||
|
pub site: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub controller: Option<String>,
|
||||||
|
|
||||||
|
// -- any type ---------------------------------------------------------
|
||||||
|
/// Replaces the generated command outright. Required for `generic`.
|
||||||
|
#[serde(default)]
|
||||||
|
pub command: Option<String>,
|
||||||
|
/// Extra arguments appended to the generated command.
|
||||||
|
#[serde(default)]
|
||||||
|
pub args: Option<Vec<String>>,
|
||||||
|
/// Overrides the pane border label.
|
||||||
|
#[serde(default)]
|
||||||
|
pub title: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Config {
|
||||||
|
/// Search order: `--config`, then XDG, then `/etc`.
|
||||||
|
pub fn search_paths() -> Vec<PathBuf> {
|
||||||
|
let mut v = Vec::new();
|
||||||
|
if let Some(dir) = std::env::var_os("XDG_CONFIG_HOME")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.filter(|p| p.is_absolute())
|
||||||
|
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".config")))
|
||||||
|
{
|
||||||
|
v.push(dir.join("socktop-swipe/config.yaml"));
|
||||||
|
}
|
||||||
|
v.push(PathBuf::from("/etc/socktop-swipe/config.yaml"));
|
||||||
|
v
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn locate(explicit: Option<&Path>) -> Result<PathBuf> {
|
||||||
|
if let Some(p) = explicit {
|
||||||
|
if !p.exists() {
|
||||||
|
bail!("no config file at {}", p.display());
|
||||||
|
}
|
||||||
|
return Ok(p.to_path_buf());
|
||||||
|
}
|
||||||
|
let searched = Self::search_paths();
|
||||||
|
searched
|
||||||
|
.iter()
|
||||||
|
.find(|p| p.exists())
|
||||||
|
.cloned()
|
||||||
|
.with_context(|| {
|
||||||
|
format!(
|
||||||
|
"no config file found. Looked in:\n{}\n\nWrite one there, or pass --config <path>. \
|
||||||
|
A commented starting point ships as config.example.yaml.",
|
||||||
|
searched
|
||||||
|
.iter()
|
||||||
|
.map(|p| format!(" {}", p.display()))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load(path: &Path) -> Result<Self> {
|
||||||
|
let text = std::fs::read_to_string(path)
|
||||||
|
.with_context(|| format!("cannot read {}", path.display()))?;
|
||||||
|
let cfg: Config = serde_yaml::from_str(&text)
|
||||||
|
.with_context(|| format!("cannot parse {}", path.display()))?;
|
||||||
|
cfg.check()?;
|
||||||
|
Ok(cfg)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Structural checks that serde cannot express. Reports every problem it
|
||||||
|
/// finds rather than only the first, so one `validate` run is enough.
|
||||||
|
fn check(&self) -> Result<()> {
|
||||||
|
let mut errs: Vec<String> = Vec::new();
|
||||||
|
|
||||||
|
if self.screens.is_empty() {
|
||||||
|
errs.push("no screens defined -- the grid would be empty".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut seen: BTreeSet<Coord> = BTreeSet::new();
|
||||||
|
for s in &self.screens {
|
||||||
|
let at = s.at;
|
||||||
|
if !seen.insert(at) {
|
||||||
|
errs.push(format!("two screens both claim {at}"));
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut wrong = |msg: String| errs.push(format!("screen {at} ({}): {msg}", s.kind));
|
||||||
|
|
||||||
|
match s.kind {
|
||||||
|
MonitorType::Socktop => {
|
||||||
|
match &s.socktop_group {
|
||||||
|
None if s.command.is_none() => wrong(
|
||||||
|
"needs socktop_group -- a list of socktop profile names, \
|
||||||
|
or a comma-separated string"
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
Some(g) if g.0.is_empty() => {
|
||||||
|
wrong("socktop_group is empty".into())
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
if s.url.is_some() {
|
||||||
|
wrong("url is not a socktop parameter".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MonitorType::UptimeKumaStatus => {
|
||||||
|
if s.url.is_none() && s.command.is_none() {
|
||||||
|
wrong("needs url -- the public Uptime Kuma status page to render".into());
|
||||||
|
}
|
||||||
|
if s.socktop_group.is_some() {
|
||||||
|
wrong("socktop_group is not an uptime-kuma-status parameter".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MonitorType::Unifly => {
|
||||||
|
if s.socktop_group.is_some() {
|
||||||
|
wrong("socktop_group is not a unifly parameter".into());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MonitorType::Generic => {
|
||||||
|
if s.command.is_none() {
|
||||||
|
wrong("needs command -- generic screens have nothing else to run".into());
|
||||||
|
}
|
||||||
|
if s.socktop_group.is_some() || s.url.is_some() {
|
||||||
|
wrong(
|
||||||
|
"socktop_group and url are ignored for generic; put everything \
|
||||||
|
in command"
|
||||||
|
.into(),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if s.layout.is_some() && s.kind != MonitorType::Socktop {
|
||||||
|
wrong("layout only applies to socktop screens, which have several panes".into());
|
||||||
|
}
|
||||||
|
if let Some(c) = &s.command {
|
||||||
|
if shell_words::split(c).is_err() {
|
||||||
|
wrong(format!("command has unbalanced quotes: {c}"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.touch.leniency > 45 {
|
||||||
|
errs.push(format!(
|
||||||
|
"touch.leniency is {} -- the maximum is 45 degrees, beyond which \
|
||||||
|
horizontal and vertical swipes cannot be told apart",
|
||||||
|
self.touch.leniency
|
||||||
|
));
|
||||||
|
}
|
||||||
|
if self.touch.fingers.is_empty() {
|
||||||
|
errs.push("touch.fingers is empty -- no contact count would ever match".into());
|
||||||
|
}
|
||||||
|
if self.touch.fingers.contains(&0) {
|
||||||
|
errs.push("touch.fingers contains 0 -- a swipe needs at least one contact".into());
|
||||||
|
}
|
||||||
|
if self.touch.width == 0 || self.touch.height == 0 {
|
||||||
|
errs.push("touch.width and touch.height must be the panel's real resolution".into());
|
||||||
|
}
|
||||||
|
|
||||||
|
let g = &self.gestures;
|
||||||
|
let horizontal = |d: Direction| matches!(d, Direction::RL | Direction::LR);
|
||||||
|
if horizontal(g.forward) != horizontal(g.back) {
|
||||||
|
errs.push("gestures.forward and gestures.back must be on the same axis".into());
|
||||||
|
}
|
||||||
|
if horizontal(g.up) || horizontal(g.down) {
|
||||||
|
errs.push("gestures.up and gestures.down must be vertical (DU or UD)".into());
|
||||||
|
}
|
||||||
|
let all = [g.forward, g.back, g.up, g.down];
|
||||||
|
for (i, a) in all.iter().enumerate() {
|
||||||
|
if all[i + 1..].contains(a) {
|
||||||
|
errs.push(format!("gesture {a:?} is bound to more than one action"));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if errs.is_empty() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
bail!(
|
||||||
|
"{} problem{} in the config:\n{}",
|
||||||
|
errs.len(),
|
||||||
|
if errs.len() == 1 { "" } else { "s" },
|
||||||
|
errs.iter()
|
||||||
|
.map(|e| format!(" - {e}"))
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
.join("\n")
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Expand a leading `~/`. Paths in the config are written by hand and `~` is
|
||||||
|
/// the natural thing to type, but nothing expands it when the value is passed
|
||||||
|
/// straight to exec.
|
||||||
|
pub fn expand_tilde(s: &str) -> String {
|
||||||
|
if let Some(rest) = s.strip_prefix("~/") {
|
||||||
|
if let Some(home) = std::env::var_os("HOME") {
|
||||||
|
return Path::new(&home).join(rest).to_string_lossy().into_owned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.to_owned()
|
||||||
|
}
|
||||||
+121
@@ -0,0 +1,121 @@
|
|||||||
|
//! A small control socket, so the grid can be driven without touching it.
|
||||||
|
//!
|
||||||
|
//! Two reasons this exists beyond testing: a wall display often has no keyboard
|
||||||
|
//! but the box it runs on does, and binding these to keys is the only way to
|
||||||
|
//! navigate if the panel dies. `run` and `daemon` listen; the bare movement
|
||||||
|
//! subcommands connect.
|
||||||
|
|
||||||
|
use std::io::{BufRead, BufReader, Write};
|
||||||
|
use std::os::unix::net::{UnixListener, UnixStream};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::sync::mpsc::Sender;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::grid::Move;
|
||||||
|
|
||||||
|
/// What the main loop reacts to, whatever produced it.
|
||||||
|
pub enum Ctl {
|
||||||
|
/// A move to make. The stream, when present, is a caller of the movement
|
||||||
|
/// subcommands waiting to be told where it ended up -- so `socktop-swipe
|
||||||
|
/// forward` is synchronous and scriptable rather than fire-and-forget.
|
||||||
|
Go(Move, Option<UnixStream>),
|
||||||
|
/// The session or the terminal went away; wind up cleanly.
|
||||||
|
Quit,
|
||||||
|
Failed(anyhow::Error),
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn socket_path(session: &str) -> PathBuf {
|
||||||
|
let dir = std::env::var_os("XDG_RUNTIME_DIR")
|
||||||
|
.map(PathBuf::from)
|
||||||
|
.unwrap_or_else(std::env::temp_dir);
|
||||||
|
dir.join(format!("socktop-swipe-{session}.sock"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn parse_move(s: &str) -> Option<Move> {
|
||||||
|
match s.trim() {
|
||||||
|
"forward" | "next" => Some(Move::Forward),
|
||||||
|
"back" | "prev" => Some(Move::Back),
|
||||||
|
"up" => Some(Move::Up),
|
||||||
|
"down" => Some(Move::Down),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Bind the socket and feed moves into `tx` until the listener is dropped.
|
||||||
|
pub fn listen(session: &str, tx: Sender<Ctl>) -> Result<UnixListener> {
|
||||||
|
let path = socket_path(session);
|
||||||
|
|
||||||
|
// A socket left behind by a killed process would block the bind. Only
|
||||||
|
// remove it once we know nobody is listening on it.
|
||||||
|
if path.exists() {
|
||||||
|
if UnixStream::connect(&path).is_ok() {
|
||||||
|
bail!(
|
||||||
|
"another socktop-swipe is already running for session {session:?} \
|
||||||
|
({}).\nStop it first, or use a different `session:` in the config.",
|
||||||
|
path.display()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
let _ = std::fs::remove_file(&path);
|
||||||
|
}
|
||||||
|
|
||||||
|
let listener = UnixListener::bind(&path)
|
||||||
|
.with_context(|| format!("cannot create control socket at {}", path.display()))?;
|
||||||
|
|
||||||
|
let accepting = listener.try_clone().context("cannot clone control socket")?;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
for stream in accepting.incoming() {
|
||||||
|
let Ok(stream) = stream else { continue };
|
||||||
|
let reply = stream.try_clone().ok();
|
||||||
|
let mut lines = BufReader::new(stream).lines();
|
||||||
|
let Some(Ok(line)) = lines.next() else { continue };
|
||||||
|
match parse_move(&line) {
|
||||||
|
Some(m) => {
|
||||||
|
if tx.send(Ctl::Go(m, reply)).is_err() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => {
|
||||||
|
if let Some(mut r) = reply {
|
||||||
|
let _ = writeln!(r, "error: {:?} is not a direction", line.trim());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
Ok(listener)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Send one move to a running instance and wait to hear where it landed.
|
||||||
|
pub fn send(session: &str, m: &str) -> Result<String> {
|
||||||
|
let path = socket_path(session);
|
||||||
|
let mut stream = UnixStream::connect(&path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"no socktop-swipe listening for session {session:?} at {}.\n\
|
||||||
|
Start one with `socktop-swipe run`.",
|
||||||
|
path.display()
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
writeln!(stream, "{m}").context("cannot write to the control socket")?;
|
||||||
|
stream.shutdown(std::net::Shutdown::Write).ok();
|
||||||
|
|
||||||
|
let mut reply = String::new();
|
||||||
|
BufReader::new(&stream)
|
||||||
|
.read_line(&mut reply)
|
||||||
|
.context("no reply from socktop-swipe")?;
|
||||||
|
let reply = reply.trim().to_owned();
|
||||||
|
if let Some(e) = reply.strip_prefix("error: ") {
|
||||||
|
bail!("{e}");
|
||||||
|
}
|
||||||
|
Ok(reply)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Removes the socket file when the listener goes away.
|
||||||
|
pub struct SocketGuard(pub PathBuf);
|
||||||
|
|
||||||
|
impl Drop for SocketGuard {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = std::fs::remove_file(&self.0);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
//! Touch diagnostics. Replaces v1's tools/diag.sh and tools/find-device.sh.
|
||||||
|
//!
|
||||||
|
//! The question it exists to answer is "the panel works, so why does nothing
|
||||||
|
//! happen?" -- which in v1 meant reading lisgd's `Cfg(f=1) <=> Evt(f=2)` output
|
||||||
|
//! and knowing that meant the contact count. Here the answer is printed in
|
||||||
|
//! English instead.
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
use crate::config::Config;
|
||||||
|
use crate::input::{list_touchscreens, Event, Touchpanel};
|
||||||
|
|
||||||
|
pub fn list() -> Result<()> {
|
||||||
|
let found = list_touchscreens();
|
||||||
|
if found.is_empty() {
|
||||||
|
bail!(
|
||||||
|
"no multitouch devices found under /dev/input/by-id/.\n\
|
||||||
|
If the panel is plugged in, you may not have permission to read the \
|
||||||
|
devices: install the udev rule or join the 'input' group and log back in."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
println!("Multitouch devices:\n");
|
||||||
|
for (path, name) in found {
|
||||||
|
println!(" {name}\n {path}\n");
|
||||||
|
}
|
||||||
|
println!("Put the path in touch.device. Always the by-id path -- eventN numbers");
|
||||||
|
println!("get reshuffled on reboot or USB re-enumeration.");
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn run(cfg: &Config) -> Result<()> {
|
||||||
|
println!("Watching {}", cfg.touch.device);
|
||||||
|
println!(
|
||||||
|
" panel {}x{}, threshold {}px, leniency {}\u{b0}, contacts accepted: {:?}",
|
||||||
|
cfg.touch.width, cfg.touch.height, cfg.touch.threshold, cfg.touch.leniency, cfg.touch.fingers
|
||||||
|
);
|
||||||
|
println!(
|
||||||
|
" grab: {}\n",
|
||||||
|
if cfg.touch.grab {
|
||||||
|
"yes -- X will not see these touches"
|
||||||
|
} else {
|
||||||
|
"no -- X also receives these touches"
|
||||||
|
}
|
||||||
|
);
|
||||||
|
println!("Swipe left, right, up and down. Ctrl-C when done.\n");
|
||||||
|
|
||||||
|
let mut panel = Touchpanel::open(&cfg.touch)?;
|
||||||
|
let g = &cfg.gestures;
|
||||||
|
|
||||||
|
panel.run(|ev| {
|
||||||
|
match ev {
|
||||||
|
Event::Swipe(s) => {
|
||||||
|
let action = match s.direction {
|
||||||
|
d if d == g.forward => "forward",
|
||||||
|
d if d == g.back => "back",
|
||||||
|
d if d == g.up => "up",
|
||||||
|
d if d == g.down => "down",
|
||||||
|
_ => "not bound to anything",
|
||||||
|
};
|
||||||
|
println!(
|
||||||
|
" {:?} swipe, {} contact(s) -> {action}",
|
||||||
|
s.direction, s.fingers
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Event::Discarded(dir, why) => {
|
||||||
|
println!(" {dir:?} swipe ignored: {why}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
})
|
||||||
|
}
|
||||||
+479
@@ -0,0 +1,479 @@
|
|||||||
|
//! The navigation state machine. Pure: no tmux, no evdev, no I/O.
|
||||||
|
//!
|
||||||
|
//! Model (notes/PLAN-v2.md section 2):
|
||||||
|
//!
|
||||||
|
//! * Coordinates are sparse ordinals. Rows sort ascending, cells sort by column
|
||||||
|
//! within a row, and movement steps to the next *defined* neighbour. `0x1` and
|
||||||
|
//! `0x5` are interchangeable as long as they sort the same way.
|
||||||
|
//! * A socktop group is ONE cell containing a sub-sequence: tiled overview, then
|
||||||
|
//! each host zoomed. Horizontal movement walks that sub-sequence and only
|
||||||
|
//! leaves the cell after its last sub-screen.
|
||||||
|
//! * Entering a cell horizontally from the left lands on its first sub-screen,
|
||||||
|
//! from the right on its last.
|
||||||
|
//! * Vertical movement returns to where you were in that row if you have been
|
||||||
|
//! there before. Snapping to the nearest column only happens on first entry.
|
||||||
|
//! * No wrap-around at any edge: on a wall display, wrapping makes it impossible
|
||||||
|
//! to tell where you are.
|
||||||
|
|
||||||
|
use anyhow::{bail, Result};
|
||||||
|
|
||||||
|
use crate::config::Coord;
|
||||||
|
use crate::monitor::Cell;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum Move {
|
||||||
|
Forward,
|
||||||
|
Back,
|
||||||
|
Up,
|
||||||
|
Down,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Row {
|
||||||
|
pub row: i32,
|
||||||
|
/// Sorted by column ascending.
|
||||||
|
pub cells: Vec<Cell>,
|
||||||
|
/// Index into `cells`. Persists so a vertical return restores it.
|
||||||
|
pub cursor: usize,
|
||||||
|
visited: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug)]
|
||||||
|
pub struct Grid {
|
||||||
|
/// Sorted by row ascending. Row increases downward, so index 0 is the top.
|
||||||
|
pub rows: Vec<Row>,
|
||||||
|
pub cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Where the grid ended up after a move. `changed` is false when the move hit
|
||||||
|
/// an edge, which the caller uses to skip pointless tmux work.
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Position {
|
||||||
|
pub coord: Coord,
|
||||||
|
/// Sub-screen within the cell.
|
||||||
|
pub screen: usize,
|
||||||
|
pub changed: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Grid {
|
||||||
|
pub fn new(cells: Vec<Cell>) -> Result<Self> {
|
||||||
|
if cells.is_empty() {
|
||||||
|
bail!("no screens defined -- the grid would be empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut row_numbers: Vec<i32> = cells.iter().map(|c| c.coord.row).collect();
|
||||||
|
row_numbers.sort_unstable();
|
||||||
|
row_numbers.dedup();
|
||||||
|
|
||||||
|
let rows: Vec<Row> = row_numbers
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| {
|
||||||
|
let mut cells: Vec<Cell> =
|
||||||
|
cells.iter().filter(|c| c.coord.row == r).cloned().collect();
|
||||||
|
cells.sort_by_key(|c| c.coord.col);
|
||||||
|
Row { row: r, cells, cursor: 0, visited: false }
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
// Start at 0x0 when it exists; otherwise the leftmost cell of row 0, and
|
||||||
|
// failing that the top-left of the whole grid.
|
||||||
|
let cursor = rows
|
||||||
|
.iter()
|
||||||
|
.position(|r| r.row == 0)
|
||||||
|
.unwrap_or(0);
|
||||||
|
let mut grid = Grid { rows, cursor };
|
||||||
|
let start_col = grid.rows[cursor].cells.iter().position(|c| c.coord.col == 0);
|
||||||
|
grid.rows[cursor].cursor = start_col.unwrap_or(0);
|
||||||
|
grid.rows[cursor].visited = true;
|
||||||
|
Ok(grid)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn row(&self) -> &Row {
|
||||||
|
&self.rows[self.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn cell(&self) -> &Cell {
|
||||||
|
let r = self.row();
|
||||||
|
&r.cells[r.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
fn cell_mut(&mut self) -> &mut Cell {
|
||||||
|
let r = &mut self.rows[self.cursor];
|
||||||
|
&mut r.cells[r.cursor]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn position(&self, changed: bool) -> Position {
|
||||||
|
let c = self.cell();
|
||||||
|
Position { coord: c.coord, screen: c.cursor, changed }
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply(&mut self, m: Move) -> Position {
|
||||||
|
let changed = match m {
|
||||||
|
Move::Forward => self.forward(),
|
||||||
|
Move::Back => self.back(),
|
||||||
|
Move::Up => self.vertical(true),
|
||||||
|
Move::Down => self.vertical(false),
|
||||||
|
};
|
||||||
|
self.position(changed)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn forward(&mut self) -> bool {
|
||||||
|
if self.cell().cursor < self.cell().last_screen() {
|
||||||
|
self.cell_mut().cursor += 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let row = &mut self.rows[self.cursor];
|
||||||
|
if row.cursor + 1 < row.cells.len() {
|
||||||
|
row.cursor += 1;
|
||||||
|
// Entered from the left: land on the first sub-screen.
|
||||||
|
row.cells[row.cursor].cursor = 0;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn back(&mut self) -> bool {
|
||||||
|
if self.cell().cursor > 0 {
|
||||||
|
self.cell_mut().cursor -= 1;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
let row = &mut self.rows[self.cursor];
|
||||||
|
if row.cursor > 0 {
|
||||||
|
row.cursor -= 1;
|
||||||
|
// Entered from the right: land on the last sub-screen, so the
|
||||||
|
// carousel reads as continuous rather than jumping to an overview.
|
||||||
|
let last = row.cells[row.cursor].last_screen();
|
||||||
|
row.cells[row.cursor].cursor = last;
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
false
|
||||||
|
}
|
||||||
|
|
||||||
|
fn vertical(&mut self, up: bool) -> bool {
|
||||||
|
let Some(target) = (if up {
|
||||||
|
self.cursor.checked_sub(1)
|
||||||
|
} else {
|
||||||
|
(self.cursor + 1 < self.rows.len()).then(|| self.cursor + 1)
|
||||||
|
}) else {
|
||||||
|
return false;
|
||||||
|
};
|
||||||
|
|
||||||
|
let from_col = self.cell().coord.col;
|
||||||
|
let row = &mut self.rows[target];
|
||||||
|
|
||||||
|
// Return memory wins. Snapping only decides the very first entry.
|
||||||
|
if !row.visited {
|
||||||
|
row.cursor = nearest_column(&row.cells, from_col);
|
||||||
|
row.visited = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.cursor = target;
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Every cell in grid order, for `validate` and session building.
|
||||||
|
pub fn cells(&self) -> impl Iterator<Item = &Cell> {
|
||||||
|
self.rows.iter().flat_map(|r| r.cells.iter())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// The cell above and below the current one, for the indicator.
|
||||||
|
pub fn neighbours(&self) -> (Option<&Cell>, Option<&Cell>) {
|
||||||
|
let peek = |i: Option<usize>| -> Option<&Cell> {
|
||||||
|
let r = &self.rows[i?];
|
||||||
|
r.cells.get(if r.visited {
|
||||||
|
r.cursor
|
||||||
|
} else {
|
||||||
|
nearest_column(&r.cells, self.cell().coord.col)
|
||||||
|
})
|
||||||
|
};
|
||||||
|
(
|
||||||
|
peek(self.cursor.checked_sub(1)),
|
||||||
|
peek((self.cursor + 1 < self.rows.len()).then(|| self.cursor + 1)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Index of the cell whose column is closest to `col`. Exact match wins; ties
|
||||||
|
/// break toward the lower column. `cells` is sorted by column and non-empty.
|
||||||
|
fn nearest_column(cells: &[Cell], col: i32) -> usize {
|
||||||
|
cells
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.min_by_key(|(_, c)| ((c.coord.col - col).abs(), c.coord.col))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
use crate::config::{Layout, MonitorType};
|
||||||
|
use crate::monitor::Pane;
|
||||||
|
|
||||||
|
/// A cell at `coord` with `n` panes -- so `n + 1` sub-screens when n > 1.
|
||||||
|
fn cell(coord: &str, n: usize) -> Cell {
|
||||||
|
let coord: Coord = coord.parse().unwrap();
|
||||||
|
Cell {
|
||||||
|
coord,
|
||||||
|
kind: MonitorType::Socktop,
|
||||||
|
label: format!("{coord}"),
|
||||||
|
panes: (0..n)
|
||||||
|
.map(|i| Pane { title: format!("{coord}#{i}"), command: vec!["true".into()] })
|
||||||
|
.collect(),
|
||||||
|
layout: Layout::Tiled,
|
||||||
|
cursor: 0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Walk a sequence of moves, collecting "coord@screen" after each.
|
||||||
|
fn walk(g: &mut Grid, moves: &[Move]) -> Vec<String> {
|
||||||
|
moves
|
||||||
|
.iter()
|
||||||
|
.map(|&m| {
|
||||||
|
let p = g.apply(m);
|
||||||
|
format!("{}@{}", p.coord, p.screen)
|
||||||
|
})
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn at(g: &Grid) -> String {
|
||||||
|
let p = g.position(true);
|
||||||
|
format!("{}@{}", p.coord, p.screen)
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- sub-screen expansion ------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn multi_pane_cell_is_overview_plus_each_pane() {
|
||||||
|
let c = cell("0x0", 4);
|
||||||
|
assert_eq!(c.screens(), 5);
|
||||||
|
assert!(c.has_overview());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn single_pane_cell_is_one_screen_with_nothing_to_zoom() {
|
||||||
|
// An overview of one pane and that pane zoomed are the same picture.
|
||||||
|
let c = cell("0x0", 1);
|
||||||
|
assert_eq!(c.screens(), 1);
|
||||||
|
assert!(!c.has_overview());
|
||||||
|
assert_eq!(c.zoomed_pane(), Some(0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn zoomed_pane_maps_cursor_past_the_overview() {
|
||||||
|
let mut c = cell("0x0", 3);
|
||||||
|
assert_eq!(c.zoomed_pane(), None); // overview
|
||||||
|
c.cursor = 1;
|
||||||
|
assert_eq!(c.zoomed_pane(), Some(0));
|
||||||
|
c.cursor = 3;
|
||||||
|
assert_eq!(c.zoomed_pane(), Some(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- horizontal ----------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn forward_walks_sub_screens_then_moves_to_the_next_cell() {
|
||||||
|
let mut g = Grid::new(vec![cell("0x0", 4), cell("0x1", 2)]).unwrap();
|
||||||
|
assert_eq!(at(&g), "0x0@0");
|
||||||
|
assert_eq!(
|
||||||
|
walk(&mut g, &[Move::Forward; 5]),
|
||||||
|
["0x0@1", "0x0@2", "0x0@3", "0x0@4", "0x1@0"]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn entering_from_the_right_lands_on_the_last_sub_screen() {
|
||||||
|
let mut g = Grid::new(vec![cell("0x0", 4), cell("0x1", 2)]).unwrap();
|
||||||
|
walk(&mut g, &[Move::Forward; 5]);
|
||||||
|
assert_eq!(at(&g), "0x1@0");
|
||||||
|
// Back out of 0x1 and into 0x0 -- should be 0x0's LAST host, not its
|
||||||
|
// overview, or the carousel jumps.
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Back]), ["0x0@4"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_wrap_around_at_either_end() {
|
||||||
|
let mut g = Grid::new(vec![cell("0x0", 2), cell("0x1", 1)]).unwrap();
|
||||||
|
for _ in 0..10 {
|
||||||
|
g.apply(Move::Back);
|
||||||
|
}
|
||||||
|
assert_eq!(at(&g), "0x0@0", "should stop at the first sub-screen");
|
||||||
|
for _ in 0..20 {
|
||||||
|
g.apply(Move::Forward);
|
||||||
|
}
|
||||||
|
assert_eq!(at(&g), "0x1@0", "should stop at the last cell");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn edge_moves_report_unchanged() {
|
||||||
|
let mut g = Grid::new(vec![cell("0x0", 2)]).unwrap();
|
||||||
|
assert!(!g.apply(Move::Back).changed);
|
||||||
|
assert!(g.apply(Move::Forward).changed);
|
||||||
|
assert!(g.apply(Move::Forward).changed);
|
||||||
|
assert!(!g.apply(Move::Forward).changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn sparse_columns_are_pure_ordering() {
|
||||||
|
// 0x0, 0x5, 0x99 must behave exactly like 0x0, 0x1, 0x2.
|
||||||
|
let mut sparse = Grid::new(vec![cell("0x0", 1), cell("0x5", 1), cell("0x99", 1)]).unwrap();
|
||||||
|
let mut dense = Grid::new(vec![cell("0x0", 1), cell("0x1", 1), cell("0x2", 1)]).unwrap();
|
||||||
|
let moves = [Move::Forward, Move::Forward, Move::Back, Move::Forward];
|
||||||
|
let s: Vec<_> = walk(&mut sparse, &moves).iter().map(|p| p.split('@').nth(1).unwrap().to_string()).collect();
|
||||||
|
let d: Vec<_> = walk(&mut dense, &moves).iter().map(|p| p.split('@').nth(1).unwrap().to_string()).collect();
|
||||||
|
assert_eq!(s, d);
|
||||||
|
assert_eq!(sparse.cell().coord, Coord::new(0, 99));
|
||||||
|
assert_eq!(dense.cell().coord, Coord::new(0, 2));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn columns_out_of_order_in_the_file_still_sort() {
|
||||||
|
let mut g = Grid::new(vec![cell("0x2", 1), cell("0x0", 1), cell("0x1", 1)]).unwrap();
|
||||||
|
assert_eq!(at(&g), "0x0@0");
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Forward, Move::Forward]), ["0x1@0", "0x2@0"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- vertical ------------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn default_rack_layout_round_trips_to_the_same_host() {
|
||||||
|
// -1x0 unifly, 0x0 four Pis, 0x1 two boxes, 1x0 kuma.
|
||||||
|
let mut g = Grid::new(vec![
|
||||||
|
cell("-1x0", 1),
|
||||||
|
cell("0x0", 4),
|
||||||
|
cell("0x1", 2),
|
||||||
|
cell("1x0", 1),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
// Zoom into the third Pi.
|
||||||
|
walk(&mut g, &[Move::Forward, Move::Forward, Move::Forward]);
|
||||||
|
assert_eq!(at(&g), "0x0@3");
|
||||||
|
|
||||||
|
// Up to unifly, and back down to exactly the same Pi.
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x0@0"]);
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Down]), ["0x0@3"]);
|
||||||
|
|
||||||
|
// Down again to kuma, and back up to the same Pi.
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Down]), ["1x0@0"]);
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Up]), ["0x0@3"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn horizontal_swipes_on_a_single_cell_row_do_nothing() {
|
||||||
|
let mut g = Grid::new(vec![cell("-1x0", 1), cell("0x0", 2)]).unwrap();
|
||||||
|
g.apply(Move::Up);
|
||||||
|
assert_eq!(at(&g), "-1x0@0");
|
||||||
|
assert!(!g.apply(Move::Forward).changed);
|
||||||
|
assert!(!g.apply(Move::Back).changed);
|
||||||
|
assert_eq!(at(&g), "-1x0@0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn return_memory_beats_snapping() {
|
||||||
|
// Row -1 has only column 0, so leaving from 0x1 must snap on the way up
|
||||||
|
// but must NOT snap on the way back down.
|
||||||
|
let mut g = Grid::new(vec![cell("-1x0", 1), cell("0x0", 2), cell("0x1", 3)]).unwrap();
|
||||||
|
// 0x0 has 3 sub-screens and 0x1 has 4, so five forwards lands us in the
|
||||||
|
// MIDDLE of 0x1 -- a return to "0x1@0" would look like success otherwise.
|
||||||
|
assert_eq!(
|
||||||
|
walk(&mut g, &[Move::Forward; 5]),
|
||||||
|
["0x0@1", "0x0@2", "0x1@0", "0x1@1", "0x1@2"]
|
||||||
|
);
|
||||||
|
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x0@0"], "snapped on first entry");
|
||||||
|
assert_eq!(
|
||||||
|
walk(&mut g, &[Move::Down]),
|
||||||
|
["0x1@2"],
|
||||||
|
"must return to 0x1 sub-screen 2, not snap to 0x0"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snapping_picks_the_nearest_column_on_first_entry_only() {
|
||||||
|
let mut g = Grid::new(vec![
|
||||||
|
cell("-1x1", 1),
|
||||||
|
cell("-1x9", 1),
|
||||||
|
cell("0x0", 1),
|
||||||
|
cell("0x8", 1),
|
||||||
|
])
|
||||||
|
.unwrap();
|
||||||
|
g.apply(Move::Forward); // to 0x8
|
||||||
|
assert_eq!(at(&g), "0x8@0");
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x9@0"], "9 is nearer to 8 than 1");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn snap_ties_break_toward_the_lower_column() {
|
||||||
|
// From 0x2, both -1x1 and -1x3 are distance 1.
|
||||||
|
let mut g = Grid::new(vec![cell("-1x1", 1), cell("-1x3", 1), cell("0x2", 1)]).unwrap();
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Up]), ["-1x1@0"]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn vertical_steps_to_the_next_defined_row_however_numbered() {
|
||||||
|
// Rows -7, 0 and 42 must behave exactly like -1, 0 and 1.
|
||||||
|
let mut g = Grid::new(vec![cell("-7x0", 1), cell("0x0", 1), cell("42x0", 1)]).unwrap();
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Up]), ["-7x0@0"]);
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Down, Move::Down]), ["0x0@0", "42x0@0"]);
|
||||||
|
assert!(!g.apply(Move::Down).changed, "no row below 42");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn no_vertical_movement_in_a_single_row_grid() {
|
||||||
|
let mut g = Grid::new(vec![cell("0x0", 2), cell("0x1", 2)]).unwrap();
|
||||||
|
assert!(!g.apply(Move::Up).changed);
|
||||||
|
assert!(!g.apply(Move::Down).changed);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn horizontal_movement_updates_what_a_vertical_return_restores() {
|
||||||
|
let mut g = Grid::new(vec![cell("-1x0", 1), cell("0x0", 1), cell("0x1", 1)]).unwrap();
|
||||||
|
g.apply(Move::Up);
|
||||||
|
g.apply(Move::Down); // row 0 remembered 0x0
|
||||||
|
assert_eq!(at(&g), "0x0@0");
|
||||||
|
g.apply(Move::Forward); // now at 0x1
|
||||||
|
g.apply(Move::Up);
|
||||||
|
assert_eq!(walk(&mut g, &[Move::Down]), ["0x1@0"], "memory follows the move");
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- start position ------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starts_at_0x0_when_it_exists() {
|
||||||
|
let g = Grid::new(vec![cell("1x0", 1), cell("-1x0", 1), cell("0x0", 1)]).unwrap();
|
||||||
|
assert_eq!(g.cell().coord, Coord::new(0, 0));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starts_at_the_leftmost_cell_of_row_0_when_0x0_is_missing() {
|
||||||
|
let g = Grid::new(vec![cell("0x7", 1), cell("0x3", 1), cell("-1x0", 1)]).unwrap();
|
||||||
|
assert_eq!(g.cell().coord, Coord::new(0, 3));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn starts_at_the_top_left_when_there_is_no_row_0() {
|
||||||
|
let g = Grid::new(vec![cell("5x2", 1), cell("3x9", 1), cell("3x4", 1)]).unwrap();
|
||||||
|
assert_eq!(g.cell().coord, Coord::new(3, 4));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn empty_grid_is_rejected() {
|
||||||
|
assert!(Grid::new(vec![]).is_err());
|
||||||
|
}
|
||||||
|
|
||||||
|
// -- indicator -----------------------------------------------------------
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn neighbours_reports_the_rows_above_and_below() {
|
||||||
|
let mut g = Grid::new(vec![cell("-1x0", 1), cell("0x0", 2), cell("1x0", 1)]).unwrap();
|
||||||
|
let (up, down) = g.neighbours();
|
||||||
|
assert_eq!(up.unwrap().coord, Coord::new(-1, 0));
|
||||||
|
assert_eq!(down.unwrap().coord, Coord::new(1, 0));
|
||||||
|
|
||||||
|
g.apply(Move::Up);
|
||||||
|
let (up, down) = g.neighbours();
|
||||||
|
assert!(up.is_none(), "nothing above the top row");
|
||||||
|
assert_eq!(down.unwrap().coord, Coord::new(0, 0));
|
||||||
|
}
|
||||||
|
}
|
||||||
+395
@@ -0,0 +1,395 @@
|
|||||||
|
//! Touch gestures straight from the kernel, replacing lisgd.
|
||||||
|
//!
|
||||||
|
//! libinput deliberately emits gesture events only for touchpads, never for
|
||||||
|
//! touchscreens, so libinput-gestures and friends cannot work here at all. We
|
||||||
|
//! read multitouch protocol B from the event device and synthesise swipes.
|
||||||
|
//!
|
||||||
|
//! Protocol B reports each contact in a numbered slot: `ABS_MT_SLOT` selects the
|
||||||
|
//! slot, `ABS_MT_TRACKING_ID` of -1 lifts it, and `ABS_MT_POSITION_X/Y` update
|
||||||
|
//! it. We record where each slot started and where it ended, then decide on the
|
||||||
|
//! release of the last contact.
|
||||||
|
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::path::Path;
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
use evdev::{AbsoluteAxisType, Device, InputEventKind};
|
||||||
|
|
||||||
|
use crate::config::{Direction, Touch};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub struct Swipe {
|
||||||
|
pub direction: Direction,
|
||||||
|
/// Peak simultaneous contacts during the gesture.
|
||||||
|
pub fingers: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy)]
|
||||||
|
struct Slot {
|
||||||
|
start: (i32, i32),
|
||||||
|
last: (i32, i32),
|
||||||
|
active: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Touchpanel {
|
||||||
|
device: Device,
|
||||||
|
cfg: Touch,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Why a candidate gesture was not emitted. Only ever shown by `doctor`.
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Rejected {
|
||||||
|
TooShort { travel: f64, threshold: u32 },
|
||||||
|
OffAxis { degrees: f64, leniency: u32 },
|
||||||
|
WrongFingerCount { saw: usize, want: Vec<usize> },
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for Rejected {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
Self::TooShort { travel, threshold } => write!(
|
||||||
|
f,
|
||||||
|
"travelled {travel:.0}px, needs {threshold}px (touch.threshold)"
|
||||||
|
),
|
||||||
|
Self::OffAxis { degrees, leniency } => write!(
|
||||||
|
f,
|
||||||
|
"{degrees:.0}\u{b0} off axis, tolerance is {leniency}\u{b0} (touch.leniency)"
|
||||||
|
),
|
||||||
|
Self::WrongFingerCount { saw, want } => write!(
|
||||||
|
f,
|
||||||
|
"saw {saw} contact(s), config accepts {want:?} -- add {saw} to touch.fingers"
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum Event {
|
||||||
|
Swipe(Swipe),
|
||||||
|
/// A gesture was seen and discarded. Carries the direction it would have
|
||||||
|
/// been, so `doctor` can say "that was a left swipe, but ...".
|
||||||
|
Discarded(Direction, Rejected),
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Touchpanel {
|
||||||
|
pub fn open(cfg: &Touch) -> Result<Self> {
|
||||||
|
let path = Path::new(&cfg.device);
|
||||||
|
if !path.exists() {
|
||||||
|
bail!(
|
||||||
|
"{} does not exist.\n\
|
||||||
|
Touch devices move around when USB re-enumerates -- always use a \
|
||||||
|
/dev/input/by-id/ path, never eventN.\n\
|
||||||
|
Run `socktop-swipe doctor --list` to see what is present.",
|
||||||
|
cfg.device
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut device = Device::open(path).with_context(|| {
|
||||||
|
format!(
|
||||||
|
"cannot open {}.\n\
|
||||||
|
Reading touch events needs access to the device. Either install the \
|
||||||
|
udev rule (packaging/70-socktop-swipe.rules) or add yourself to the \
|
||||||
|
'input' group and log out and back in.",
|
||||||
|
cfg.device
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let has_mt = device
|
||||||
|
.supported_absolute_axes()
|
||||||
|
.is_some_and(|a| a.contains(AbsoluteAxisType::ABS_MT_POSITION_X));
|
||||||
|
if !has_mt {
|
||||||
|
bail!(
|
||||||
|
"{} does not report multitouch positions -- it is probably not the \
|
||||||
|
touchscreen.\nRun `socktop-swipe doctor --list`.",
|
||||||
|
cfg.device
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if cfg.grab {
|
||||||
|
// Take the device exclusively so X never sees the touches. This is
|
||||||
|
// what the v1 xorg.conf.d "Ignore" rule was faking, and it needs no
|
||||||
|
// X restart or relogin.
|
||||||
|
device.grab().with_context(|| {
|
||||||
|
format!(
|
||||||
|
"cannot take exclusive control of {}.\n\
|
||||||
|
Something else may already hold it (another socktop-swipe?). \
|
||||||
|
Set touch.grab: false to share the device with X, but then also \
|
||||||
|
install the X ignore rule -- see the README.",
|
||||||
|
cfg.device
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(Self { device, cfg: cfg.clone() })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Blocking gesture loop. Calls `on_event` for every completed gesture,
|
||||||
|
/// including rejected ones, and stops when it returns `false`.
|
||||||
|
pub fn run(&mut self, mut on_event: impl FnMut(Event) -> bool) -> Result<()> {
|
||||||
|
let mut slots: HashMap<i32, Slot> = HashMap::new();
|
||||||
|
let mut current: i32 = 0;
|
||||||
|
let mut peak: usize = 0;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
for ev in self.device.fetch_events().context("reading touch events")? {
|
||||||
|
match ev.kind() {
|
||||||
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
|
||||||
|
current = ev.value();
|
||||||
|
}
|
||||||
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
|
||||||
|
if ev.value() < 0 {
|
||||||
|
if let Some(s) = slots.get_mut(¤t) {
|
||||||
|
s.active = false;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
slots.insert(
|
||||||
|
current,
|
||||||
|
Slot { start: (0, 0), last: (0, 0), active: true },
|
||||||
|
);
|
||||||
|
peak = peak.max(slots.values().filter(|s| s.active).count());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
|
||||||
|
update(&mut slots, current, |p| p.0 = ev.value());
|
||||||
|
}
|
||||||
|
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => {
|
||||||
|
update(&mut slots, current, |p| p.1 = ev.value());
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// The gesture ends when the last contact lifts.
|
||||||
|
if !slots.is_empty() && slots.values().all(|s| !s.active) {
|
||||||
|
let tracks: Vec<Track> =
|
||||||
|
slots.values().map(|s| (s.start, s.last)).collect();
|
||||||
|
if let Some(ev) = classify(&self.cfg, &tracks, peak) {
|
||||||
|
if !on_event(ev) {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
slots.clear();
|
||||||
|
peak = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One contact's journey: where it landed and where it lifted.
|
||||||
|
pub type Track = ((i32, i32), (i32, i32));
|
||||||
|
|
||||||
|
/// Decide what a completed gesture was. Split out from the device so the rules
|
||||||
|
/// -- threshold, leniency, contact count -- can be tested without hardware.
|
||||||
|
pub fn classify(cfg: &Touch, tracks: &[Track], peak: usize) -> Option<Event> {
|
||||||
|
// Average the contacts' travel: a "one finger" swipe that the panel reports
|
||||||
|
// as two or three contacts is one motion counted repeatedly, so the mean is
|
||||||
|
// the real displacement rather than a multiple of it.
|
||||||
|
let n = tracks.len() as f64;
|
||||||
|
if n == 0.0 {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
let (dx, dy) = tracks.iter().fold((0.0, 0.0), |(ax, ay), (start, last)| {
|
||||||
|
(ax + (last.0 - start.0) as f64 / n, ay + (last.1 - start.1) as f64 / n)
|
||||||
|
});
|
||||||
|
|
||||||
|
let travel = (dx * dx + dy * dy).sqrt();
|
||||||
|
let horizontal = dx.abs() >= dy.abs();
|
||||||
|
let direction = match (horizontal, dx > 0.0, dy > 0.0) {
|
||||||
|
(true, true, _) => Direction::LR,
|
||||||
|
(true, false, _) => Direction::RL,
|
||||||
|
// Y grows downward on a touch panel, so a positive dy is a downward
|
||||||
|
// swipe: up-to-down.
|
||||||
|
(false, _, true) => Direction::UD,
|
||||||
|
(false, _, false) => Direction::DU,
|
||||||
|
};
|
||||||
|
|
||||||
|
if travel < cfg.threshold as f64 {
|
||||||
|
return Some(Event::Discarded(
|
||||||
|
direction,
|
||||||
|
Rejected::TooShort { travel, threshold: cfg.threshold },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Angle away from the dominant axis.
|
||||||
|
let (along, across) = if horizontal { (dx.abs(), dy.abs()) } else { (dy.abs(), dx.abs()) };
|
||||||
|
let degrees = across.atan2(along).to_degrees();
|
||||||
|
if degrees > cfg.leniency as f64 {
|
||||||
|
return Some(Event::Discarded(
|
||||||
|
direction,
|
||||||
|
Rejected::OffAxis { degrees, leniency: cfg.leniency },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
if !cfg.fingers.contains(&peak) {
|
||||||
|
return Some(Event::Discarded(
|
||||||
|
direction,
|
||||||
|
Rejected::WrongFingerCount { saw: peak, want: cfg.fingers.clone() },
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
Some(Event::Swipe(Swipe { direction, fingers: peak }))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update(slots: &mut HashMap<i32, Slot>, current: i32, f: impl Fn(&mut (i32, i32))) {
|
||||||
|
let slot = slots.entry(current).or_insert(Slot {
|
||||||
|
start: (0, 0),
|
||||||
|
last: (0, 0),
|
||||||
|
active: true,
|
||||||
|
});
|
||||||
|
f(&mut slot.last);
|
||||||
|
// The first position report after a contact begins is also its origin.
|
||||||
|
if slot.start == (0, 0) {
|
||||||
|
slot.start = slot.last;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Candidate touchscreens, for `doctor --list` and the installer.
|
||||||
|
pub fn list_touchscreens() -> Vec<(String, String)> {
|
||||||
|
let mut out = Vec::new();
|
||||||
|
let by_id = Path::new("/dev/input/by-id");
|
||||||
|
let entries = std::fs::read_dir(by_id).into_iter().flatten().flatten();
|
||||||
|
for e in entries {
|
||||||
|
let path = e.path();
|
||||||
|
let Ok(dev) = Device::open(&path) else { continue };
|
||||||
|
let multitouch = dev
|
||||||
|
.supported_absolute_axes()
|
||||||
|
.is_some_and(|a| a.contains(AbsoluteAxisType::ABS_MT_POSITION_X));
|
||||||
|
if multitouch {
|
||||||
|
out.push((
|
||||||
|
path.to_string_lossy().into_owned(),
|
||||||
|
dev.name().unwrap_or("unnamed device").to_owned(),
|
||||||
|
));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out.sort();
|
||||||
|
out
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn touch(threshold: u32, leniency: u32, fingers: Vec<usize>) -> Touch {
|
||||||
|
Touch {
|
||||||
|
device: "/dev/null".into(),
|
||||||
|
width: 1280,
|
||||||
|
height: 720,
|
||||||
|
grab: false,
|
||||||
|
threshold,
|
||||||
|
leniency,
|
||||||
|
fingers,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// One contact travelling from `from` by `(dx, dy)`.
|
||||||
|
fn track(from: (i32, i32), dx: i32, dy: i32) -> Track {
|
||||||
|
(from, (from.0 + dx, from.1 + dy))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn swipe(cfg: &Touch, tracks: &[Track], peak: usize) -> Event {
|
||||||
|
classify(cfg, tracks, peak).expect("a completed gesture should classify")
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn recognises_the_four_directions() {
|
||||||
|
let cfg = touch(80, 30, vec![1]);
|
||||||
|
let cases = [
|
||||||
|
((-200, 0), Direction::RL),
|
||||||
|
((200, 0), Direction::LR),
|
||||||
|
// Y grows downward, so a negative dy is a swipe upward.
|
||||||
|
((0, -200), Direction::DU),
|
||||||
|
((0, 200), Direction::UD),
|
||||||
|
];
|
||||||
|
for ((dx, dy), want) in cases {
|
||||||
|
match swipe(&cfg, &[track((640, 360), dx, dy)], 1) {
|
||||||
|
Event::Swipe(s) => assert_eq!(s.direction, want, "({dx},{dy})"),
|
||||||
|
other => panic!("({dx},{dy}) should be a swipe, got {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ghost_contacts_do_not_multiply_the_travel() {
|
||||||
|
// The ILITEK panel reports one physical finger as 2-3 contacts. Each
|
||||||
|
// reports the same motion, so the average must equal one finger's
|
||||||
|
// travel -- not the sum, which would make short drags look long.
|
||||||
|
let cfg = touch(150, 30, vec![1, 2, 3]);
|
||||||
|
let one = [track((900, 300), -100, 0)];
|
||||||
|
let three = [
|
||||||
|
track((900, 300), -100, 0),
|
||||||
|
track((902, 305), -100, 0),
|
||||||
|
track((898, 295), -100, 0),
|
||||||
|
];
|
||||||
|
for tracks in [&one[..], &three[..]] {
|
||||||
|
match classify(&cfg, tracks, tracks.len()) {
|
||||||
|
Some(Event::Discarded(Direction::RL, Rejected::TooShort { travel, .. })) => {
|
||||||
|
assert!((travel - 100.0).abs() < 1.0, "travel was {travel}");
|
||||||
|
}
|
||||||
|
other => panic!("100px under a 150px threshold should be too short: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn short_drags_are_rejected_with_the_measurement() {
|
||||||
|
let cfg = touch(80, 30, vec![1]);
|
||||||
|
match swipe(&cfg, &[track((640, 360), -40, 0)], 1) {
|
||||||
|
Event::Discarded(Direction::RL, Rejected::TooShort { travel, threshold }) => {
|
||||||
|
assert_eq!(threshold, 80);
|
||||||
|
assert!((travel - 40.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
other => panic!("40px should be too short: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn diagonal_swipes_are_rejected_past_the_leniency() {
|
||||||
|
let cfg = touch(80, 30, vec![1]);
|
||||||
|
// 45 degrees: equal travel on both axes, well past a 30 degree tolerance.
|
||||||
|
match swipe(&cfg, &[track((640, 360), -200, -200)], 1) {
|
||||||
|
Event::Discarded(_, Rejected::OffAxis { degrees, leniency }) => {
|
||||||
|
assert_eq!(leniency, 30);
|
||||||
|
assert!((degrees - 45.0).abs() < 0.01, "was {degrees}");
|
||||||
|
}
|
||||||
|
other => panic!("a 45 degree drag should be off-axis: {other:?}"),
|
||||||
|
}
|
||||||
|
// 20 degrees off: within tolerance, still a left swipe.
|
||||||
|
let dy = -(200.0 * 20f64.to_radians().tan()) as i32;
|
||||||
|
match swipe(&cfg, &[track((640, 360), -200, dy)], 1) {
|
||||||
|
Event::Swipe(s) => assert_eq!(s.direction, Direction::RL),
|
||||||
|
other => panic!("20 degrees off axis should pass: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn unconfigured_contact_counts_are_rejected_and_name_the_fix() {
|
||||||
|
// The v1 failure that cost the most time: gestures detected perfectly,
|
||||||
|
// nothing ever fires, because the panel reports 2 contacts and the
|
||||||
|
// config accepts only 1.
|
||||||
|
let cfg = touch(80, 30, vec![1]);
|
||||||
|
match swipe(&cfg, &[track((900, 300), -200, 0), track((905, 305), -200, 0)], 2) {
|
||||||
|
Event::Discarded(Direction::RL, r @ Rejected::WrongFingerCount { saw, .. }) => {
|
||||||
|
assert_eq!(saw, 2);
|
||||||
|
assert!(r.to_string().contains("touch.fingers"), "{r}");
|
||||||
|
}
|
||||||
|
other => panic!("2 contacts against fingers:[1] should be rejected: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn threshold_is_checked_before_the_contact_count() {
|
||||||
|
// Otherwise a stray tap on a panel with ghost contacts reports the
|
||||||
|
// finger-count problem, sending you to fix the wrong setting.
|
||||||
|
let cfg = touch(80, 30, vec![1]);
|
||||||
|
match swipe(&cfg, &[track((640, 360), -5, 0), track((641, 361), -5, 0)], 2) {
|
||||||
|
Event::Discarded(_, Rejected::TooShort { .. }) => {}
|
||||||
|
other => panic!("expected the short-travel reason first: {other:?}"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn a_gesture_with_no_contacts_is_not_a_gesture() {
|
||||||
|
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
|
||||||
|
}
|
||||||
|
}
|
||||||
+12
@@ -0,0 +1,12 @@
|
|||||||
|
//! socktop-swipe: swipe between terminal dashboards on a touchscreen.
|
||||||
|
//!
|
||||||
|
//! The binary is a thin CLI over these modules. They are public so the
|
||||||
|
//! integration tests can drive a real tmux session without a touch panel.
|
||||||
|
|
||||||
|
pub mod config;
|
||||||
|
pub mod control;
|
||||||
|
pub mod doctor;
|
||||||
|
pub mod grid;
|
||||||
|
pub mod input;
|
||||||
|
pub mod monitor;
|
||||||
|
pub mod session;
|
||||||
+343
@@ -0,0 +1,343 @@
|
|||||||
|
//! socktop-swipe: swipe between terminal dashboards on a touchscreen.
|
||||||
|
|
||||||
|
use std::path::PathBuf;
|
||||||
|
use std::process::Command;
|
||||||
|
use std::sync::mpsc;
|
||||||
|
|
||||||
|
use anyhow::{Context, Result};
|
||||||
|
use clap::{Parser, Subcommand};
|
||||||
|
|
||||||
|
use socktop_swipe::config::{self, Config, Direction};
|
||||||
|
use socktop_swipe::control::{self, Ctl};
|
||||||
|
use socktop_swipe::grid::{Grid, Move};
|
||||||
|
use socktop_swipe::input::{Event, Touchpanel};
|
||||||
|
use socktop_swipe::session::tmux::Tmux;
|
||||||
|
use socktop_swipe::session::Multiplexer;
|
||||||
|
use socktop_swipe::{doctor, monitor};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(
|
||||||
|
name = "socktop-swipe",
|
||||||
|
version,
|
||||||
|
about = "Swipe between terminal dashboards on a touchscreen",
|
||||||
|
long_about = None
|
||||||
|
)]
|
||||||
|
struct Cli {
|
||||||
|
/// Config file. Defaults to ~/.config/socktop-swipe/config.yaml, then
|
||||||
|
/// /etc/socktop-swipe/config.yaml.
|
||||||
|
#[arg(short, long, global = true, value_name = "PATH")]
|
||||||
|
config: Option<PathBuf>,
|
||||||
|
|
||||||
|
#[command(subcommand)]
|
||||||
|
command: Option<Cmd>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Subcommand)]
|
||||||
|
enum Cmd {
|
||||||
|
/// Build the session, open it in a terminal and start reading the panel.
|
||||||
|
/// This is the one command to autostart.
|
||||||
|
Run {
|
||||||
|
/// Do not open the touch panel; navigate only via the control socket
|
||||||
|
/// (`socktop-swipe forward`, and so on). For a box whose panel is not
|
||||||
|
/// wired up yet, or for driving the grid from the keyboard.
|
||||||
|
#[arg(long)]
|
||||||
|
no_touch: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Build the session and attach to it in this terminal. No gestures.
|
||||||
|
Attach,
|
||||||
|
|
||||||
|
/// Build the session and leave it running detached, without attaching.
|
||||||
|
/// For splitting the terminal and the gesture daemon into separate units.
|
||||||
|
Build,
|
||||||
|
|
||||||
|
/// Read the panel and drive an already-running session. For a systemd split.
|
||||||
|
Daemon {
|
||||||
|
/// Do not open the touch panel; control socket only.
|
||||||
|
#[arg(long)]
|
||||||
|
no_touch: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Check the config, resolve the grid and print the map.
|
||||||
|
Validate,
|
||||||
|
|
||||||
|
/// Watch the touch panel and explain what it sees.
|
||||||
|
Doctor {
|
||||||
|
/// List candidate touch devices instead of watching one.
|
||||||
|
#[arg(long)]
|
||||||
|
list: bool,
|
||||||
|
},
|
||||||
|
|
||||||
|
/// Move deeper into the grid, as a forward swipe would.
|
||||||
|
#[command(visible_alias = "next")]
|
||||||
|
Forward,
|
||||||
|
/// Move back, as a backward swipe would.
|
||||||
|
#[command(visible_alias = "prev")]
|
||||||
|
Back,
|
||||||
|
/// Move to the row above.
|
||||||
|
Up,
|
||||||
|
/// Move to the row below.
|
||||||
|
Down,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
if let Err(e) = real_main() {
|
||||||
|
eprintln!("socktop-swipe: {e:#}");
|
||||||
|
std::process::exit(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn real_main() -> Result<()> {
|
||||||
|
let cli = Cli::parse();
|
||||||
|
|
||||||
|
// --list is the one command that must work before a config exists.
|
||||||
|
if let Some(Cmd::Doctor { list: true }) = cli.command {
|
||||||
|
return doctor::list();
|
||||||
|
}
|
||||||
|
|
||||||
|
let path = Config::locate(cli.config.as_deref())?;
|
||||||
|
let cfg = Config::load(&path)?;
|
||||||
|
|
||||||
|
match cli.command.unwrap_or(Cmd::Run { no_touch: false }) {
|
||||||
|
Cmd::Validate => validate(&cfg, &path),
|
||||||
|
Cmd::Doctor { .. } => doctor::run(&cfg),
|
||||||
|
Cmd::Build => {
|
||||||
|
let (tmux, grid) = build(&cfg)?;
|
||||||
|
tmux.build(&grid)?;
|
||||||
|
println!("session {:?} is up. Attach with: tmux attach -t {}", cfg.session, cfg.session);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
Cmd::Attach => {
|
||||||
|
let (tmux, grid) = build(&cfg)?;
|
||||||
|
tmux.build(&grid)?;
|
||||||
|
tmux.attach()?;
|
||||||
|
unreachable!()
|
||||||
|
}
|
||||||
|
Cmd::Daemon { no_touch } => daemon(&cfg, no_touch),
|
||||||
|
Cmd::Run { no_touch } => run(&cfg, no_touch),
|
||||||
|
Cmd::Forward => step(&cfg, "forward"),
|
||||||
|
Cmd::Back => step(&cfg, "back"),
|
||||||
|
Cmd::Up => step(&cfg, "up"),
|
||||||
|
Cmd::Down => step(&cfg, "down"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn step(cfg: &Config, direction: &str) -> Result<()> {
|
||||||
|
println!("{}", control::send(&cfg.session, direction)?);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(cfg: &Config) -> Result<(Tmux, Grid)> {
|
||||||
|
let grid = Grid::new(monitor::build_cells(cfg)?)?;
|
||||||
|
Ok((Tmux::new(&cfg.session, cfg.indicator), grid))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn validate(cfg: &Config, path: &std::path::Path) -> Result<()> {
|
||||||
|
let grid = Grid::new(monitor::build_cells(cfg)?)?;
|
||||||
|
|
||||||
|
println!("{}\n", path.display());
|
||||||
|
|
||||||
|
let mut missing = Vec::new();
|
||||||
|
for row in &grid.rows {
|
||||||
|
println!("row {}:", row.row);
|
||||||
|
for cell in &row.cells {
|
||||||
|
let start = if cell.coord == config::Coord::new(0, 0) { " <- start" } else { "" };
|
||||||
|
println!(
|
||||||
|
" {:<7} {:<20} {} screen{}{start}",
|
||||||
|
cell.coord.to_string(),
|
||||||
|
cell.kind.to_string(),
|
||||||
|
cell.screens(),
|
||||||
|
if cell.screens() == 1 { "" } else { "s" },
|
||||||
|
);
|
||||||
|
for pane in &cell.panes {
|
||||||
|
let bin = &pane.command[0];
|
||||||
|
let ok = which(bin);
|
||||||
|
if !ok {
|
||||||
|
missing.push(bin.clone());
|
||||||
|
}
|
||||||
|
println!(
|
||||||
|
" {} {}",
|
||||||
|
if ok { "\u{2713}" } else { "\u{2717}" },
|
||||||
|
shell_words::join(pane.command.iter().map(String::as_str))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
println!();
|
||||||
|
}
|
||||||
|
|
||||||
|
if grid.cells().all(|c| c.coord != config::Coord::new(0, 0)) {
|
||||||
|
println!(
|
||||||
|
"note: no screen at 0x0, so the display starts at {} instead.\n",
|
||||||
|
grid.cell().coord
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if !std::path::Path::new(&cfg.touch.device).exists() {
|
||||||
|
println!("touch: \u{2717} {} is not present right now", cfg.touch.device);
|
||||||
|
println!(" `socktop-swipe doctor --list` shows what is.\n");
|
||||||
|
} else {
|
||||||
|
println!("touch: \u{2713} {}\n", cfg.touch.device);
|
||||||
|
}
|
||||||
|
|
||||||
|
if missing.is_empty() {
|
||||||
|
println!("Config is valid.");
|
||||||
|
} else {
|
||||||
|
missing.sort();
|
||||||
|
missing.dedup();
|
||||||
|
println!("Config parses, but these are not installed or not on PATH:");
|
||||||
|
for m in &missing {
|
||||||
|
println!(" {m}");
|
||||||
|
}
|
||||||
|
println!("\nSet full paths under `binaries:` if they are installed elsewhere.");
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn which(bin: &str) -> bool {
|
||||||
|
if bin.contains('/') {
|
||||||
|
return std::path::Path::new(bin).is_file();
|
||||||
|
}
|
||||||
|
std::env::var_os("PATH")
|
||||||
|
.map(|p| {
|
||||||
|
std::env::split_paths(&p).any(|dir| dir.join(bin).is_file())
|
||||||
|
})
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build the session, hand it to a terminal, and read the panel until that
|
||||||
|
/// terminal exits. One process, so a second copy cannot fight the first over
|
||||||
|
/// the input device -- the evdev grab fails immediately and says so.
|
||||||
|
fn run(cfg: &Config, no_touch: bool) -> Result<()> {
|
||||||
|
let (tmux, mut grid) = build(cfg)?;
|
||||||
|
tmux.build(&grid)?;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let _socket = control::listen(&cfg.session, tx.clone())?;
|
||||||
|
let _guard = control::SocketGuard(control::socket_path(&cfg.session));
|
||||||
|
|
||||||
|
let attach = tmux.attach_argv();
|
||||||
|
let mut child = match &cfg.terminal {
|
||||||
|
Some(term) => {
|
||||||
|
let mut argv = shell_words::split(term)
|
||||||
|
.with_context(|| format!("cannot parse terminal: {term}"))?;
|
||||||
|
let prog = argv.remove(0);
|
||||||
|
Command::new(&prog)
|
||||||
|
.args(argv)
|
||||||
|
.arg("-e")
|
||||||
|
.args(&attach)
|
||||||
|
.spawn()
|
||||||
|
.with_context(|| format!("cannot start terminal {prog:?}"))?
|
||||||
|
}
|
||||||
|
None => Command::new(&attach[0])
|
||||||
|
.args(&attach[1..])
|
||||||
|
.spawn()
|
||||||
|
.context("cannot attach to the session")?,
|
||||||
|
};
|
||||||
|
|
||||||
|
// Closing the dashboard window is the ordinary way this ends.
|
||||||
|
let quit = tx.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let _ = child.wait();
|
||||||
|
let _ = quit.send(Ctl::Quit);
|
||||||
|
});
|
||||||
|
|
||||||
|
if !no_touch {
|
||||||
|
spawn_panel(cfg, tx)?;
|
||||||
|
}
|
||||||
|
drive(&tmux, &mut grid, rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn daemon(cfg: &Config, no_touch: bool) -> Result<()> {
|
||||||
|
let (tmux, mut grid) = build(cfg)?;
|
||||||
|
if !tmux.is_running() {
|
||||||
|
anyhow::bail!(
|
||||||
|
"no session named {:?}. Start one with `socktop-swipe attach`, \
|
||||||
|
or use `socktop-swipe run` to do both.",
|
||||||
|
cfg.session
|
||||||
|
);
|
||||||
|
}
|
||||||
|
tmux.adopt(&grid)?;
|
||||||
|
|
||||||
|
let (tx, rx) = mpsc::channel();
|
||||||
|
let _socket = control::listen(&cfg.session, tx.clone())?;
|
||||||
|
let _guard = control::SocketGuard(control::socket_path(&cfg.session));
|
||||||
|
if !no_touch {
|
||||||
|
spawn_panel(cfg, tx)?;
|
||||||
|
} else {
|
||||||
|
// Without a panel there is nothing else to hold the channel open.
|
||||||
|
drop(tx);
|
||||||
|
}
|
||||||
|
drive(&tmux, &mut grid, rx)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Read the panel on its own thread. Opening it here rather than in the thread
|
||||||
|
/// keeps a permission or grab failure on the main path, where it can be
|
||||||
|
/// reported properly instead of vanishing into a detached thread.
|
||||||
|
fn spawn_panel(cfg: &Config, tx: mpsc::Sender<Ctl>) -> Result<()> {
|
||||||
|
let mut panel = Touchpanel::open(&cfg.touch)?;
|
||||||
|
let gestures = cfg.gestures.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let result = panel.run(|ev| {
|
||||||
|
let Event::Swipe(s) = ev else { return true };
|
||||||
|
match binding(&gestures, s.direction) {
|
||||||
|
Some(m) => tx.send(Ctl::Go(m, None)).is_ok(),
|
||||||
|
None => true,
|
||||||
|
}
|
||||||
|
});
|
||||||
|
if let Err(e) = result {
|
||||||
|
let _ = tx.send(Ctl::Failed(e));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drive(tmux: &Tmux, grid: &mut Grid, rx: mpsc::Receiver<Ctl>) -> Result<()> {
|
||||||
|
for msg in rx {
|
||||||
|
match msg {
|
||||||
|
Ctl::Quit => return Ok(()),
|
||||||
|
Ctl::Failed(e) => return Err(e),
|
||||||
|
Ctl::Go(m, reply) => {
|
||||||
|
let pos = grid.apply(m);
|
||||||
|
let outcome = if pos.changed {
|
||||||
|
tmux.show(grid, &pos)
|
||||||
|
} else {
|
||||||
|
Ok(())
|
||||||
|
};
|
||||||
|
if let Err(e) = outcome {
|
||||||
|
// A vanished session is the normal way this ends: the user
|
||||||
|
// closed the terminal. Anything else is worth surfacing.
|
||||||
|
if !tmux.is_running() {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
answer(reply, &format!("error: {e:#}"));
|
||||||
|
return Err(e);
|
||||||
|
}
|
||||||
|
let edge = if pos.changed { "" } else { " (edge, nothing to move to)" };
|
||||||
|
answer(
|
||||||
|
reply,
|
||||||
|
&format!("{} {}{edge}", pos.coord, grid.cell().screen_label()),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Tell a waiting movement subcommand what happened. Best effort: it may have
|
||||||
|
/// been interrupted, and a gesture carries no stream at all.
|
||||||
|
fn answer(reply: Option<std::os::unix::net::UnixStream>, text: &str) {
|
||||||
|
use std::io::Write;
|
||||||
|
if let Some(mut r) = reply {
|
||||||
|
let _ = writeln!(r, "{text}");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn binding(g: &config::Gestures, d: Direction) -> Option<Move> {
|
||||||
|
Some(match d {
|
||||||
|
_ if d == g.forward => Move::Forward,
|
||||||
|
_ if d == g.back => Move::Back,
|
||||||
|
_ if d == g.up => Move::Up,
|
||||||
|
_ if d == g.down => Move::Down,
|
||||||
|
_ => return None,
|
||||||
|
})
|
||||||
|
}
|
||||||
+169
@@ -0,0 +1,169 @@
|
|||||||
|
//! Turning a config screen into the panes tmux should run.
|
||||||
|
//!
|
||||||
|
//! Only `socktop` produces more than one pane today. The representation is a
|
||||||
|
//! plain `Vec<Pane>` per cell, so a future type wanting the same treatment is a
|
||||||
|
//! data change rather than a redesign.
|
||||||
|
|
||||||
|
use anyhow::{bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::config::{expand_tilde, Binaries, Config, Coord, Layout, MonitorType, Screen};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||||
|
pub struct Pane {
|
||||||
|
/// Shown in the pane border.
|
||||||
|
pub title: String,
|
||||||
|
/// argv, already split. Never passed through a shell.
|
||||||
|
pub command: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct Cell {
|
||||||
|
pub coord: Coord,
|
||||||
|
pub kind: MonitorType,
|
||||||
|
/// Human label for the position indicator.
|
||||||
|
pub label: String,
|
||||||
|
pub panes: Vec<Pane>,
|
||||||
|
pub layout: Layout,
|
||||||
|
/// Which sub-screen of this cell is current. Persists while you are
|
||||||
|
/// elsewhere, so a vertical return lands where you left.
|
||||||
|
pub cursor: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Cell {
|
||||||
|
/// A multi-pane cell shows a tiled overview first, then each pane zoomed.
|
||||||
|
/// A single-pane cell has nothing to zoom into, so it is one sub-screen.
|
||||||
|
pub fn has_overview(&self) -> bool {
|
||||||
|
self.panes.len() > 1
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn screens(&self) -> usize {
|
||||||
|
if self.has_overview() {
|
||||||
|
self.panes.len() + 1
|
||||||
|
} else {
|
||||||
|
1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn last_screen(&self) -> usize {
|
||||||
|
self.screens() - 1
|
||||||
|
}
|
||||||
|
|
||||||
|
/// `None` for the overview, otherwise the pane index to zoom.
|
||||||
|
pub fn zoomed_pane(&self) -> Option<usize> {
|
||||||
|
match (self.has_overview(), self.cursor) {
|
||||||
|
(true, 0) => None,
|
||||||
|
(true, i) => Some(i - 1),
|
||||||
|
(false, _) => Some(0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// What the indicator shows for the current sub-screen.
|
||||||
|
pub fn screen_label(&self) -> String {
|
||||||
|
match self.zoomed_pane() {
|
||||||
|
None => format!("{} (all)", self.label),
|
||||||
|
Some(i) if self.has_overview() => self.panes[i].title.clone(),
|
||||||
|
Some(_) => self.label.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_cell(screen: &Screen, bins: &Binaries) -> Result<Cell> {
|
||||||
|
let at = screen.at;
|
||||||
|
let extra = screen.args.clone().unwrap_or_default();
|
||||||
|
|
||||||
|
// An explicit `command:` replaces the generated one for every type.
|
||||||
|
if let Some(cmd) = &screen.command {
|
||||||
|
let mut argv = shell_words::split(cmd)
|
||||||
|
.with_context(|| format!("screen {at}: cannot parse command: {cmd}"))?;
|
||||||
|
if argv.is_empty() {
|
||||||
|
bail!("screen {at}: command is empty");
|
||||||
|
}
|
||||||
|
argv[0] = expand_tilde(&argv[0]);
|
||||||
|
argv.extend(extra);
|
||||||
|
let title = screen
|
||||||
|
.title
|
||||||
|
.clone()
|
||||||
|
.unwrap_or_else(|| screen.kind.to_string());
|
||||||
|
return Ok(Cell {
|
||||||
|
coord: at,
|
||||||
|
kind: screen.kind,
|
||||||
|
label: title.clone(),
|
||||||
|
panes: vec![Pane { title, command: argv }],
|
||||||
|
layout: screen.layout.unwrap_or_default(),
|
||||||
|
cursor: 0,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let bin = bins.get(screen.kind);
|
||||||
|
|
||||||
|
let (label, panes) = match screen.kind {
|
||||||
|
MonitorType::Socktop => {
|
||||||
|
let hosts = screen
|
||||||
|
.socktop_group
|
||||||
|
.as_ref()
|
||||||
|
.expect("validated: socktop needs socktop_group");
|
||||||
|
let panes = hosts
|
||||||
|
.0
|
||||||
|
.iter()
|
||||||
|
.map(|h| Pane {
|
||||||
|
title: h.clone(),
|
||||||
|
command: {
|
||||||
|
let mut c = vec![bin.clone(), "-P".into(), h.clone()];
|
||||||
|
c.extend(extra.clone());
|
||||||
|
c
|
||||||
|
},
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
let label = if hosts.0.len() == 1 {
|
||||||
|
hosts.0[0].clone()
|
||||||
|
} else {
|
||||||
|
format!("{} hosts", hosts.0.len())
|
||||||
|
};
|
||||||
|
(label, panes)
|
||||||
|
}
|
||||||
|
|
||||||
|
MonitorType::UptimeKumaStatus => {
|
||||||
|
let url = screen.url.as_ref().expect("validated: kuma needs url");
|
||||||
|
let mut c = vec![bin, url.clone()];
|
||||||
|
c.extend(extra);
|
||||||
|
(
|
||||||
|
"uptime kuma".to_string(),
|
||||||
|
vec![Pane { title: "uptime kuma".into(), command: c }],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MonitorType::Unifly => {
|
||||||
|
let mut c = vec![bin, "tui".into()];
|
||||||
|
// Carved out for when the fork grows the flags; see notes/PLAN-v2.md.
|
||||||
|
if let Some(site) = &screen.site {
|
||||||
|
c.push("--site".into());
|
||||||
|
c.push(site.clone());
|
||||||
|
}
|
||||||
|
if let Some(controller) = &screen.controller {
|
||||||
|
c.push("--controller".into());
|
||||||
|
c.push(controller.clone());
|
||||||
|
}
|
||||||
|
c.extend(extra);
|
||||||
|
(
|
||||||
|
"unifly".to_string(),
|
||||||
|
vec![Pane { title: "unifly".into(), command: c }],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
MonitorType::Generic => unreachable!("validated: generic always has a command"),
|
||||||
|
};
|
||||||
|
|
||||||
|
let label = screen.title.clone().unwrap_or(label);
|
||||||
|
Ok(Cell {
|
||||||
|
coord: at,
|
||||||
|
kind: screen.kind,
|
||||||
|
label,
|
||||||
|
panes,
|
||||||
|
layout: screen.layout.unwrap_or_default(),
|
||||||
|
cursor: 0,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn build_cells(cfg: &Config) -> Result<Vec<Cell>> {
|
||||||
|
cfg.screens.iter().map(|s| build_cell(s, &cfg.binaries)).collect()
|
||||||
|
}
|
||||||
@@ -0,0 +1,32 @@
|
|||||||
|
//! The multiplexer boundary.
|
||||||
|
//!
|
||||||
|
//! This module and its children are the ONLY place that knows tmux is in use.
|
||||||
|
//! zellij is shelved rather than rejected (notes/DESIGN.md); keeping the seam
|
||||||
|
//! here is what makes revisiting that cheap.
|
||||||
|
|
||||||
|
pub mod tmux;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
use crate::grid::{Grid, Position};
|
||||||
|
|
||||||
|
pub trait Multiplexer {
|
||||||
|
/// Tear down any previous session and build one window per cell.
|
||||||
|
fn build(&self, grid: &Grid) -> Result<()>;
|
||||||
|
|
||||||
|
/// Learn the layout of a session that is already running, and check it
|
||||||
|
/// matches this grid.
|
||||||
|
fn adopt(&self, grid: &Grid) -> Result<()>;
|
||||||
|
|
||||||
|
/// Make `pos` the visible screen.
|
||||||
|
fn show(&self, grid: &Grid, pos: &Position) -> Result<()>;
|
||||||
|
|
||||||
|
/// True when the session exists right now.
|
||||||
|
fn is_running(&self) -> bool;
|
||||||
|
|
||||||
|
/// Replace this process with a client attached to the session.
|
||||||
|
fn attach(&self) -> Result<std::convert::Infallible>;
|
||||||
|
|
||||||
|
/// Command line that attaches a client, for handing to a terminal emulator.
|
||||||
|
fn attach_argv(&self) -> Vec<String>;
|
||||||
|
}
|
||||||
@@ -0,0 +1,303 @@
|
|||||||
|
//! tmux implementation of [`Multiplexer`].
|
||||||
|
//!
|
||||||
|
//! Windows and panes are addressed by tmux *id* (`@3`, `%12`), captured at
|
||||||
|
//! creation, never by index. Indices renumber when a pane dies; ids do not, and
|
||||||
|
//! `remain-on-exit` cannot be relied on to hold the numbering stable if a
|
||||||
|
//! monitor program is killed by hand.
|
||||||
|
|
||||||
|
use std::cell::RefCell;
|
||||||
|
use std::collections::HashMap;
|
||||||
|
use std::os::unix::process::CommandExt;
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use anyhow::{anyhow, bail, Context, Result};
|
||||||
|
|
||||||
|
use crate::config::Coord;
|
||||||
|
use crate::grid::{Grid, Position};
|
||||||
|
use crate::monitor::Cell;
|
||||||
|
|
||||||
|
use super::Multiplexer;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
struct Placed {
|
||||||
|
window: String,
|
||||||
|
panes: Vec<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Tmux {
|
||||||
|
session: String,
|
||||||
|
indicator: bool,
|
||||||
|
placed: RefCell<HashMap<Coord, Placed>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tmux {
|
||||||
|
pub fn new(session: &str, indicator: bool) -> Self {
|
||||||
|
Self {
|
||||||
|
session: session.to_owned(),
|
||||||
|
indicator,
|
||||||
|
placed: RefCell::new(HashMap::new()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn run(&self, args: &[&str]) -> Result<String> {
|
||||||
|
let out = Command::new("tmux")
|
||||||
|
.args(args)
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.output()
|
||||||
|
.context("cannot run tmux -- is it installed and on PATH?")?;
|
||||||
|
if !out.status.success() {
|
||||||
|
bail!(
|
||||||
|
"tmux {} failed: {}",
|
||||||
|
args.join(" "),
|
||||||
|
String::from_utf8_lossy(&out.stderr).trim()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
Ok(String::from_utf8_lossy(&out.stdout).trim().to_owned())
|
||||||
|
}
|
||||||
|
|
||||||
|
/// tmux takes the command as one string and runs it through `sh -c`, so the
|
||||||
|
/// argv has to be quoted back into a shell word list. Building argv first
|
||||||
|
/// and quoting once here keeps every caller free of quoting concerns.
|
||||||
|
///
|
||||||
|
/// The command is wrapped so the pane outlives it. A monitor that exits --
|
||||||
|
/// a typo in a `generic` command, a socktop that cannot reach its agent --
|
||||||
|
/// would otherwise take its pane with it, and tmux destroys a window when
|
||||||
|
/// its last pane goes. During construction that breaks the next
|
||||||
|
/// `split-window` with a baffling "no current target"; afterwards it
|
||||||
|
/// silently reshuffles the display. Keeping the pane means the failure is
|
||||||
|
/// visible ON the wall display, with its exit status, which is the whole
|
||||||
|
/// point of a wall display.
|
||||||
|
///
|
||||||
|
/// This replaces v1's `remain-on-exit`, which cannot do the job: it is a
|
||||||
|
/// per-window option that new windows do not inherit, so there is always a
|
||||||
|
/// gap between creating a window and setting it.
|
||||||
|
fn shell_command(argv: &[String]) -> String {
|
||||||
|
let cmd = shell_words::join(argv.iter().map(String::as_str));
|
||||||
|
let name = shell_words::quote(&argv[0]).into_owned();
|
||||||
|
format!(
|
||||||
|
"{cmd}; s=$?; printf '\\n[%s exited: status %s]\\n' {name} \"$s\"; \
|
||||||
|
while :; do sleep 86400; done"
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn zoomed(&self, window: &str) -> Result<bool> {
|
||||||
|
Ok(self.run(&["display-message", "-p", "-t", window, "#{window_zoomed_flag}"])? == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn place(&self, cell: &Cell, first: bool) -> Result<Placed> {
|
||||||
|
let name = cell.coord.window_name();
|
||||||
|
let head = Self::shell_command(&cell.panes[0].command);
|
||||||
|
|
||||||
|
let window = if first {
|
||||||
|
self.run(&[
|
||||||
|
"new-session", "-d", "-s", &self.session, "-n", &name,
|
||||||
|
"-P", "-F", "#{window_id}", &head,
|
||||||
|
])?
|
||||||
|
} else {
|
||||||
|
self.run(&[
|
||||||
|
"new-window", "-d", "-t", &self.session, "-n", &name,
|
||||||
|
"-P", "-F", "#{window_id}", &head,
|
||||||
|
])?
|
||||||
|
};
|
||||||
|
|
||||||
|
let first_pane = self.run(&[
|
||||||
|
"display-message", "-p", "-t", &window, "#{pane_id}",
|
||||||
|
])?;
|
||||||
|
self.run(&["select-pane", "-t", &first_pane, "-T", &cell.panes[0].title])?;
|
||||||
|
let mut panes = vec![first_pane];
|
||||||
|
|
||||||
|
for pane in &cell.panes[1..] {
|
||||||
|
// Split the most recently created pane so creation order matches
|
||||||
|
// the visual order the layout will impose.
|
||||||
|
let id = self.run(&[
|
||||||
|
"split-window", "-t", panes.last().unwrap(), "-P", "-F", "#{pane_id}",
|
||||||
|
&Self::shell_command(&pane.command),
|
||||||
|
])?;
|
||||||
|
self.run(&["select-pane", "-t", &id, "-T", &pane.title])?;
|
||||||
|
panes.push(id);
|
||||||
|
}
|
||||||
|
|
||||||
|
if panes.len() > 1 {
|
||||||
|
self.run(&["select-layout", "-t", &window, cell.layout.as_tmux()])?;
|
||||||
|
}
|
||||||
|
Ok(Placed { window, panes })
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Learn the window and pane ids of a session someone else built.
|
||||||
|
///
|
||||||
|
/// `daemon` drives a session it did not create, so it has none of the ids
|
||||||
|
/// `build` captured. Rediscovering them by window name is also the check
|
||||||
|
/// that the running session actually matches this config -- otherwise the
|
||||||
|
/// daemon would cheerfully drive a stale layout.
|
||||||
|
fn adopt(&self, grid: &Grid) -> Result<()> {
|
||||||
|
let listing = self.run(&[
|
||||||
|
"list-windows", "-t", &self.session, "-F", "#{window_id} #{window_name}",
|
||||||
|
])?;
|
||||||
|
let mut by_name: HashMap<&str, &str> = HashMap::new();
|
||||||
|
for line in listing.lines() {
|
||||||
|
if let Some((id, name)) = line.split_once(' ') {
|
||||||
|
by_name.insert(name, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut placed = HashMap::new();
|
||||||
|
for cell in grid.cells() {
|
||||||
|
let name = cell.coord.window_name();
|
||||||
|
let window = by_name.get(name.as_str()).copied().ok_or_else(|| {
|
||||||
|
anyhow!(
|
||||||
|
"the running session {:?} has no window for {} -- it was built from a \
|
||||||
|
different config.\nRebuild it with `socktop-swipe build`.",
|
||||||
|
self.session,
|
||||||
|
cell.coord
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
let panes: Vec<String> = self
|
||||||
|
.run(&["list-panes", "-t", window, "-F", "#{pane_id}"])?
|
||||||
|
.lines()
|
||||||
|
.map(str::to_owned)
|
||||||
|
.collect();
|
||||||
|
if panes.len() != cell.panes.len() {
|
||||||
|
bail!(
|
||||||
|
"{} has {} pane(s) in the running session but {} in the config -- \
|
||||||
|
it was built from a different config.\nRebuild it with \
|
||||||
|
`socktop-swipe build`.",
|
||||||
|
cell.coord,
|
||||||
|
panes.len(),
|
||||||
|
cell.panes.len()
|
||||||
|
);
|
||||||
|
}
|
||||||
|
placed.insert(cell.coord, Placed { window: (*window).to_owned(), panes });
|
||||||
|
}
|
||||||
|
*self.placed.borrow_mut() = placed;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn lookup(&self, coord: Coord) -> Result<Placed> {
|
||||||
|
self.placed
|
||||||
|
.borrow()
|
||||||
|
.get(&coord)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| anyhow!("no tmux window for {coord} -- was the session rebuilt?"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn set_indicator(&self, grid: &Grid) -> Result<()> {
|
||||||
|
if !self.indicator {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let cell = grid.cell();
|
||||||
|
let (up, down) = grid.neighbours();
|
||||||
|
let mut parts = Vec::new();
|
||||||
|
if let Some(u) = up {
|
||||||
|
parts.push(format!("{} \u{25b2}", u.label));
|
||||||
|
}
|
||||||
|
let here = if cell.screens() > 1 {
|
||||||
|
format!(" {} [{}/{}] ", cell.screen_label(), cell.cursor + 1, cell.screens())
|
||||||
|
} else {
|
||||||
|
format!(" {} ", cell.screen_label())
|
||||||
|
};
|
||||||
|
parts.push(here);
|
||||||
|
if let Some(d) = down {
|
||||||
|
parts.push(format!("\u{25bc} {}", d.label));
|
||||||
|
}
|
||||||
|
let text = format!(" {} ", parts.join(" \u{b7} "));
|
||||||
|
self.run(&["set-option", "-t", &self.session, "status-left", &text])?;
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Multiplexer for Tmux {
|
||||||
|
fn adopt(&self, grid: &Grid) -> Result<()> {
|
||||||
|
Tmux::adopt(self, grid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build(&self, grid: &Grid) -> Result<()> {
|
||||||
|
let _ = Command::new("tmux")
|
||||||
|
.args(["kill-session", "-t", &self.session])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
|
||||||
|
let mut placed = HashMap::new();
|
||||||
|
for (i, cell) in grid.cells().enumerate() {
|
||||||
|
placed.insert(cell.coord, self.place(cell, i == 0)?);
|
||||||
|
}
|
||||||
|
*self.placed.borrow_mut() = placed;
|
||||||
|
|
||||||
|
let s = &self.session;
|
||||||
|
self.run(&["set-option", "-t", s, "pane-border-status", "top"])?;
|
||||||
|
self.run(&["set-option", "-t", s, "pane-border-format", " #{pane_title} "])?;
|
||||||
|
|
||||||
|
// Mouse mode MUST stay off. With it on, a touch swipe is also delivered
|
||||||
|
// to tmux as a click-drag: dragging across a pane border resizes it and
|
||||||
|
// taps reselect panes, both fighting the gesture layer. Exclusive evdev
|
||||||
|
// grab normally prevents this, but a panel that fails to grab would
|
||||||
|
// otherwise produce exactly the v1 symptom.
|
||||||
|
self.run(&["set-option", "-t", s, "mouse", "off"])?;
|
||||||
|
|
||||||
|
if self.indicator {
|
||||||
|
self.run(&["set-option", "-t", s, "status", "on"])?;
|
||||||
|
self.run(&["set-option", "-t", s, "status-style", "bg=default"])?;
|
||||||
|
self.run(&["set-option", "-t", s, "status-right", ""])?;
|
||||||
|
self.run(&["set-option", "-t", s, "status-left-length", "200"])?;
|
||||||
|
} else {
|
||||||
|
self.run(&["set-option", "-t", s, "status", "off"])?;
|
||||||
|
}
|
||||||
|
|
||||||
|
self.show(grid, &grid.position(true))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show(&self, grid: &Grid, pos: &Position) -> Result<()> {
|
||||||
|
let placed = self.lookup(pos.coord)?;
|
||||||
|
self.run(&["select-window", "-t", &placed.window])?;
|
||||||
|
|
||||||
|
let cell = grid.cell();
|
||||||
|
match cell.zoomed_pane() {
|
||||||
|
None => {
|
||||||
|
if self.zoomed(&placed.window)? {
|
||||||
|
self.run(&["resize-pane", "-Z", "-t", &placed.window])?;
|
||||||
|
}
|
||||||
|
// Building a window leaves the LAST split pane active, and
|
||||||
|
// coming back from a zoom leaves whichever was zoomed. Neither
|
||||||
|
// is what "the overview" should highlight, and the active pane
|
||||||
|
// border is visible on the wall. Always the first.
|
||||||
|
self.run(&["select-pane", "-t", &placed.panes[0]])?;
|
||||||
|
}
|
||||||
|
Some(i) => {
|
||||||
|
let pane = placed
|
||||||
|
.panes
|
||||||
|
.get(i)
|
||||||
|
.ok_or_else(|| anyhow!("{} has no pane {i}", pos.coord))?;
|
||||||
|
// Selecting a different pane auto-unzooms, so zoom afterwards
|
||||||
|
// and only if the window is not already zoomed.
|
||||||
|
self.run(&["select-pane", "-t", pane])?;
|
||||||
|
if cell.has_overview() && !self.zoomed(&placed.window)? {
|
||||||
|
self.run(&["resize-pane", "-Z", "-t", pane])?;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
self.set_indicator(grid)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_running(&self) -> bool {
|
||||||
|
Command::new("tmux")
|
||||||
|
.args(["has-session", "-t", &self.session])
|
||||||
|
.stdin(Stdio::null())
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach(&self) -> Result<std::convert::Infallible> {
|
||||||
|
let err = Command::new("tmux")
|
||||||
|
.args(["attach", "-t", &self.session])
|
||||||
|
.exec();
|
||||||
|
Err(err).context("cannot exec tmux attach")
|
||||||
|
}
|
||||||
|
|
||||||
|
fn attach_argv(&self) -> Vec<String> {
|
||||||
|
vec!["tmux".into(), "attach".into(), "-t".into(), self.session.clone()]
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,182 @@
|
|||||||
|
//! End-to-end against a real tmux server.
|
||||||
|
//!
|
||||||
|
//! Covers what the unit tests deliberately cannot: that the grid's idea of
|
||||||
|
//! "sub-screen 3 of the cell at 0x0" lands on the pane a person would expect,
|
||||||
|
//! zoomed. Skipped when tmux is not installed.
|
||||||
|
|
||||||
|
use std::process::{Command, Stdio};
|
||||||
|
|
||||||
|
use socktop_swipe::config::Config;
|
||||||
|
use socktop_swipe::grid::{Grid, Move};
|
||||||
|
use socktop_swipe::monitor;
|
||||||
|
use socktop_swipe::session::tmux::Tmux;
|
||||||
|
use socktop_swipe::session::Multiplexer;
|
||||||
|
|
||||||
|
const SESSION: &str = "socktop-swipe-selftest";
|
||||||
|
|
||||||
|
fn have_tmux() -> bool {
|
||||||
|
Command::new("tmux")
|
||||||
|
.arg("-V")
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status()
|
||||||
|
.map(|s| s.success())
|
||||||
|
.unwrap_or(false)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn tmux(args: &[&str]) -> String {
|
||||||
|
let out = Command::new("tmux").args(args).output().expect("tmux");
|
||||||
|
String::from_utf8_lossy(&out.stdout).trim().to_owned()
|
||||||
|
}
|
||||||
|
|
||||||
|
fn config() -> Config {
|
||||||
|
// `true` exits at once, which also exercises remain-on-exit keeping the
|
||||||
|
// pane addressable afterwards.
|
||||||
|
let yaml = format!(
|
||||||
|
r#"
|
||||||
|
session: {SESSION}
|
||||||
|
binaries: {{ socktop: /bin/echo, unifly: /bin/echo, uptime-kuma-status: /bin/echo }}
|
||||||
|
touch: {{ device: /dev/null, width: 1280, height: 720, grab: false }}
|
||||||
|
screens:
|
||||||
|
- at: "-1x0"
|
||||||
|
type: unifly
|
||||||
|
- at: "0x0"
|
||||||
|
type: socktop
|
||||||
|
socktop_group: [alpha, bravo, charlie, delta]
|
||||||
|
layout: tiled
|
||||||
|
- at: "0x5"
|
||||||
|
type: socktop
|
||||||
|
socktop_group: "echo1, foxtrot"
|
||||||
|
layout: even-vertical
|
||||||
|
- at: "1x0"
|
||||||
|
type: uptime-kuma-status
|
||||||
|
url: https://example.invalid/status
|
||||||
|
"#
|
||||||
|
);
|
||||||
|
serde_yaml::from_str(&yaml).expect("test config should parse")
|
||||||
|
}
|
||||||
|
|
||||||
|
/// (active window name, active pane title, is the window zoomed)
|
||||||
|
fn visible() -> (String, String, bool) {
|
||||||
|
let s = tmux(&[
|
||||||
|
"display-message",
|
||||||
|
"-p",
|
||||||
|
"-t",
|
||||||
|
SESSION,
|
||||||
|
"#{window_name}\t#{pane_title}\t#{window_zoomed_flag}",
|
||||||
|
]);
|
||||||
|
let f: Vec<&str> = s.split('\t').collect();
|
||||||
|
(f[0].into(), f[1].into(), f[2] == "1")
|
||||||
|
}
|
||||||
|
|
||||||
|
struct Cleanup;
|
||||||
|
impl Drop for Cleanup {
|
||||||
|
fn drop(&mut self) {
|
||||||
|
let _ = Command::new("tmux")
|
||||||
|
.args(["kill-session", "-t", SESSION])
|
||||||
|
.stdout(Stdio::null())
|
||||||
|
.stderr(Stdio::null())
|
||||||
|
.status();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn session_layout_and_navigation_match_the_grid() {
|
||||||
|
if !have_tmux() {
|
||||||
|
eprintln!("skipping: tmux is not installed");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let _cleanup = Cleanup;
|
||||||
|
|
||||||
|
let cfg = config();
|
||||||
|
let mut grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
|
||||||
|
let mux = Tmux::new(&cfg.session, false);
|
||||||
|
mux.build(&grid).expect("session should build");
|
||||||
|
|
||||||
|
// One window per cell, in grid order, named by coordinate.
|
||||||
|
let windows = tmux(&["list-windows", "-t", SESSION, "-F", "#{window_name} #{window_panes}"]);
|
||||||
|
assert_eq!(
|
||||||
|
windows.lines().collect::<Vec<_>>(),
|
||||||
|
vec!["rm1c0 1", "r0c0 4", "r0c5 2", "r1c0 1"],
|
||||||
|
"windows should be one per cell, in grid order, with one pane per host"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Starts at 0x0's overview: not zoomed, so all four Pis are visible.
|
||||||
|
let (win, _, zoomed) = visible();
|
||||||
|
assert_eq!(win, "r0c0");
|
||||||
|
assert!(!zoomed, "the overview must not be zoomed");
|
||||||
|
|
||||||
|
// Forward walks the sub-sequence, zooming each host in declaration order.
|
||||||
|
for expected in ["alpha", "bravo", "charlie", "delta"] {
|
||||||
|
let pos = grid.apply(Move::Forward);
|
||||||
|
mux.show(&grid, &pos).unwrap();
|
||||||
|
let (win, title, zoomed) = visible();
|
||||||
|
assert_eq!(win, "r0c0");
|
||||||
|
assert_eq!(title, expected, "wrong host zoomed");
|
||||||
|
assert!(zoomed, "{expected} should be zoomed full-screen");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Past the last host, on to the next cell's overview.
|
||||||
|
let pos = grid.apply(Move::Forward);
|
||||||
|
mux.show(&grid, &pos).unwrap();
|
||||||
|
let (win, _, zoomed) = visible();
|
||||||
|
assert_eq!(win, "r0c5", "0x5 follows 0x0 despite the gap in column numbers");
|
||||||
|
assert!(!zoomed);
|
||||||
|
|
||||||
|
// Back must land on 0x0's LAST host, not its overview.
|
||||||
|
let pos = grid.apply(Move::Back);
|
||||||
|
mux.show(&grid, &pos).unwrap();
|
||||||
|
let (win, title, zoomed) = visible();
|
||||||
|
assert_eq!((win.as_str(), title.as_str(), zoomed), ("r0c0", "delta", true));
|
||||||
|
|
||||||
|
// Up to unifly: a single-pane cell, so nothing to zoom.
|
||||||
|
let pos = grid.apply(Move::Up);
|
||||||
|
mux.show(&grid, &pos).unwrap();
|
||||||
|
let (win, _, zoomed) = visible();
|
||||||
|
assert_eq!(win, "rm1c0");
|
||||||
|
assert!(!zoomed, "a one-pane cell has nothing to zoom into");
|
||||||
|
|
||||||
|
// And back down to exactly the host we left.
|
||||||
|
let pos = grid.apply(Move::Down);
|
||||||
|
mux.show(&grid, &pos).unwrap();
|
||||||
|
let (win, title, zoomed) = visible();
|
||||||
|
assert_eq!(
|
||||||
|
(win.as_str(), title.as_str(), zoomed),
|
||||||
|
("r0c0", "delta", true),
|
||||||
|
"returning must restore the host AND its zoom"
|
||||||
|
);
|
||||||
|
|
||||||
|
// Down twice: through row 0 to kuma.
|
||||||
|
let pos = grid.apply(Move::Down);
|
||||||
|
mux.show(&grid, &pos).unwrap();
|
||||||
|
assert_eq!(visible().0, "r1c0");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn pane_commands_are_quoted_not_interpreted() {
|
||||||
|
if !have_tmux() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
// A profile name containing shell metacharacters must reach the program as
|
||||||
|
// one argument, not be re-split or evaluated by the sh tmux runs it under.
|
||||||
|
let cfg: Config = serde_yaml::from_str(
|
||||||
|
r#"
|
||||||
|
session: socktop-swipe-quoting-test
|
||||||
|
binaries: { socktop: /bin/echo }
|
||||||
|
touch: { device: /dev/null, width: 1, height: 1, grab: false }
|
||||||
|
screens:
|
||||||
|
- at: "0x0"
|
||||||
|
type: socktop
|
||||||
|
socktop_group: ["a b; touch /tmp/socktop-swipe-pwned"]
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.unwrap();
|
||||||
|
|
||||||
|
let cells = monitor::build_cells(&cfg).unwrap();
|
||||||
|
let argv = &cells[0].panes[0].command;
|
||||||
|
assert_eq!(argv, &["/bin/echo", "-P", "a b; touch /tmp/socktop-swipe-pwned"]);
|
||||||
|
|
||||||
|
let quoted = shell_words::join(argv.iter().map(String::as_str));
|
||||||
|
let reparsed = shell_words::split("ed).unwrap();
|
||||||
|
assert_eq!(&reparsed, argv, "quoting must survive the round trip through sh");
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user