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]