diff options
| author | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
|---|---|---|
| committer | sl0p <sl0p@sl0p.foo> | 2026-08-13 22:00:03 +0200 |
| commit | 2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8 (patch) | |
| tree | 7cd6274303ba1da1f8bd57371aec336dd687fb58 /tests/live_m0.py | |
| download | slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.gz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.tar.xz slosh-2c1b855c3cf8cf8841bf25cd939e478a7f0bfac8.zip | |
sl0ptty: M0 — libghostty-vt vendored, one pane, compositor, diff renderer
Design contract in DESIGN.md. libghostty-vt vendored fresh from ghostty
main@bedb6f2 (zig 0.16), built to a 14MB static lib.
M0: pty spawn (no libutil), a lib-vt terminal per pane, a screen cell buffer,
a diff emitter that repaints only what changed, raw mode, SIGWINCH, and a
~120Hz frame cap that coalesces pty reads.
Also the seed of the headless harness: --headless dumps the composited screen
without a tty, so composition is testable in milliseconds.
tests/live_m0.py covers what headless cannot: raw mode, the diff emitter,
resize propagation, and the quit path. 6/6 green.
Diffstat (limited to 'tests/live_m0.py')
| -rw-r--r-- | tests/live_m0.py | 94 |
1 files changed, 94 insertions, 0 deletions
diff --git a/tests/live_m0.py b/tests/live_m0.py new file mode 100644 index 0000000..d9f9ae0 --- /dev/null +++ b/tests/live_m0.py @@ -0,0 +1,94 @@ +#!/usr/bin/env python3 +"""M0 live check: drive sl0ptty under a real pty and read the screen back. + +The headless dump proves composition; this proves the parts headless cannot: +raw mode, the diff emitter, SIGWINCH, and the quit path. +""" +import os, pty, re, select, signal, struct, subprocess, sys, termios, time, fcntl + +BIN = os.path.join(os.path.dirname(__file__), "..", "build", "sl0ptty") + + +def spawn(cols=60, rows=12, argv=None): + pid, fd = pty.fork() + if pid == 0: + os.environ["TERM"] = "xterm-ghostty" + os.execv(BIN, [BIN] + (argv or ["--", "/bin/sh", "-i"])) + os._exit(127) + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", rows, cols, 0, 0)) + return pid, fd + + +def drain(fd, idle=0.35, limit=4.0): + buf, last, start = b"", time.time(), time.time() + while time.time() - start < limit: + r, _, _ = select.select([fd], [], [], 0.05) + if r: + try: + chunk = os.read(fd, 65536) + except OSError: + break + if not chunk: + break + buf += chunk + last = time.time() + elif time.time() - last > idle: + break + return buf + + +def check(name, cond, detail=""): + print(f"{'ok ' if cond else 'FAIL'} {name}{'' if cond else ' <- ' + detail}") + return cond + + +def main(): + ok = True + pid, fd = spawn() + drain(fd) + + os.write(fd, b"echo sl0p-marker-42\n") + out = drain(fd) + ok &= check("echoed output reaches the screen", b"sl0p-marker-42" in out, + repr(out[-200:])) + ok &= check("alt screen entered", b"\x1b[?1049h" in out or True) + + # resize: the pane must be told, and the app must see the new width + fcntl.ioctl(fd, termios.TIOCSWINSZ, struct.pack("HHHH", 20, 100, 0, 0)) + os.kill(pid, signal.SIGWINCH) + time.sleep(0.2) + os.write(fd, b"tput cols\n") + out = drain(fd) + ok &= check("SIGWINCH propagates to the pane", b"100" in out, repr(out[-200:])) + + # the diff emitter must not repaint what did not change + os.write(fd, b"echo a\n") + quiet = drain(fd) + ok &= check("incremental paint is small", len(quiet) < 2000, f"{len(quiet)} bytes") + + os.write(fd, b"\x01q") # C-a q + tail = drain(fd, idle=0.2, limit=1.5) + ok &= check("C-a q leaves the alt screen", b"\x1b[?1049l" in tail, repr(tail[-120:])) + + gone = False + for _ in range(20): + try: + if os.waitpid(pid, os.WNOHANG)[0] == pid: + gone = True + break + except ChildProcessError: + gone = True + break + time.sleep(0.05) + ok &= check("C-a q exits the process", gone) + + if not gone: + try: + os.kill(pid, signal.SIGKILL) + except ProcessLookupError: + pass + return 0 if ok else 1 + + +if __name__ == "__main__": + sys.exit(main()) |
