aboutsummaryrefslogtreecommitdiffstats
path: root/packaging
diff options
context:
space:
mode:
authoruser <user@clank>2026-08-13 19:39:18 +0200
committeruser <user@clank>2026-08-13 19:39:18 +0200
commitae726b5bb3ff8e9e8f2bef0e9fbc9d4dcb62a79e (patch)
treea6cc3f56559c05b812d6bf6998d2b768c8672162 /packaging
parentbuild: cut the edit loop from 52s to 5s with more codegen units (diff)
downloadsl0ppi-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-xpackaging/checks/split-button-live.py246
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(