v2: Rust rewrite with a YAML grid layout, evdev gestures and a preflighting installer #1

Merged
jason merged 8 commits from v2-rust into main 2026-09-09 21:27:12 +00:00
Owner

Replaces the three shell scripts and lisgd with a single Rust binary. tmux stays the pane engine.

Deployed to the LattePanda rack display, which passed a physical swipe test and a cold boot.

The grid

Screens are placed at "<row>x<col>" in a YAML config. Coordinates are sparse ordinals — only their sort order matters, so 0x1 and 0x5 are interchangeable and you never have to count how many screens a socktop group produces in order to place something beside it.

A socktop group is one cell however many hosts it holds. Swiping forward walks its sub-sequence (tiled overview, then each host zoomed) and only leaves after the last one; coming back from the right lands on its last host so the row reads as one strip. Vertical movement returns to where you were in that row — snapping to the nearest column only decides the first entry.

Layout in service: unifly at -1x0, two socktop groups across row 0, uptime-kuma-status at 1x0.

Bugs the deployment found

Four, three of which v1 also had and nobody had noticed.

Every swipe classified as up-to-down. X and Y arrive as separate evdev events, so using (0, 0) as the "start not yet known" sentinel captured the start on the X event alone and recorded Y as zero. Displacement was then measured from the top edge of the panel rather than from the finger: a swipe at y=360 reported 360px of downward travel that never happened, which dwarfed dx. On the wall this took the display from the top row to the bottom row and stuck there, because "down" from the bottom row is a no-op — it looked like dead gestures while the process was healthy and the panel still grabbed. Start position is now Option per axis. The existing tests could not have caught this: they built ((start),(end)) tuples by hand and never ran the decoding, which is exactly where the bug lived. The slot bookkeeping is now a SlotTracker fed by a small event enum, with six tests driving realistic protocol-B streams.

tmux 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 is current, so only one window ever got pane borders — the rack display has had unlabeled borders on two of its three windows since v1. Same trap that makes remain-on-exit useless here. Now set per window, with a test asserting every window has them.

A program can overwrite its own pane label. unifly probes for Kitty graphics support on startup and tmux consumed part of the probe as an OSC title change, so the border read Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA. Labels moved to a pane-scoped user option no escape sequence can reach.

run died if the panel was missing. The autostart can beat USB enumeration at boot. It now retries for ten seconds, and if the panel still is not there it says so loudly and keeps the dashboard up rather than replacing a display you cannot swipe with no display at all.

Other notable

  • --xignore is gone. EVIOCGRAB takes the panel exclusively so X never sees the touches, which is what the Option "Ignore" InputClass was faking — without the xorg.conf.d file or the relogin it needed.
  • Unquoted at: 0x0 is hexadecimal 0 to YAML, and 1x0 is not valid hex, so only row-0 entries would break. Deserialization catches it and names the fix.
  • Contact counts are averaged, not summed — a panel reporting one finger as three contacts must not look like three times the travel.
  • Installer set -e landmines fixed. Several [ test ] && action lines abort the script when false, so a box with no lightdm, no i3 or nothing to install would have exited silently partway through detection — exactly the fresh-Debian case. Verified against a stripped PATH with no tmux, cargo, git or package manager.
  • Device access defaults to a udev rule matching touchscreens only, rather than the input group which covers every input device including the keyboard.

Installer

Preflight checks everything — distro, package manager, display manager, WM, terminal, tmux, cargo, git, screen locker, touch device, permissions, free disk — and prints the total cost for one confirmation before touching anything. Prompts read /dev/tty so they survive curl | sh.

Source-only for now; no prebuilt binaries. [package.metadata.deb] is in place for later.

Measured, not guessed

On the LattePanda (Atom x5-Z8350, 4 cores, 1.9 GB, no swap, toolchain present): build 108 s, peak 1.1 GB, 103 MB target dir, 946 KB binary. The README had said twenty minutes and the installer had budgeted 600 MB; both were guesses and both are corrected. Also confirms tmux 3.1c works, which was untested.

Idle cost on the display host: 234 MB used of 1.9 GB with all eight panes up, and 0.0% CPU for the gesture process — it blocks on the device rather than polling.

Testing

43 tests: the grid model, the gesture classifier, protocol-B decoding, and three end-to-end passes driving a real tmux server. cargo fmt and cargo clippy -D warnings clean, no #[allow].

Every README example verified verbatim against the binary; every relative link resolves.

Docs

