diff options
| author | user <user@clank> | 2026-08-13 18:46:58 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-13 18:46:58 +0200 |
| commit | 2f0750285b1b174a552113c4aed9288f44985ae3 (patch) | |
| tree | e8033ba0b3d3ac04e4e49fb4fa53f7049ac21499 /packaging | |
| parent | docs: handover (diff) | |
| download | sl0ppi-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-x | packaging/checks/split-button-live.py | 242 |
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) |
