diff options
| author | user <user@clank> | 2026-08-13 19:39:18 +0200 |
|---|---|---|
| committer | user <user@clank> | 2026-08-13 19:39:18 +0200 |
| commit | ae726b5bb3ff8e9e8f2bef0e9fbc9d4dcb62a79e (patch) | |
| tree | a6cc3f56559c05b812d6bf6998d2b768c8672162 /packaging | |
| parent | build: cut the edit loop from 52s to 5s with more codegen units (diff) | |
| download | sl0ppi-ae726b5bb3ff8e9e8f2bef0e9fbc9d4dcb62a79e.tar.gz sl0ppi-ae726b5bb3ff8e9e8f2bef0e9fbc9d4dcb62a79e.tar.xz sl0ppi-ae726b5bb3ff8e9e8f2bef0e9fbc9d4dcb62a79e.zip | |
sl0p: make the split button work on stacked panes too
Reported still dead on a live session after the previous fix. Stacks move the
frame twice more, and neither move was in the hit-test:
- a stack list is drawn over the top rows of its visible member, pushing that
pane's frame down. The renderer applies this as a frame geom override
(reserved_top_rows); the click side did not, so every pane in a stack was
clickable one row above its own title. frame_rect_with_top_reserved() now
applies the same shift, before the gap inset, in the same order as render;
- a stack's collapsed members drew a button on their one-row title. Nothing
could honour it -- that row is the stack's picker, and "split the pane that
is not on screen" is not a thing to ask anyway. They no longer draw one, and
the click side mirrors the condition. 120 snapshot lines change, every one
of them a one-liner losing its button and nothing else (same width, same
content otherwise; audited mechanically).
Both confirmed by reintroducing each bug and watching the check fail. A third
candidate fix -- resolving a click to the stack's visible member, on the theory
that get_pane_at could return a collapsed sibling -- turned out to be
unnecessary: collapsed members have one-row geoms, so position lookup already
lands on the right pane. Removed rather than kept "just in case".
The check itself was wrong twice before it was right, in the direction that
matters most (reporting failures that were its own): it now reads until the pty
goes quiet instead of for a fixed time, and parses only a freshly forced
repaint, so a half-arrived screen cannot look like a button drawn where it is
not. It clicks every button it can see, and runs against config/config.kdl as
well, whose stacks the minimal config never had.
When the two disagree, the server's own log settles it: log::info! in
intercept_split_button_click, read /tmp/sl0ppij-$UID/sl0ppij-log/.
Diffstat (limited to 'packaging')
| -rwxr-xr-x | packaging/checks/split-button-live.py | 246 |
1 files changed, 222 insertions, 24 deletions
diff --git a/packaging/checks/split-button-live.py b/packaging/checks/split-button-live.py index 51b8e25..dd8862d 100755 --- a/packaging/checks/split-button-live.py +++ b/packaging/checks/split-button-live.py @@ -13,7 +13,10 @@ It then attaches a SECOND client, because that is what the live box always had 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] +Usage: split-button-live.py [path-to-sl0ppij] [path-to-config.kdl] + +With no config it uses a minimal one carrying just the live frame settings. +Pass `config/config.kdl` to drive the real thing, layouts and plugins included. Exits non-zero with a description of what it saw. """ @@ -57,23 +60,60 @@ ANSI = re.compile(r"\x1b\[[0-9;?]*[a-zA-Z]|\x1b[]P][^\x07\x1b]*(\x07|\x1b\\)?|\x 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.""" + """Rebuild the visible grid from the pty stream: cursor moves, erases and + printable runs. Not a terminal emulator, but it must honour the erases -- + without them a pane that shrinks leaves its old frame behind in the model, + and the check then clicks a `+` that is no longer on screen and calls the + product broken.""" grid = {} row = col = 0 i = 0 + + def erase(cells): + for cell in cells: + grid.pop(cell, None) + while i < len(raw): ch = raw[i] if ch == "\x1b": - m = re.match(r"\x1b\[([0-9;]*)([a-zA-Z])", raw[i:]) + 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": + nums = [int(n) for n in args.replace("?", "").split(";") if n != ""] + first = nums[0] if nums else 0 + if verb in "Hf": row = (nums[0] - 1) if nums else 0 col = (nums[1] - 1) if len(nums) > 1 else 0 + elif verb == "A": + row = max(0, row - max(1, first)) + elif verb == "B": + row += max(1, first) + elif verb == "C": + col += max(1, first) + elif verb == "D": + col = max(0, col - max(1, first)) + elif verb == "G": + col = max(0, first - 1) elif verb == "J": - grid.clear() + if first == 0: + erase( + [(row, c) for c in range(col, COLS)] + + [(r, c) for r in range(row + 1, ROWS) for c in range(COLS)] + ) + elif first == 1: + erase( + [(row, c) for c in range(0, col + 1)] + + [(r, c) for r in range(0, row) for c in range(COLS)] + ) + else: + grid.clear() + elif verb == "K": + if first == 0: + erase([(row, c) for c in range(col, COLS)]) + elif first == 1: + erase([(row, c) for c in range(0, col + 1)]) + else: + erase([(row, c) for c in range(COLS)]) i += m.end() continue m = re.match(r"\x1b][^\x07\x1b]*(\x07|\x1b\\)", raw[i:]) @@ -109,40 +149,98 @@ def spawn_client(env, args, rows=ROWS, cols=COLS): return pid, fd -def read_screen(fd, buffered, seconds=2.5, until=None): +def repaint(fd, cols=COLS, rows=ROWS): + """Force a full redraw and return an EMPTY buffer to accumulate it into. + + The screen model here is deliberately simple, and the way a simple model + goes wrong is stale cells: a pane that shrinks leaves its old frame behind, + and the check then clicks a `+` that is not on screen any more. A resize + makes the server repaint every cell, so reading from scratch afterwards + cannot see anything that is no longer drawn. + """ + # shrink, throw that paint away, then grow back: what the caller reads next + # is one complete repaint of the final size and nothing else + 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=2.5, until=None, quiet_for=0.7): + """Accumulate output until the stream goes QUIET, not until a timer expires. + + A repaint arrives in many chunks, and stopping mid-repaint yields a screen + that is half old and half new -- which reads exactly like a button drawn + where it is not. Waiting for silence is the difference between a check that + finds bugs and one that invents them. + """ deadline = time.time() + seconds + last_data = time.time() while time.time() < deadline: - time.sleep(0.4) + time.sleep(0.15) try: - buffered += os.read(fd, 1 << 20).decode("utf8", "replace") + chunk = os.read(fd, 1 << 20).decode("utf8", "replace") except BlockingIOError: - pass + chunk = "" except OSError: break + if chunk: + buffered += chunk + last_data = time.time() + continue if until and until(screen_rows(buffered)): break + if buffered and time.time() - last_data >= quiet_for: + break return buffered, screen_rows(buffered) def find_button(rows): """(row, 0-based column of the `+`) on a pane's top frame row.""" + found = find_buttons(rows) + return found[0] if found else None + + +def find_buttons(rows): + """Every drawn split button: (row, 0-based column of the `+`). + + A frame row can hold more than one, once panes sit side by side.""" + found = [] for y, line in enumerate(rows): - if ("╭" in line or "┌" in line) and " + " in line: - return y, line.index(" + ") + 1 - return None + if "╭" not in line and "┌" not in line: + continue + start = 0 + while True: + at = line.find(" + ", start) + if at < 0: + break + found.append((y, at + 1)) + start = at + 1 + return found 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) + if len(sys.argv) > 2: + config_file = os.path.abspath(sys.argv[2]) + config_dir = os.path.dirname(config_file) + print(f"config: {config_file}") + else: + config_file = os.path.join(tmp, "config.kdl") + config_dir = tmp + 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_CONFIG_DIR": config_dir, "SL0PPIJ_CACHE_DIR": os.path.join(tmp, "cache"), "TERM": "xterm-256color", "SHELL": "/bin/sh", @@ -173,7 +271,7 @@ def main(): # 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) + raw, rows = read_screen(fd, repaint(fd), 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") @@ -198,6 +296,37 @@ def main(): print(f"list-panes: {proc.stdout[:2000]!r} {proc.stderr[:500]!r}") return proc.stdout.count('"pane_x"') + def pane_geometries(): + out = subprocess.run( + [BIN, "-s", SESSION, "action", "list-panes", "--json"], + env=env, + capture_output=True, + text=True, + ).stdout + try: + import json + + return [ + { + k: p[k] + for k in ( + "title", + "is_plugin", + "is_focused", + "pane_x", + "pane_y", + "pane_columns", + "pane_rows", + "pane_content_x", + "pane_content_y", + ) + } + for p in json.loads(out) + if not p["is_plugin"] + ] + except Exception as e: # noqa: BLE001 - diagnostics only + return [f"could not parse list-panes: {e}"] + before = pane_count() # 2. clicking two columns right of the glyph -- where the OLD hit-test @@ -209,16 +338,85 @@ def main(): 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) + def click(col, row): + os.write(fd, f"\x1b[<0;{col + 1};{row + 1}M".encode()) + os.write(fd, f"\x1b[<0;{col + 1};{row + 1}m".encode()) + time.sleep(1.5) + + def settled(): + """A freshly repainted, fully arrived screen.""" + return read_screen(fd, repaint(fd), seconds=8) + + click(x, y) after = pane_count() print(f"panes: {before} -> {after}") + if after != before + 1: + failures.append(f"clicking the drawn button did not split ({before} -> {after})") + + # 4. EVERY drawn button must split the pane it belongs to, not just the + # first one -- panes away from the screen edge are inset differently + # (the gap is only applied at the edges), an unfocused pane is a + # different code path in the handler, and a stacked pane's frame is + # one row somewhere else entirely. + for phase, setup in ( + ("tiled, side by side", None), + ("stacked", ["action", "new-pane", "--", "/bin/sh"]), + ): + if setup: + subprocess.run([BIN, "-s", SESSION] + setup, env=env, capture_output=True) + time.sleep(1) + ids = subprocess.run( + [BIN, "-s", SESSION, "action", "list-panes", "--json"], + env=env, + capture_output=True, + text=True, + ).stdout + terminals = [ + str(m) + for m in re.findall(r'"id": (\d+),\n "is_plugin": false', ids) + ] + if len(terminals) >= 2: + subprocess.run( + [BIN, "-s", SESSION, "action", "stack-panes", "--"] + terminals[:3], + env=env, + capture_output=True, + ) + time.sleep(1.5) + raw, rows = settled() + print(f"{phase}: {len(find_buttons(rows))} button(s) drawn at {find_buttons(rows)}") + if not find_buttons(rows): + failures.append(f"{phase}: no split button drawn at all") + # Re-scan between clicks: every split moves the panes, so a list of + # buttons collected before the first click is stale by the second. + clicked = set() + for _ in range(4): + raw, rows = settled() + todo = [b for b in find_buttons(rows) if b not in clicked] + if not todo: + break + by, bx = todo[0] + clicked.add((by, bx)) + n0 = pane_count() + click(bx, by) + n1 = pane_count() + print(f" click ({by},{bx}): {n0} -> {n1}") + if n1 != n0 + 1: + failures.append( + f"{phase}: the button drawn at row {by} column {bx} did not split" + f" ({n0} -> {n1})" + ) + # the screen the button was found on, so the next reader can + # see WHICH pane's button was ignored, plus the geometry the + # server thinks those panes have + print(f" screen when ({by},{bx}) was clicked:") + print("\n".join(f" {i:02d}|{r}" for i, r in enumerate(rows))) + print(" panes (x, y, cols, rows) per the server:") + for p in pane_geometries(): + print(f" {p}") + 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( |
