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>
This commit is contained in:
jasonwitty
2026-09-09 13:24:16 -07:00
parent ce6acec299
commit 9226224507
5 changed files with 125 additions and 36 deletions
+7 -2
View File
@@ -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."
+4 -1
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")
+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]