Compare commits

...

7 Commits

Author SHA1 Message Date
jason eabce75e5f Update README.md 2026-09-09 21:30:23 +00:00
jason 0bf070131d Merge pull request 'v2: Rust rewrite with a YAML grid layout, evdev gestures and a preflighting installer' (#1) from v2-rust into main
Reviewed-on: #1
2026-09-09 21:27:11 +00:00
jasonwitty ad8ebcaa71 docs: record the LattePanda deployment and the traps it turned up
The rack display has been running v2 since today and passed a physical swipe
test and a cold boot, so the hardware table now says what is actually in
service and what is merely intended: the Wyse 3040 has not been tried yet and
should not read as though it has.

notes/HARDWARE-NOTES.md gains the two traps that cost the most time during the
deployment, both of which will recur:

lightdm autologin fires when a seat starts, not after a logout, so `i3-msg
exit` does not test the autostart -- it strands a keyboard-less wall display at
a greeter that nobody standing at the rack can get past. Only a real reboot
tests the boot path.

And `ssh host 'sudo ...'` allocates no TTY, so sudo has nowhere to prompt and
fails; chaining the rest of the recovery behind `&&` then swallows it silently.
Use `ssh -t`, and `;` for steps that must run regardless.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 14:03:30 -07:00
jasonwitty ec9a9a8892 Capture a contact's start position per axis, not with a (0,0) sentinel
Every swipe on the rack display classified as up-to-down. The first one took
the display from the top row to the bottom row, and from there "down" is a
no-op, so it was stuck permanently -- looking like dead gestures while the
process was alive and the panel still grabbed.

X and Y arrive as SEPARATE events, so a new contact's opening frame is
ABS_MT_POSITION_X then ABS_MT_POSITION_Y. Treating (0, 0) as "start not yet
known" meant the start was captured on the X event alone, recording a Y of
zero. Every later comparison then measured from the top edge of the panel
rather than from the finger: dy became the absolute Y coordinate, dwarfed dx,
and the gesture came out vertical. A swipe at y=360 on a 720-tall panel
reported 360 pixels of downward travel that never happened.

The start position is now Option per axis. An axis that never reports
contributes no displacement, so there is no sentinel to collide with a real
coordinate.

The slot bookkeeping moves into a SlotTracker fed by a small Touched enum
rather than evdev's types, because it was untestable before and that is exactly
where the bug lived -- the existing classifier tests built Track tuples by hand
and skipped the decoding entirely. Six new tests drive realistic protocol-B
streams: the regression itself, per-axis start capture, all four directions
end to end, ghost contacts decoding as one swipe of the correct length,
completion only once every contact lifts, and reset between gestures.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 13:48:37 -07:00
jasonwitty 75d18cf494 notes: record the LattePanda deployment and what still needs a person
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 13:31:16 -07:00
jasonwitty 7d2491ea10 Survive a missing touch panel at boot; retry the open briefly
Two changes to the unattended path, both prompted by deploying to the rack.

The autostart can win the race against USB enumeration at boot, so the panel
may simply not exist yet when socktop-swipe starts. Opening it now retries for
ten seconds -- long enough for enumeration, short enough that a genuinely wrong
device path still reports promptly.

If the panel cannot be opened at all, `run` no longer treats that as fatal. By
that point the dashboard is already on the wall, and exiting would replace a
display you cannot swipe with no display at all. It now says loudly that
gestures are inactive, points at the movement subcommands, and carries on
serving the control socket. `daemon` and `doctor` still fail fast, since there
is no display at stake in either.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 13:30:11 -07:00
jasonwitty 9226224507 Set window options per window; label panes where programs cannot clobber them
Two bugs found deploying to the LattePanda, both of which v1 also had.

Window options do not propagate from the session and new windows do not
inherit them. `set-option -t <session> pane-border-status` quietly applies to
whichever window happens to be current, so only ONE window ever got pane
borders -- v1's rack display has had unlabeled borders on two of its three
windows this whole time and nobody noticed, because the window that got them
was the one usually on screen. This is the same trap that makes remain-on-exit
useless here. pane-border-status, pane-border-format and allow-rename are now
set per window in place(), and a test asserts every window has them.

Pane labels no longer use `select-pane -T`. The pane *title* is writable by
whatever runs in the pane: unifly probes for Kitty graphics support on startup
and tmux consumed part of that probe as a title change, so the border read
"Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA" instead of "unifly". Labels now live in a
pane-scoped user option, @socktop_label, which no escape sequence can reach;
pane-border-format falls back to the title if it is somehow unset.

Also: `terminal:` now expands a leading ~/ like `binaries:` already did. A
window manager's PATH rarely includes ~/.cargo/bin, so a full path is the usual
answer there and should not have to be spelled out longhand.

Build cost corrected from guesses to measurements on the LattePanda (Atom
x5-Z8350, 4 cores, 1.9 GB, no swap, toolchain already present): 108 seconds,
peak 1.1 GB, 103 MB target directory, 946 KB binary. The README said twenty
minutes and the installer budgeted 600 MB; both were wrong.

Integration tests now use one tmux session name each -- cargo runs them in
parallel and they were tearing down each other's server state.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-09 13:24:16 -07:00
9 changed files with 461 additions and 93 deletions
+13 -8
View File
@@ -1,6 +1,6 @@
# socktop-swipe
A rack-mounted touchscreen you swipe through to see what your machines are doing.
A rack-mounted touchscreen swipable display that monitors your servers via socktop and other status tuis.
Terminal dashboards are arranged in a grid. Swipe left and right to move along a
row; swipe up and down to move between rows. A group of hosts is one place in
@@ -45,12 +45,12 @@ you left.
## Hardware
| Part | What was used | Notes |
| Part | What was used | Status |
| --- | --- | --- |
| Display host | LattePanda (Atom x5-Z8350, 1.9 GB) | Debian 11, i3 on X11 |
| Display host | Dell Wyse 3040 (Atom x5-Z8350, 2 GB) | ~$35 used; 8/16 GB eMMC, DP out |
| Panel | GeeekPi 9" 1280x720 3U touchscreen | ILITEK USB touch |
| Mounting | [`cad/`](cad) — 19" rack adapter | 3D-print or laser-cut aluminium |
| Display host | LattePanda (Atom x5-Z8350, 1.9 GB), Debian 11, i3 on X11 | in daily use |
| Display host | Dell Wyse 3040 (Atom x5-Z8350, 2 GB), 8/16 GB eMMC, DP out | not yet tried |
| Panel | GeeekPi 9" 1280x720 3U touchscreen, ILITEK USB touch | in daily use |
| Mounting | [`cad/`](cad) — 19" rack adapter | printed and in the rack |
Any Linux box with a USB or DSI touchscreen will do. It is deliberately modest
hardware: the whole point is a display that idles at a few watts.
@@ -83,8 +83,13 @@ if there is no terminal at all it takes the defaults and says so. `--yes` skips
the questions.
There are no prebuilt binaries yet — it builds from source, so a Rust toolchain
is installed if you do not have one. Budget about 1.8 GB and, on an Atom, twenty
minutes or so.
is installed if you do not have one.
Measured on the LattePanda (Atom x5-Z8350, 4 cores, 1.9 GB RAM, no swap), with a
toolchain already present: **108 seconds**, peaking at 1.1 GB of the 1.9 GB and
leaving a 103 MB build directory. The 946 KB binary is the only thing installed.
Add roughly 1.2 GB and a few minutes if rustup has to be fetched too. The
installer checks free space first and tells you if it will not fit.
<details>
<summary>From a checkout instead</summary>
+4 -3
View File
@@ -170,8 +170,9 @@ if ! have cargo; then
NEED_RUSTUP=yes
COST_MB=$((COST_MB + 1200))
fi
# The build itself: a debug-free release build of this crate and its deps.
COST_MB=$((COST_MB + 600))
# The build itself. Measured on an Atom x5-Z8350: a 103 MB target directory,
# 108 seconds, peaking at 1.1 GB of RAM. 250 leaves headroom.
COST_MB=$((COST_MB + 250))
if [ -z "$TERMINAL" ]; then
warn "no terminal emulator found. socktop-swipe can attach in an existing"
@@ -190,7 +191,7 @@ fi
step "This will"
if [ -n "$NEED_PKGS" ]; then say " install packages:$NEED_PKGS"; fi
if [ "$NEED_RUSTUP" = yes ]; then say " install the Rust toolchain via rustup (~1.2 GB)"; fi
say " build socktop-swipe from source (~600 MB of build artifacts)"
say " build socktop-swipe from source (~100 MB of build artifacts)"
say " install the binary to $BIN"
say " write a starter config to $CONF"
say " ...then ask about the touch device, autostart, autologin and blanking."
+16
View File
@@ -103,3 +103,19 @@ adapter for the panel; roughly 3-4 W idle, 101x101x28 mm, VESA holes.
The eMMC is why the installer checks free space before starting a build: a Rust
toolchain plus a target directory is around 1.8 GB.
## Never log out of a keyboard-less wall display
lightdm autologin fires when a **seat starts**, not after a logout. `i3-msg exit`
therefore drops the display to the greeter and leaves it there — and the rack
panel has no keyboard, so nobody can log back in at the machine. Recovering it
needs `sudo systemctl restart lightdm` or a reboot, and the LattePanda has no
passwordless sudo.
So: **`i3-msg exit` does not test the autostart, it only strands the box.** The
only honest test of the boot path is an actual reboot.
Related trap when handing over a root command: `ssh host 'sudo …'` allocates no
TTY, so sudo has nowhere to prompt and fails. Use `ssh -t`. Chaining with `&&`
then swallows the rest of the line, which is how a recovery command silently did
nothing at all. Use `;` for recovery steps that must run regardless.
+6 -3
View File
@@ -1,8 +1,11 @@
# socktop-swipe v2 — release plan
Status: **implemented**, 2026-09-09. Milestones 1-7 are done on branch `v2-rust`;
milestone 8 (the Wyse 3040 validation) is the remaining acceptance gate. Where the
built thing differs from this plan, `notes/DESIGN.md` is authoritative.
Status: **implemented and in service**, 2026-09-09. Milestones 1-7 done on branch
`v2-rust` (PR #1); the LattePanda rack display has been running v2 since, and
passed a physical swipe test and a cold boot. Milestone 8, the Wyse 3040 install
following only the README, is the remaining acceptance gate.
Where the built thing differs from this plan, `notes/DESIGN.md` is authoritative.
v1 is three POSIX shell scripts driving lisgd and tmux. It works and is running on
the rack display today, but it hard-codes a single linear carousel of socktop hosts,
+13 -2
View File
@@ -5,8 +5,19 @@
- [ ] **Wyse 3040 validation.** Debian minimal to working rack display,
following only the README. Every stumble is a README fix, then re-run.
This is the acceptance gate, not a nice-to-have.
- [ ] Switch the LattePanda over from v1. Its v2 config path and session name
differ, so both can be installed side by side while testing.
- [x] **LattePanda switched over 2026-09-09.** v2 binary at `~/.local/bin`
(no passwordless sudo on that box), config at
`~/.config/socktop-swipe/config.yaml`, i3 autostart replaced with a single
`socktop-swipe run`. All four screens verified live from the framebuffer.
Build there: 108 s, peak 1.1 GB of 1.9 GB, 103 MB target dir.
- [ ] Remove the v1 leftovers in `/usr/local/bin` on the LattePanda -- needs root:
`sudo rm -f /usr/local/bin/socktop-rack /usr/local/bin/socktop-swipe /usr/local/bin/socktop-gestures /usr/local/etc/socktop-swipe.env`
- [x] **Physically swipe-tested 2026-09-09** -- and it found a real bug the unit
tests could not: every swipe classified as up-to-down, because a contact's
start position was captured from the X event alone and recorded Y as zero.
Fixed, with protocol-B decoding tests. QA passed on the retest.
- [x] **Cold boot verified 2026-09-09.** Autologin, i3, and `socktop-swipe run`
all come up unattended.
- [ ] Add the tested-hardware table to the README once the Wyse is done.
- [ ] Decide on the position indicator (`indicator: true`). Implemented; keep or
cut based on whether it actually helps on the wall.
+265 -44
View File
@@ -24,13 +24,120 @@ pub struct Swipe {
pub fingers: usize,
}
#[derive(Debug, Clone, Copy)]
/// One contact's journey.
///
/// The start position is per axis and `Option`, NOT a `(0, 0)` sentinel. X and Y
/// arrive as SEPARATE events, so a contact's opening frame is `POSITION_X` then
/// `POSITION_Y`: capturing "the start" on the first of those records a Y of
/// zero, and every later comparison then measures from the top edge of the panel
/// rather than from the finger. That made `dy` enormous and positive, so every
/// swipe -- horizontal ones included -- classified as up-to-down.
#[derive(Debug, Default, Clone, Copy)]
struct Slot {
start: (i32, i32),
start_x: Option<i32>,
start_y: Option<i32>,
last: (i32, i32),
active: bool,
}
impl Slot {
/// An axis that never reported a position contributes no displacement.
fn track(&self) -> Track {
(
(
self.start_x.unwrap_or(self.last.0),
self.start_y.unwrap_or(self.last.1),
),
self.last,
)
}
}
/// The parts of a multitouch protocol-B stream we care about. Kept separate from
/// evdev's own types so the slot bookkeeping is testable without a device --
/// which is precisely the code the above bug lived in, untested.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Touched {
/// `ABS_MT_SLOT`: subsequent events apply to this slot.
Slot(i32),
/// `ABS_MT_TRACKING_ID`: a new contact when >= 0, a lift when -1.
TrackingId(i32),
X(i32),
Y(i32),
}
/// Slot bookkeeping for multitouch protocol B.
#[derive(Debug, Default)]
pub struct SlotTracker {
slots: HashMap<i32, Slot>,
current: i32,
peak: usize,
}
impl SlotTracker {
pub fn feed(&mut self, ev: Touched) {
match ev {
Touched::Slot(n) => self.current = n,
Touched::TrackingId(id) => {
if id < 0 {
if let Some(s) = self.slots.get_mut(&self.current) {
s.active = false;
}
} else {
self.slots.insert(
self.current,
Slot {
active: true,
..Slot::default()
},
);
self.peak = self.peak.max(self.active());
}
}
Touched::X(x) => {
let s = self.slot();
s.last.0 = x;
s.start_x.get_or_insert(x);
}
Touched::Y(y) => {
let s = self.slot();
s.last.1 = y;
s.start_y.get_or_insert(y);
}
}
}
fn slot(&mut self) -> &mut Slot {
self.slots.entry(self.current).or_insert(Slot {
active: true,
..Slot::default()
})
}
fn active(&self) -> usize {
self.slots.values().filter(|s| s.active).count()
}
/// A gesture is over when every contact seen has lifted.
pub fn complete(&self) -> bool {
!self.slots.is_empty() && self.active() == 0
}
pub fn tracks(&self) -> Vec<Track> {
self.slots.values().map(Slot::track).collect()
}
/// Peak simultaneous contacts, which is what `touch.fingers` matches.
pub fn peak(&self) -> usize {
self.peak
}
pub fn reset(&mut self) {
self.slots.clear();
self.peak = 0;
}
}
pub struct Touchpanel {
device: Device,
cfg: Touch,
@@ -129,53 +236,36 @@ impl Touchpanel {
/// 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;
let mut tracker = SlotTracker::default();
loop {
for ev in self.device.fetch_events().context("reading touch events")? {
match ev.kind() {
let touched = match ev.kind() {
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_SLOT) => {
current = ev.value();
Touched::Slot(ev.value())
}
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_TRACKING_ID) => {
if ev.value() < 0 {
if let Some(s) = slots.get_mut(&current) {
s.active = false;
}
} else {
slots.insert(
current,
Slot {
start: (0, 0),
last: (0, 0),
active: true,
},
);
peak = peak.max(slots.values().filter(|s| s.active).count());
}
Touched::TrackingId(ev.value())
}
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_X) => {
update(&mut slots, current, |p| p.0 = ev.value());
Touched::X(ev.value())
}
InputEventKind::AbsAxis(AbsoluteAxisType::ABS_MT_POSITION_Y) => {
update(&mut slots, current, |p| p.1 = ev.value());
Touched::Y(ev.value())
}
_ => {}
}
_ => continue,
};
tracker.feed(touched);
}
// 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 tracker.complete() {
if let Some(ev) = classify(&self.cfg, &tracker.tracks(), tracker.peak()) {
if !on_event(ev) {
return Ok(());
}
}
slots.clear();
peak = 0;
tracker.reset();
}
}
}
@@ -255,19 +345,6 @@ pub fn classify(cfg: &Touch, tracks: &[Track], peak: usize) -> Option<Event> {
}))
}
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();
@@ -422,6 +499,150 @@ mod tests {
}
}
// -- slot tracking: real protocol-B event streams -----------------------
//
// These exist because the classifier tests above build Track tuples by hand
// and so never exercised the decoding. The bug that shipped to the rack
// display lived exactly here: every swipe came out as up-to-down, and the
// display got stuck on the bottom row because "down" from there is a no-op.
/// One contact moving from `from` to `to`, reported the way the kernel does:
/// tracking id, then X and Y as separate events, then a lift.
fn contact(t: &mut SlotTracker, slot: i32, from: (i32, i32), to: (i32, i32), steps: i32) {
t.feed(Touched::Slot(slot));
t.feed(Touched::TrackingId(slot + 1));
for i in 0..=steps {
let x = from.0 + (to.0 - from.0) * i / steps;
let y = from.1 + (to.1 - from.1) * i / steps;
t.feed(Touched::Slot(slot));
t.feed(Touched::X(x));
t.feed(Touched::Y(y));
}
}
fn lift(t: &mut SlotTracker, slot: i32) {
t.feed(Touched::Slot(slot));
t.feed(Touched::TrackingId(-1));
}
#[test]
fn a_horizontal_swipe_low_on_the_panel_is_not_read_as_downward() {
// THE REGRESSION. A right-to-left swipe at y=360 on a 720-tall panel.
// With a (0,0) start sentinel, Y was captured as 0 and dy became +360,
// dwarfing dx and classifying this as UD.
let mut t = SlotTracker::default();
contact(&mut t, 0, (900, 360), (700, 362), 10);
lift(&mut t, 0);
assert!(t.complete());
match classify(&touch(80, 30, vec![1]), &t.tracks(), t.peak()) {
Some(Event::Swipe(s)) => {
assert_eq!(
s.direction,
Direction::RL,
"a left swipe must not read as down"
);
assert_eq!(s.fingers, 1);
}
other => panic!("expected an RL swipe, got {other:?}"),
}
}
#[test]
fn start_is_captured_per_axis() {
// X arrives before Y, so a start captured on the first event alone would
// record y=0 and report the finger travelling the height of the panel.
let mut t = SlotTracker::default();
t.feed(Touched::Slot(0));
t.feed(Touched::TrackingId(1));
t.feed(Touched::X(900));
t.feed(Touched::Y(360));
t.feed(Touched::X(700));
t.feed(Touched::Y(360));
lift(&mut t, 0);
assert_eq!(t.tracks(), vec![((900, 360), (700, 360))]);
}
#[test]
fn all_four_directions_survive_decoding() {
let cfg = touch(80, 30, vec![1]);
// Centre of a 1280x720 panel, 200px each way.
for (to, want) in [
((440, 360), Direction::RL),
((840, 360), Direction::LR),
((640, 160), Direction::DU),
((640, 560), Direction::UD),
] {
let mut t = SlotTracker::default();
contact(&mut t, 0, (640, 360), to, 10);
lift(&mut t, 0);
match classify(&cfg, &t.tracks(), t.peak()) {
Some(Event::Swipe(s)) => assert_eq!(s.direction, want, "moving to {to:?}"),
other => panic!("moving to {to:?}: {other:?}"),
}
}
}
#[test]
fn ghost_contacts_decode_as_one_swipe_of_the_right_length() {
// The ILITEK panel reports one finger as two or three contacts.
let mut t = SlotTracker::default();
contact(&mut t, 0, (900, 300), (700, 300), 8);
contact(&mut t, 1, (903, 305), (703, 305), 8);
contact(&mut t, 2, (897, 295), (697, 295), 8);
lift(&mut t, 0);
lift(&mut t, 1);
lift(&mut t, 2);
assert_eq!(t.peak(), 3, "peak contacts drive the touch.fingers match");
match classify(&touch(150, 30, vec![1, 2, 3]), &t.tracks(), t.peak()) {
Some(Event::Swipe(s)) => {
assert_eq!(s.direction, Direction::RL);
assert_eq!(s.fingers, 3);
}
other => panic!("expected one RL swipe: {other:?}"),
}
// 200px of travel, not 600.
match classify(&touch(250, 30, vec![1, 2, 3]), &t.tracks(), t.peak()) {
Some(Event::Discarded(_, Rejected::TooShort { travel, .. })) => {
assert!((travel - 200.0).abs() < 2.0, "travel was {travel}");
}
other => panic!("expected 200px of travel: {other:?}"),
}
}
#[test]
fn a_gesture_is_only_complete_once_every_contact_lifts() {
let mut t = SlotTracker::default();
assert!(!t.complete(), "nothing has been touched yet");
contact(&mut t, 0, (900, 300), (800, 300), 4);
assert!(!t.complete(), "still down");
contact(&mut t, 1, (500, 300), (400, 300), 4);
lift(&mut t, 0);
assert!(!t.complete(), "one contact is still down");
lift(&mut t, 1);
assert!(t.complete());
}
#[test]
fn reset_clears_the_previous_gesture() {
let mut t = SlotTracker::default();
contact(&mut t, 0, (900, 300), (700, 300), 4);
lift(&mut t, 0);
t.reset();
assert!(!t.complete());
assert_eq!(t.peak(), 0);
assert!(t.tracks().is_empty());
// A second swipe must measure from its own origin, not the first one's.
contact(&mut t, 0, (300, 300), (500, 300), 4);
lift(&mut t, 0);
match classify(&touch(80, 30, vec![1]), &t.tracks(), t.peak()) {
Some(Event::Swipe(s)) => assert_eq!(s.direction, Direction::LR),
other => panic!("{other:?}"),
}
}
#[test]
fn a_gesture_with_no_contacts_is_not_a_gesture() {
assert!(classify(&touch(80, 30, vec![1]), &[], 0).is_none());
+34 -3
View File
@@ -228,7 +228,10 @@ fn run(cfg: &Config, no_touch: bool) -> Result<()> {
Some(term) => {
let mut argv = shell_words::split(term)
.with_context(|| format!("cannot parse terminal: {term}"))?;
let prog = argv.remove(0);
// Same tilde expansion as `binaries:`. A window manager's PATH
// rarely includes ~/.cargo/bin, so a full path is the usual answer
// here and it should not have to be spelled out longhand.
let prog = config::expand_tilde(&argv.remove(0));
Command::new(&prog)
.args(argv)
.arg("-e")
@@ -250,7 +253,16 @@ fn run(cfg: &Config, no_touch: bool) -> Result<()> {
});
if !no_touch {
spawn_panel(cfg, tx)?;
// Deliberately not fatal. The dashboard is already on the wall by this
// point; exiting because the panel is missing would replace a display
// you cannot swipe with no display at all. Say so loudly and carry on
// serving the control socket, which is still a way to drive it.
if let Err(e) = spawn_panel(cfg, tx) {
eprintln!("socktop-swipe: touch gestures are NOT active: {e:#}");
eprintln!(
"socktop-swipe: the dashboard is up; drive it with `socktop-swipe forward` etc."
);
}
}
drive(&tmux, &mut grid, rx)
}
@@ -278,11 +290,30 @@ fn daemon(cfg: &Config, no_touch: bool) -> Result<()> {
drive(&tmux, &mut grid, rx)
}
/// Open the touch panel, retrying briefly.
///
/// At boot the autostart can win the race against USB enumeration, so the
/// device is simply not there yet. Ten seconds covers that without making a
/// genuinely wrong device path take ten seconds to report.
fn open_panel(cfg: &Config) -> Result<Touchpanel> {
let deadline = std::time::Instant::now() + std::time::Duration::from_secs(10);
loop {
match Touchpanel::open(&cfg.touch) {
Ok(p) => return Ok(p),
Err(e) if std::time::Instant::now() < deadline => {
std::thread::sleep(std::time::Duration::from_millis(500));
let _ = e;
}
Err(e) => return Err(e),
}
}
}
/// Read the panel on its own thread. Opening it here rather than in the thread
/// keeps a permission or grab failure on the main path, where it can be
/// reported properly instead of vanishing into a detached thread.
fn spawn_panel(cfg: &Config, tx: mpsc::Sender<Ctl>) -> Result<()> {
let mut panel = Touchpanel::open(&cfg.touch)?;
let mut panel = open_panel(cfg)?;
let gestures = cfg.gestures.clone();
std::thread::spawn(move || {
let result = panel.run(|ev| {
+39 -11
View File
@@ -80,6 +80,19 @@ impl Tmux {
)
}
/// Name a pane for the border.
///
/// NOT `select-pane -T`, which sets the pane *title* -- a value the program
/// running in the pane can overwrite at any time with an OSC escape. unifly
/// probes for Kitty graphics support on startup and tmux consumed part of
/// that probe as a title change, so the border read
/// `Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA` instead of "unifly". A pane-scoped user
/// option is ours alone and no escape sequence can reach it.
fn label(&self, pane: &str, title: &str) -> Result<()> {
self.run(&["set-option", "-p", "-t", pane, "@socktop_label", title])?;
Ok(())
}
fn zoomed(&self, window: &str) -> Result<bool> {
Ok(self.run(&[
"display-message",
@@ -122,8 +135,30 @@ impl Tmux {
])?
};
// These are all WINDOW options, and new windows do not inherit them, so
// they must be set per window rather than once on the session. Setting
// a window option with `set-option -t <session>` silently applies it to
// whichever window happens to be current -- the same trap that makes
// `remain-on-exit` useless here, and the reason v1's pane borders only
// ever appeared on one of its windows.
//
// allow-rename: a window created with -n has automatic-rename off, but a
// program can still rename it with an escape sequence.
for (opt, val) in [
("allow-rename", "off"),
("pane-border-status", "top"),
(
"pane-border-format",
// Fall back to the pane title if the label is somehow unset, so
// a pane is never nameless.
" #{?#{@socktop_label},#{@socktop_label},#{pane_title}} ",
),
] {
self.run(&["set-option", "-w", "-t", &window, opt, val])?;
}
let first_pane = self.run(&["display-message", "-p", "-t", &window, "#{pane_id}"])?;
self.run(&["select-pane", "-t", &first_pane, "-T", &cell.panes[0].title])?;
self.label(&first_pane, &cell.panes[0].title)?;
let mut panes = vec![first_pane];
for pane in &cell.panes[1..] {
@@ -138,7 +173,7 @@ impl Tmux {
"#{pane_id}",
&Self::shell_command(&pane.command),
])?;
self.run(&["select-pane", "-t", &id, "-T", &pane.title])?;
self.label(&id, &pane.title)?;
panes.push(id);
}
@@ -264,16 +299,9 @@ impl Multiplexer for Tmux {
}
*self.placed.borrow_mut() = placed;
// Only SESSION options below; window options are set per window in
// place(), for the reason given there.
let s = &self.session;
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
+71 -19
View File
@@ -12,8 +12,6 @@ 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")
@@ -29,12 +27,12 @@ fn tmux(args: &[&str]) -> String {
String::from_utf8_lossy(&out.stdout).trim().to_owned()
}
fn config() -> Config {
fn config(session: &str) -> Config {
// `true` exits at once, which also exercises remain-on-exit keeping the
// pane addressable afterwards.
let yaml = format!(
r#"
session: {SESSION}
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:
@@ -56,24 +54,26 @@ screens:
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) {
/// (active window name, active pane label, is the window zoomed)
fn visible(session: &str) -> (String, String, bool) {
let s = tmux(&[
"display-message",
"-p",
"-t",
SESSION,
"#{window_name}\t#{pane_title}\t#{window_zoomed_flag}",
session,
"#{window_name}\t#{@socktop_label}\t#{window_zoomed_flag}",
]);
let f: Vec<&str> = s.split('\t').collect();
(f[0].into(), f[1].into(), f[2] == "1")
}
struct Cleanup;
/// Each test uses its own session name: cargo runs tests in parallel and they
/// would otherwise tear down each other's tmux server state.
struct Cleanup(&'static str);
impl Drop for Cleanup {
fn drop(&mut self) {
let _ = Command::new("tmux")
.args(["kill-session", "-t", SESSION])
.args(["kill-session", "-t", self.0])
.stdout(Stdio::null())
.stderr(Stdio::null())
.status();
@@ -86,9 +86,10 @@ fn session_layout_and_navigation_match_the_grid() {
eprintln!("skipping: tmux is not installed");
return;
}
let _cleanup = Cleanup;
const SESSION: &str = "socktop-swipe-selftest-nav";
let _cleanup = Cleanup(SESSION);
let cfg = config();
let cfg = config(SESSION);
let mut grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
let mux = Tmux::new(&cfg.session, false);
mux.build(&grid).expect("session should build");
@@ -108,7 +109,7 @@ fn session_layout_and_navigation_match_the_grid() {
);
// Starts at 0x0's overview: not zoomed, so all four Pis are visible.
let (win, _, zoomed) = visible();
let (win, _, zoomed) = visible(SESSION);
assert_eq!(win, "r0c0");
assert!(!zoomed, "the overview must not be zoomed");
@@ -116,7 +117,7 @@ fn session_layout_and_navigation_match_the_grid() {
for expected in ["alpha", "bravo", "charlie", "delta"] {
let pos = grid.apply(Move::Forward);
mux.show(&grid, &pos).unwrap();
let (win, title, zoomed) = visible();
let (win, title, zoomed) = visible(SESSION);
assert_eq!(win, "r0c0");
assert_eq!(title, expected, "wrong host zoomed");
assert!(zoomed, "{expected} should be zoomed full-screen");
@@ -125,7 +126,7 @@ fn session_layout_and_navigation_match_the_grid() {
// 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();
let (win, _, zoomed) = visible(SESSION);
assert_eq!(
win, "r0c5",
"0x5 follows 0x0 despite the gap in column numbers"
@@ -135,7 +136,7 @@ fn session_layout_and_navigation_match_the_grid() {
// 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();
let (win, title, zoomed) = visible(SESSION);
assert_eq!(
(win.as_str(), title.as_str(), zoomed),
("r0c0", "delta", true)
@@ -144,14 +145,14 @@ fn session_layout_and_navigation_match_the_grid() {
// 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();
let (win, _, zoomed) = visible(SESSION);
assert_eq!(win, "rm1c0");
assert!(!zoomed, "a one-pane cell has nothing to zoom into");
// And back down to exactly the host we left.
let pos = grid.apply(Move::Down);
mux.show(&grid, &pos).unwrap();
let (win, title, zoomed) = visible();
let (win, title, zoomed) = visible(SESSION);
assert_eq!(
(win.as_str(), title.as_str(), zoomed),
("r0c0", "delta", true),
@@ -161,7 +162,58 @@ fn session_layout_and_navigation_match_the_grid() {
// Down twice: through row 0 to kuma.
let pos = grid.apply(Move::Down);
mux.show(&grid, &pos).unwrap();
assert_eq!(visible().0, "r1c0");
assert_eq!(visible(SESSION).0, "r1c0");
}
/// Window options do not propagate from the session, and new windows do not
/// inherit them. Setting one with `set-option -t <session>` quietly applies it
/// to whichever window is current, which is how v1 ended up with pane borders
/// on only one of its three windows. Assert EVERY window got them.
#[test]
fn window_options_are_set_on_every_window() {
if !have_tmux() {
return;
}
const SESSION: &str = "socktop-swipe-selftest-opts";
let _cleanup = Cleanup(SESSION);
let cfg = config(SESSION);
let grid = Grid::new(monitor::build_cells(&cfg).unwrap()).unwrap();
Tmux::new(&cfg.session, false).build(&grid).unwrap();
for (window, want_labels) in [
("rm1c0", vec!["unifly"]),
("r0c0", vec!["alpha", "bravo", "charlie", "delta"]),
("r0c5", vec!["echo1", "foxtrot"]),
("r1c0", vec!["uptime kuma"]),
] {
let target = format!("{SESSION}:{window}");
for (opt, want) in [("pane-border-status", "top"), ("allow-rename", "off")] {
let got = tmux(&["show-options", "-w", "-t", &target, "-v", opt]);
assert_eq!(got, want, "{window} is missing the {opt} window option");
}
assert!(
tmux(&[
"show-options",
"-w",
"-t",
&target,
"-v",
"pane-border-format"
])
.contains("@socktop_label"),
"{window} is missing the pane-border-format"
);
// Labels live in a pane-scoped user option precisely so the program in
// the pane cannot overwrite them with a title escape sequence.
let labels = tmux(&["list-panes", "-t", &target, "-F", "#{@socktop_label}"]);
assert_eq!(
labels.lines().collect::<Vec<_>>(),
want_labels,
"{window} has the wrong pane labels"
);
}
}
#[test]