From 9226224507195183bde449061ddc95a811cbe34d Mon Sep 17 00:00:00 2001 From: jasonwitty Date: Wed, 9 Sep 2026 13:24:16 -0700 Subject: [PATCH] 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 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 --- README.md | 9 ++++- install.sh | 7 ++-- src/main.rs | 5 ++- src/session/tmux.rs | 50 ++++++++++++++++++------ tests/tmux_session.rs | 90 ++++++++++++++++++++++++++++++++++--------- 5 files changed, 125 insertions(+), 36 deletions(-) diff --git a/README.md b/README.md index d1ea62e..311662c 100644 --- a/README.md +++ b/README.md @@ -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.
From a checkout instead diff --git a/install.sh b/install.sh index f90f37f..21d060e 100755 --- a/install.sh +++ b/install.sh @@ -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." diff --git a/src/main.rs b/src/main.rs index 52070b2..e62fe55 100644 --- a/src/main.rs +++ b/src/main.rs @@ -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") diff --git a/src/session/tmux.rs b/src/session/tmux.rs index 10e7ea1..d412e4b 100644 --- a/src/session/tmux.rs +++ b/src/session/tmux.rs @@ -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 { 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 ` 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 diff --git a/tests/tmux_session.rs b/tests/tmux_session.rs index c79e4b0..4a05ffb 100644 --- a/tests/tmux_session.rs +++ b/tests/tmux_session.rs @@ -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 ` 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::>(), + want_labels, + "{window} has the wrong pane labels" + ); + } } #[test]