aboutsummaryrefslogtreecommitdiffstats
path: root/packaging
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-13 20:36:45 +0200
committeruser <user@clank>2026-08-13 20:36:45 +0200
commit91f40dccd9def02d147ea41615f7bc159cb306a6 (patch)
tree525c568778860b404620ff6d4581e91cb5c07357 /packaging
parentsl0p: the plugins we ship do not ask for permission (diff)
downloadsl0ppi-91f40dccd9def02d147ea41615f7bc159cb306a6.tar.gz
sl0ppi-91f40dccd9def02d147ea41615f7bc159cb306a6.tar.xz
sl0ppi-91f40dccd9def02d147ea41615f7bc159cb306a6.zip
sl0p(bar): a new-tab button, and margins that match the pane gap
First pass of the status bar overhaul, in the zjstatus fork plus the layout that configures it. margin_left / margin_right: the bar is a full-width pane while every pane above it is inset by the pane gap, so its ends stuck out past every frame on screen. The margins are applied by rendering into an inset viewport (inset_state), which is the same thing click positions are translated by -- so what is drawn and what is clickable come from one definition. That is the split button's lesson applied before shipping rather than after. {new_tab}: a clickable button that opens a tab and focuses it, with no name and no cwd, i.e. exactly what the `n` binding in TAB mode does. Clicking a tab already switches to it and double-clicking renames it; making one was the hole in the strip. Rendering and hit-testing are the same measured string here, so the button covers exactly the columns it draws. Both keys are inert when unset, so an unmodified zjstatus config renders identically. packaging/checks/status-bar-live.py reads the bar off a real session and asserts it spans exactly the columns the pane frames span, clicks the + and expects one more tab, then clicks beside it and expects none. Confirmed by setting the margins to 0 and watching the span assertion fail: (0, 99) against the frames' (2, 97). The check counts painted cells rather than ink, because a bar segment begins with a padding space inside its coloured pill -- strip the colour and the left edge reads one column off, which is half the margin being measured.
Diffstat (limited to 'packaging')
-rwxr-xr-xpackaging/checks/status-bar-live.py296
1 files changed, 296 insertions, 0 deletions
diff --git a/packaging/checks/status-bar-live.py b/packaging/checks/status-bar-live.py
new file mode 100755
index 0000000..5a63922
--- /dev/null
+++ b/packaging/checks/status-bar-live.py
@@ -0,0 +1,296 @@
+#!/usr/bin/env python3
+"""Live check: the status bar lines up with the panes, and its new-tab button
+makes a tab.
+
+Both are things you can only see on a real session. The bar is a plugin
+rendering into a full-width pane, so nothing in the layout file says where its
+first column lands -- and the button is a click surface whose only definition of
+"where" is the string it drew.
+
+Usage: status-bar-live.py [path-to-sl0ppij] [path-to-zjstatus.wasm]
+"""
+
+import fcntl
+import json
+import os
+import pty
+import re
+import shutil
+import struct
+import subprocess
+import sys
+import tempfile
+import termios
+import time
+
+HERE = os.path.dirname(os.path.abspath(__file__))
+REPO = os.path.abspath(os.path.join(HERE, "../.."))
+BIN = os.path.abspath(sys.argv[1] if len(sys.argv) > 1 else f"{REPO}/sl0ppij/target/fastdev/sl0ppij")
+WASM = os.path.abspath(
+ sys.argv[2]
+ if len(sys.argv) > 2
+ else f"{REPO}/sl0pstatus/target/wasm32-wasip1/release/zjstatus.wasm"
+)
+ROWS, COLS = 30, 100
+SESSION = f"sl0ptest-bar-{os.getpid()}"
+
+# the horizontal pane gap the bar is supposed to match: `gap` x `gap_aspect`
+EXPECTED_MARGIN = 2
+
+
+def screen_cells(raw):
+ """Very small VT: absolute moves, erases, printable runs, and whether each
+ cell has a background colour set.
+
+ The background matters. A bar segment usually starts with a padding space
+ inside its coloured pill, so "where does the bar begin" is a question about
+ painted cells, not about ink -- strip the colour and the answer is off by
+ one, which is exactly the size of the thing being measured.
+ """
+ grid, row, col, i, bg = {}, 0, 0, 0, False
+ while i < len(raw):
+ ch = raw[i]
+ if ch == "\x1b":
+ m = re.match(r"\x1b\[([0-9;?]*)([a-zA-Z])", raw[i:])
+ if m:
+ nums = [int(n) for n in m.group(1).replace("?", "").split(";") if n != ""]
+ verb, first = m.group(2), (nums[0] if nums else 0)
+ if verb == "m":
+ # 0 = reset, 49 = default background, 40-47/100-107 = set,
+ # 48 = extended (48;5;n or 48;2;r;g;b)
+ if not nums or 0 in nums or 49 in nums:
+ bg = False
+ if any(n == 48 or 40 <= n <= 47 or 100 <= n <= 107 for n in nums):
+ bg = True
+ if verb in "Hf":
+ row = (nums[0] - 1) if nums else 0
+ col = (nums[1] - 1) if len(nums) > 1 else 0
+ elif verb == "G":
+ col = max(0, first - 1)
+ elif verb == "J":
+ if first == 2:
+ grid.clear()
+ else:
+ for c in range(col, COLS):
+ grid.pop((row, c), None)
+ for r in range(row + 1, ROWS):
+ for c in range(COLS):
+ grid.pop((r, c), None)
+ elif verb == "K":
+ span = range(col, COLS) if first == 0 else range(0, COLS)
+ for c in span:
+ grid.pop((row, c), None)
+ i += m.end()
+ continue
+ m = re.match(r"\x1b][^\x07\x1b]*(\x07|\x1b\\)", raw[i:])
+ if m:
+ i += m.end()
+ continue
+ i += 1
+ continue
+ if ch == "\r":
+ col = 0
+ elif ch == "\n":
+ row, col = row + 1, 0
+ elif ch >= " ":
+ grid[(row, col)] = (ch, bg)
+ col += 1
+ i += 1
+ return grid
+
+
+def screen_rows(raw):
+ grid = screen_cells(raw)
+ return [
+ "".join(grid.get((r, c), (" ", False))[0] for c in range(COLS)) for r in range(ROWS)
+ ]
+
+
+def painted_span(grid, row):
+ """(first, last) column of `row` that is drawn on: ink or background."""
+ painted = [
+ c
+ for c in range(COLS)
+ if (lambda cell: cell[0] not in (" ", "") or cell[1])(grid.get((row, c), (" ", False)))
+ ]
+ return (painted[0], painted[-1]) if painted else (None, None)
+
+
+def repaint(fd):
+ """Force a full redraw, discard the intermediate one, read only the final."""
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS - 1, 0, 0))
+ time.sleep(0.8)
+ try:
+ while os.read(fd, 1 << 20):
+ pass
+ except (BlockingIOError, OSError):
+ pass
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0))
+ return ""
+
+
+def read_screen(fd, buffered="", seconds=8, until=None, quiet_for=0.7):
+ deadline, last = time.time() + seconds, time.time()
+ while time.time() < deadline:
+ time.sleep(0.15)
+ try:
+ chunk = os.read(fd, 1 << 20).decode("utf8", "replace")
+ except BlockingIOError:
+ chunk = ""
+ except OSError:
+ break
+ if chunk:
+ buffered, last = buffered + chunk, time.time()
+ continue
+ if until and until(screen_rows(buffered)):
+ break
+ if buffered and time.time() - last >= quiet_for:
+ break
+ return buffered, screen_rows(buffered)
+
+
+def bar_row(rows):
+ """The bar is the last non-blank row: the tab strip and the clock are on it."""
+ for y in range(len(rows) - 1, -1, -1):
+ if rows[y].strip():
+ return y, rows[y]
+ return None, ""
+
+
+def main():
+ for path in (BIN, WASM):
+ if not os.path.isfile(path):
+ print(f"FAIL: missing {path}")
+ return 1
+
+ tmp = tempfile.mkdtemp(prefix="sl0p-bar-")
+ config_dir = os.path.join(tmp, "config")
+ shutil.copytree(os.path.join(REPO, "config"), config_dir)
+ plugins = os.path.join(config_dir, "plugins")
+ os.makedirs(plugins, exist_ok=True)
+ shutil.copy(WASM, os.path.join(plugins, "zjstatus.wasm"))
+ # the shipped config points at ~/.config/sl0ppij; this run is isolated
+ for name in ("config.kdl", "layouts/default.kdl", "layouts/default.swap.kdl"):
+ path = os.path.join(config_dir, name)
+ if not os.path.isfile(path):
+ continue
+ with open(path) as f:
+ text = f.read()
+ with open(path, "w") as f:
+ f.write(text.replace("~/.config/sl0ppij", config_dir))
+
+ env = dict(os.environ)
+ env.update(
+ {
+ "SL0PPIJ_SOCKET_DIR": os.path.join(tmp, "sock"),
+ "SL0PPIJ_CONFIG_FILE": os.path.join(config_dir, "config.kdl"),
+ "SL0PPIJ_CONFIG_DIR": config_dir,
+ "SL0PPIJ_CACHE_DIR": os.path.join(tmp, "cache"),
+ "XDG_CACHE_HOME": os.path.join(tmp, "cache"),
+ "TERM": "xterm-256color",
+ "SHELL": "/bin/sh",
+ }
+ )
+
+ pid, fd = pty.fork()
+ if pid == 0:
+ os.execvpe(BIN, [BIN, "-s", SESSION], env)
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", ROWS, COLS, 0, 0))
+ os.set_blocking(fd, False)
+
+ failures = []
+ try:
+ _, rows = read_screen(fd, "", seconds=25, until=lambda r: any("Tab #1" in x for x in r))
+ raw, rows = read_screen(fd, repaint(fd), seconds=10)
+ grid = screen_cells(raw)
+ y, bar = bar_row(rows)
+ if y is None or "Tab #1" not in bar:
+ failures.append("no status bar on screen")
+ print("\n".join(f"{i:02d}|{r}" for i, r in enumerate(rows)))
+ return failures
+ print(f"bar row {y}: {bar.rstrip()!r}")
+
+ # 1. the bar occupies exactly the columns the pane frames do
+ bar_span = painted_span(grid, y)
+ frame_rows = [i for i, r in enumerate(rows) if r.lstrip().startswith(("╭", "┌"))]
+ frame_spans = [
+ (r.index(r.lstrip()[0]), len(r.rstrip()) - 1) for i, r in enumerate(rows) if i in frame_rows
+ ]
+ frame_span = (
+ min(s[0] for s in frame_spans),
+ max(s[1] for s in frame_spans),
+ ) if frame_spans else (None, None)
+ print(f"bar spans columns {bar_span}, pane frames span {frame_span}")
+ if bar_span != (EXPECTED_MARGIN, COLS - 1 - EXPECTED_MARGIN):
+ failures.append(
+ f"bar spans {bar_span}, expected "
+ f"{(EXPECTED_MARGIN, COLS - 1 - EXPECTED_MARGIN)}"
+ )
+ if frame_span[0] is not None and bar_span != frame_span:
+ failures.append(f"bar spans {bar_span} but the pane frames span {frame_span}")
+
+ # 2. the new-tab button makes a tab
+ def tabs():
+ out = subprocess.run(
+ [BIN, "-s", SESSION, "action", "list-tabs", "--json"],
+ env=env,
+ capture_output=True,
+ text=True,
+ ).stdout
+ try:
+ return len(json.loads(out))
+ except Exception:
+ return out.count('"name"') or len(out.strip().splitlines())
+
+ plus = bar.rfind(" + ")
+ if plus < 0:
+ failures.append("no new-tab button drawn on the bar")
+ return failures
+ button_col = plus + 1
+ print(f"new-tab button at column {button_col}")
+ before = tabs()
+ os.write(fd, f"\x1b[<0;{button_col + 1};{y + 1}M".encode())
+ os.write(fd, f"\x1b[<0;{button_col + 1};{y + 1}m".encode())
+ time.sleep(2)
+ after = tabs()
+ print(f"tabs: {before} -> {after}")
+ if after != before + 1:
+ failures.append(f"clicking the new-tab button did not add a tab ({before} -> {after})")
+
+ # 3. and a click just left of the tab strip must not: the button is a
+ # button, not "anywhere on the right half of the bar"
+ before = tabs()
+ os.write(fd, f"\x1b[<0;{max(1, plus - 2)};{y + 1}M".encode())
+ os.write(fd, f"\x1b[<0;{max(1, plus - 2)};{y + 1}m".encode())
+ time.sleep(1.5)
+ if tabs() != before:
+ failures.append("a click beside the button also made a tab")
+ return failures
+ finally:
+ subprocess.run([BIN, "-s", SESSION, "kill-session"], env=env, capture_output=True)
+ try:
+ os.close(fd)
+ except OSError:
+ pass
+ os.waitpid(pid, os.WNOHANG)
+ time.sleep(1)
+ ps = subprocess.run(["ps", "-eo", "pid,comm,args"], capture_output=True, text=True).stdout
+ for line in ps.splitlines():
+ fields = line.split(None, 2)
+ if len(fields) == 3 and fields[1] == os.path.basename(BIN) and tmp in fields[2]:
+ try:
+ os.kill(int(fields[0]), 15)
+ except (ProcessLookupError, ValueError, PermissionError):
+ pass
+ shutil.rmtree(tmp, ignore_errors=True)
+
+
+if __name__ == "__main__":
+ problems = main()
+ if problems == 1:
+ sys.exit(1)
+ for p in problems:
+ print(f"FAIL: {p}")
+ if not problems:
+ print("ok: the bar lines up with the panes and its + makes a tab")
+ sys.exit(1 if problems else 0)