v2: Rust rewrite with a YAML grid layout, evdev gestures and a preflighting installer #1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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]
|
||||
|
||||
Reference in New Issue
Block a user