README rewritten for a first-time reader. Mechanism and reasoning moved to notes/ (DESIGN.md, HARDWARE-NOTES.md, V1-BASH.md, TODO.md), including the two operational traps the deployment turned up: i3-msg exit strands a keyboard-less display because lightdm autologin fires at seat start, and ssh host 'sudo ...' has no TTY to prompt on.

cad/README.md was a byte-identical copy of the one inside geeekpi_rack_adapter_release_v1/, so every path in it including the screenshot was broken from where it sits — corrected, and it now states once that the 9-inch screen, 10-inch mini-rack mount and 19-inch rack are three separate measurements.

Migration

v1 is removed here but recoverable at tag v1.2; notes/V1-BASH.md has a setting-by-setting migration table. There is deliberately no converter.

After merging

  • The LattePanda still has v1 leftovers in /usr/local/bin that need root to remove — see notes/TODO.md.
  • Remaining gate: the Wyse 3040 install following only the README.

🤖 Generated with Claude Code

Replaces the three shell scripts and lisgd with a single Rust binary. tmux stays the pane engine. **Deployed to the LattePanda rack display, which passed a physical swipe test and a cold boot.** ## The grid Screens are placed at `"<row>x<col>"` in a YAML config. Coordinates are **sparse ordinals** — only their sort order matters, so `0x1` and `0x5` are interchangeable and you never have to count how many screens a socktop group produces in order to place something beside it. A socktop group is **one cell** however many hosts it holds. Swiping forward walks its sub-sequence (tiled overview, then each host zoomed) and only leaves after the last one; coming back from the right lands on its *last* host so the row reads as one strip. Vertical movement **returns to where you were** in that row — snapping to the nearest column only decides the first entry. Layout in service: unifly at `-1x0`, two socktop groups across row 0, uptime-kuma-status at `1x0`. ## Bugs the deployment found Four, three of which v1 also had and nobody had noticed. **Every swipe classified as up-to-down.** X and Y arrive as *separate* evdev events, so using `(0, 0)` as the "start not yet known" sentinel captured the start on the X event alone and recorded Y as zero. Displacement was then measured from the top edge of the panel rather than from the finger: a swipe at y=360 reported 360px of downward travel that never happened, which dwarfed dx. On the wall this took the display from the top row to the bottom row and stuck there, because "down" from the bottom row is a no-op — it looked like dead gestures while the process was healthy and the panel still grabbed. Start position is now `Option` per axis. **The existing tests could not have caught this**: they built `((start),(end))` tuples by hand and never ran the decoding, which is exactly where the bug lived. The slot bookkeeping is now a `SlotTracker` fed by a small event enum, with six tests driving realistic protocol-B streams. **tmux 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 is current, so only one window ever got pane borders — the rack display has had unlabeled borders on two of its three windows since v1. Same trap that makes `remain-on-exit` useless here. Now set per window, with a test asserting every window has them. **A program can overwrite its own pane label.** unifly probes for Kitty graphics support on startup and tmux consumed part of the probe as an OSC title change, so the border read `Gi=31,s=1,v=1,a=q,t=d,f=24;AAAA`. Labels moved to a pane-scoped user option no escape sequence can reach. **`run` died if the panel was missing.** The autostart can beat USB enumeration at boot. It now retries for ten seconds, and if the panel still is not there it says so loudly and keeps the dashboard up rather than replacing a display you cannot swipe with no display at all. ## Other notable - **`--xignore` is gone.** `EVIOCGRAB` takes the panel exclusively so X never sees the touches, which is what the `Option "Ignore"` InputClass was faking — without the xorg.conf.d file or the relogin it needed. - **Unquoted `at: 0x0` is hexadecimal 0 to YAML**, and `1x0` is not valid hex, so only row-0 entries would break. Deserialization catches it and names the fix. - **Contact counts are averaged, not summed** — a panel reporting one finger as three contacts must not look like three times the travel. - **Installer `set -e` landmines fixed.** Several `[ test ] && action` lines abort the script when false, so a box with no lightdm, no i3 or nothing to install would have exited silently partway through detection — exactly the fresh-Debian case. Verified against a stripped PATH with no tmux, cargo, git or package manager. - **Device access defaults to a udev rule** matching touchscreens only, rather than the `input` group which covers every input device including the keyboard. ## Installer Preflight checks everything — distro, package manager, display manager, WM, terminal, tmux, cargo, git, screen locker, touch device, permissions, free disk — and prints the total cost for one confirmation before touching anything. Prompts read `/dev/tty` so they survive `curl | sh`. Source-only for now; no prebuilt binaries. `[package.metadata.deb]` is in place for later. ## Measured, not guessed On the LattePanda (Atom x5-Z8350, 4 cores, 1.9 GB, no swap, toolchain present): build **108 s**, peak **1.1 GB**, **103 MB** target dir, **946 KB** binary. The README had said twenty minutes and the installer had budgeted 600 MB; both were guesses and both are corrected. Also confirms **tmux 3.1c** works, which was untested. Idle cost on the display host: 234 MB used of 1.9 GB with all eight panes up, and 0.0% CPU for the gesture process — it blocks on the device rather than polling. ## Testing 43 tests: the grid model, the gesture classifier, protocol-B decoding, and three end-to-end passes driving a real tmux server. `cargo fmt` and `cargo clippy -D warnings` clean, no `#[allow]`. Every README example verified verbatim against the binary; every relative link resolves. ## Docs README rewritten for a first-time reader. Mechanism and reasoning moved to `notes/` (`DESIGN.md`, `HARDWARE-NOTES.md`, `V1-BASH.md`, `TODO.md`), including the two operational traps the deployment turned up: `i3-msg exit` strands a keyboard-less display because lightdm autologin fires at seat start, and `ssh host 'sudo ...'` has no TTY to prompt on. `cad/README.md` was a byte-identical copy of the one inside `geeekpi_rack_adapter_release_v1/`, so every path in it including the screenshot was broken from where it sits — corrected, and it now states once that the 9-inch screen, 10-inch mini-rack mount and 19-inch rack are three separate measurements. ## Migration v1 is removed here but recoverable at tag `v1.2`; `notes/V1-BASH.md` has a setting-by-setting migration table. There is deliberately no converter. ## After merging - The LattePanda still has v1 leftovers in `/usr/local/bin` that need root to remove — see `notes/TODO.md`. - **Remaining gate:** the Wyse 3040 install following only the README. 🤖 Generated with [Claude Code](https://claude.com/claude-code)
jason added 3 commits 2026-09-09 20:14:40 +00:00
cad/ holds the left/right side adapters that mount the GeeekPi 9" 3U
touchscreen (a 10" mini-rack part) into a standard 19" rack: OpenSCAD
source, STLs, Anycubic Mega Pro gcode, and SendCutSend DXFs for the
sheet-metal version. Geometry is physically tested in a 19" rack.

notes/PLAN-v2.md is the reviewed plan for the Rust rewrite.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
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>
Installer rewritten around a preflight: distro, package manager, display
manager, window manager, terminal, tmux, cargo, git, screen locker, touch
device, device permissions and free disk are all checked BEFORE anything is
installed, and the total cost is printed once for a single confirmation.
Prompts read /dev/tty so they still work when the script is piped from curl,
and fall back to defaults with a notice when there is no terminal at all.

Several "[ test ] && action" statements were set -e landmines: under set -e an
AND-OR list that ends up false aborts the script, so a box with no lightdm, no
i3 or nothing to install would have exited silently partway through detection
-- which is exactly the fresh-Debian case the installer exists for. Rewritten
as if-statements and verified against a stripped PATH with no tmux, cargo, git
or package manager present. Also fixed cargo detection reporting blank instead
of NOT INSTALLED: the status of `cargo --version | cut` is cut's, and cut
succeeds on empty input, so the fallback never fired.

Device access now defaults to a udev rule matching touchscreens only, rather
than the input group, which grants access to every input device including the
keyboard and needs a full logout.

README rewritten for someone who has not seen the project: what the photo
shows, the hardware, install, then a config built up step by step, each step
with the YAML and the resulting map. Every example is verified verbatim
against the binary, and every relative link resolves. The mechanism and the
reasoning move to notes/: DESIGN.md, HARDWARE-NOTES.md, V1-BASH.md, TODO.md.

cad/README.md was a verbatim copy of the one inside
geeekpi_rack_adapter_release_v1/, so every path in it -- including the
screenshot -- was broken from where it sits. Corrected to its own level, and
it now states once that the 9-inch screen, the 10-inch mini-rack mount and the
19-inch rack are three different measurements.

The v1 shell implementation is removed; it stays recoverable at tag v1.2 and
notes/V1-BASH.md carries the setting-by-setting migration table.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
jason added 1 commit 2026-09-09 20:24:20 +00:00
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>
jason added 1 commit 2026-09-09 20:30:13 +00:00
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>
jason added 1 commit 2026-09-09 20:31:19 +00:00
jason added 1 commit 2026-09-09 20:48:40 +00:00
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>
jason added 1 commit 2026-09-09 21:03:34 +00:00
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>
jason merged commit 0bf070131d into main 2026-09-09 21:27:12 +00:00
Sign in to join this conversation.
No Reviewers
No Label
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: jason/socktop-swipe#1