aboutsummaryrefslogtreecommitdiffstats
path: root/packaging
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-13 18:46:58 +0200
committeruser <user@clank>2026-08-13 18:46:58 +0200
commit2f0750285b1b174a552113c4aed9288f44985ae3 (patch)
treee8033ba0b3d3ac04e4e49fb4fa53f7049ac21499 /packaging
parentdocs: handover (diff)
downloadsl0ppi-2f0750285b1b174a552113c4aed9288f44985ae3.tar.gz
sl0ppi-2f0750285b1b174a552113c4aed9288f44985ae3.tar.xz
sl0ppi-2f0750285b1b174a552113c4aed9288f44985ae3.zip
sl0p: make the split button visible, and clickable where it is drawn
The `+` shipped in 0.1.7 and 0.1.8 without ever appearing on a live session, while rendering in 265 snapshots. Four faults, each enough on its own: - title_line_without_middle computed the right side only inside the left side's and_then, so a pane with no title (hide_default_pane_names, the live default) got none at all; - title_line_with_middle discarded the right side whenever the left was absent -- that is every frame in a session with a second client, which the live box always had, and "MY FOCUS AND:" on every frame was saying so; - the title was budgeted before the button, so a title longer than the frame (any shell showing a path) consumed its columns. The button is anchored and hit-tested by pure geometry, so it cannot be the part that loses; - the click hit-tested position_and_size() while the frame is drawn inset by the pane gap, putting the clickable columns two right of the glyph and a row above it at the top of the screen. Hence "the click works" and "nothing is drawn" both being true, and that click test proving nothing. Adds Pane::frame_rect(), the rect the frame is really drawn in, which the status bar buttons had already had to work around by hand. Also fixes render_my_focus building its short form from separators that Vec::append had already drained: 4 cells wide while reporting 6, a title line two cells short of its pane. Found by asserting the width in the new test. Verification, since unit tests have now been insufficient here four times: split_button_is_drawn_where_it_is_hit_tested covers the combinations the live config produces, and packaging/checks/split-button-live.py (make check-live) reads the glyph off a real session, attaches a second client to force the middle builder, and clicks the columns it found the glyph in. Both confirmed to fail on the parent commit.
Diffstat (limited to 'packaging')
-rwxr-xr-xpackaging/checks/split-button-live.py242
1 files changed, 242 insertions, 0 deletions
diff --git a/packaging/checks/split-button-live.py b/packaging/checks/split-button-live.py
new file mode 100755
index 0000000..51b8e25
--- /dev/null
+++ b/packaging/checks/split-button-live.py
@@ -0,0 +1,242 @@
+#!/usr/bin/env python3
+"""Live check: is the `+` split button DRAWN, and does clicking the drawn glyph
+split the pane?
+
+The unit tests can only prove the title line composes correctly. This drives a
+real session with the live config's frame settings -- centred titles, rounded
+corners, a pane gap, no default pane names -- because every one of those takes a
+different path through the title builders, and the gap moves the drawn frame
+away from the pane's allocation.
+
+It then attaches a SECOND client, because that is what the live box always had
+(the phantom client a headless `sl0ppi up` leaks) and it switches the frame onto
+a third title builder again -- the one with a "MY FOCUS" middle, which is where
+the button was being dropped for two releases.
+
+Usage: split-button-live.py [path-to-sl0ppij]
+Exits non-zero with a description of what it saw.
+"""
+
+import fcntl
+import os
+import pty
+import re
+import struct
+import subprocess
+import sys
+import tempfile
+import termios
+import time
+
+BIN = os.path.abspath(
+ sys.argv[1]
+ if len(sys.argv) > 1
+ else os.path.join(os.path.dirname(__file__), "../../sl0ppij/target/fastdev/sl0ppij")
+)
+ROWS, COLS = 30, 100
+SESSION = f"sl0ptest-splitbutton-{os.getpid()}"
+
+CONFIG = """
+ui {
+ pane_frames {
+ rounded_corners true
+ title_alignment "center"
+ gap 1
+ padding 0 0 0 0
+ gap_aspect 2
+ hide_default_pane_names true
+ }
+}
+pane_frames true
+pane_frame_style "full"
+default_mode "locked"
+show_startup_tips false
+"""
+
+ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b[]P][^\x07\x1b]*(\x07|\x1b\\)?|\x1b[()][0-9A-B]")
+
+
+def screen_rows(raw):
+ """Rebuild the visible grid from the pty stream: absolute cursor moves plus
+ printable runs. Enough for reading a frame; not a terminal emulator."""
+ grid = {}
+ row = col = 0
+ i = 0
+ while i < len(raw):
+ ch = raw[i]
+ if ch == "\x1b":
+ m = re.match(r"\x1b\[([0-9;]*)([a-zA-Z])", raw[i:])
+ if m:
+ args, verb = m.group(1), m.group(2)
+ nums = [int(n) for n in args.split(";") if n != ""]
+ if verb == "H":
+ row = (nums[0] - 1) if nums else 0
+ col = (nums[1] - 1) if len(nums) > 1 else 0
+ elif verb == "J":
+ grid.clear()
+ 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 += 1
+ col = 0
+ elif ch >= " ":
+ grid[(row, col)] = ch
+ col += 1
+ i += 1
+ out = []
+ for r in range(ROWS):
+ line = "".join(grid.get((r, c), " ") for c in range(COLS))
+ out.append(line.rstrip())
+ return out
+
+
+def spawn_client(env, args, rows=ROWS, cols=COLS):
+ pid, fd = pty.fork()
+ if pid == 0:
+ os.execvpe(BIN, [BIN] + args, env)
+ fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0))
+ # non-blocking: a settled client produces nothing, and a blocking read on
+ # its pty would hang this check instead of failing it
+ os.set_blocking(fd, False)
+ return pid, fd
+
+
+def read_screen(fd, buffered, seconds=2.5, until=None):
+ deadline = time.time() + seconds
+ while time.time() < deadline:
+ time.sleep(0.4)
+ try:
+ buffered += os.read(fd, 1 << 20).decode("utf8", "replace")
+ except BlockingIOError:
+ pass
+ except OSError:
+ break
+ if until and until(screen_rows(buffered)):
+ break
+ return buffered, screen_rows(buffered)
+
+
+def find_button(rows):
+ """(row, 0-based column of the `+`) on a pane's top frame row."""
+ for y, line in enumerate(rows):
+ if ("╭" in line or "┌" in line) and " + " in line:
+ return y, line.index(" + ") + 1
+ return None
+
+
+def main():
+ tmp = tempfile.mkdtemp(prefix="sl0p-splitbutton-")
+ config_file = os.path.join(tmp, "config.kdl")
+ with open(config_file, "w") as f:
+ f.write(CONFIG)
+ env = dict(os.environ)
+ env.update(
+ {
+ "SL0PPIJ_SOCKET_DIR": os.path.join(tmp, "sock"), # isolate from live sessions
+ "SL0PPIJ_CONFIG_FILE": config_file,
+ "SL0PPIJ_CONFIG_DIR": tmp,
+ "SL0PPIJ_CACHE_DIR": os.path.join(tmp, "cache"),
+ "TERM": "xterm-256color",
+ "SHELL": "/bin/sh",
+ "PS1": "$ ",
+ }
+ )
+
+ pid, fd = spawn_client(env, ["-s", SESSION])
+ second_pid = second_fd = None
+
+ raw = ""
+ failures = []
+ try:
+ raw, rows = read_screen(fd, raw, seconds=20, until=find_button)
+
+ # 1. one client: is it drawn at all, on the pane's top frame row?
+ hit = find_button(rows)
+ if hit is None:
+ failures.append("one client: the split button was not drawn on any frame row")
+ print("\n".join(f"{i:02d}|{r}" for i, r in enumerate(rows)))
+ return failures
+ y, x = hit
+ print(f"one client: drawn at row {y}, column {x}")
+ print(f" {rows[y]}")
+
+ # 2. two clients: the frame grows a "MY FOCUS" middle, which is a
+ # different title builder -- and the one the live box always used,
+ # because a headless `sl0ppi up` leaves a phantom client behind.
+ second_pid, second_fd = spawn_client(env, ["attach", SESSION])
+ _, second_rows = read_screen(second_fd, "", seconds=10, until=find_button)
+ raw, rows = read_screen(fd, raw, seconds=5)
+ focus_middle = any("FOCUS" in r or "| ME |" in r for r in rows + second_rows)
+ if not focus_middle:
+ failures.append("two clients: no focus indication -- the second client did not attach")
+ hit2 = find_button(rows)
+ print(f"two clients: drawn at {hit2}")
+ if hit2 is None:
+ failures.append("two clients: the split button was dropped from the title line")
+ print("\n".join(f"{i:02d}|{r}" for i, r in enumerate(rows)))
+ elif hit2 != hit:
+ failures.append(f"two clients: the button moved, {hit} -> {hit2}")
+ if hit2:
+ print(f" {rows[hit2[0]]}")
+
+ def pane_count():
+ proc = subprocess.run(
+ [BIN, "-s", SESSION, "action", "list-panes", "--json"],
+ env=env,
+ capture_output=True,
+ text=True,
+ )
+ if os.environ.get("DEBUG"):
+ print(f"list-panes: {proc.stdout[:2000]!r} {proc.stderr[:500]!r}")
+ return proc.stdout.count('"pane_x"')
+
+ before = pane_count()
+
+ # 2. clicking two columns right of the glyph -- where the OLD hit-test
+ # looked, out in the pane gap -- must do nothing
+ os.write(fd, f"\x1b[<0;{x + 3};{y + 1}M".encode())
+ os.write(fd, f"\x1b[<0;{x + 3};{y + 1}m".encode())
+ time.sleep(1.5)
+ if pane_count() != before:
+ failures.append("a click in the gap beside the button split the pane")
+
+ # 3. clicking the drawn glyph splits
+ os.write(fd, f"\x1b[<0;{x + 1};{y + 1}M".encode())
+ os.write(fd, f"\x1b[<0;{x + 1};{y + 1}m".encode())
+ time.sleep(2)
+ after = pane_count()
+ print(f"panes: {before} -> {after}")
+ if os.environ.get("DEBUG"):
+ raw, rows = read_screen(fd, raw, seconds=1)
+ print("\n".join(f"{i:02d}|{r}" for i, r in enumerate(rows)))
+ if after != before + 1:
+ failures.append(f"clicking the drawn button did not split ({before} -> {after})")
+ return failures
+ finally:
+ subprocess.run(
+ [BIN, "-s", SESSION, "kill-session"], env=env, capture_output=True, text=True
+ )
+ for p, f in ((pid, fd), (second_pid, second_fd)):
+ if f is None:
+ continue
+ try:
+ os.close(f)
+ except OSError:
+ pass
+ os.waitpid(p, os.WNOHANG)
+
+
+if __name__ == "__main__":
+ problems = main()
+ for p in problems:
+ print(f"FAIL: {p}")
+ print("ok: the split button is drawn where it is clicked" if not problems else "")
+ sys.exit(1 if problems else 0)