summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
authorGabriel Schneider <[email protected]>2026-10-01 12:42:29 -0300
committerGabriel Schneider <[email protected]>2026-10-01 13:17:15 -0300
commit4252afe6f4558b55fa34846b182312d6a384b96e (patch)
tree3b7bf6ab8eae30cf7750c25c6aa453b98a7bc53a /test
parentf700b2af8e874c586e6996b8311c3983203c6637 (diff)
downloadpardes-4252afe6f4558b55fa34846b182312d6a384b96e.tar.gz
pardes-4252afe6f4558b55fa34846b182312d6a384b96e.zip
The landing's terminal casts are recorded: live text, terminals as panes, and images and PDFs in a plain terminal, each a tty pardes that test/record_casts.py drives in a pty of its own as a plain xterm
The script answers only what an xterm answers, so images fall back to PETSCII glyph art and a PDF to its text layer. It stamps each scripted step on a virtual clock and writes the cells the step changed, as pyte saw them, so the same input makes the same file. The petscii cast generates its Mandelbrot views and reads the Intel SDM (--pdf) through a symlink. Co-Authored-By: Claude Opus 5.5 <[email protected]>
Diffstat (limited to 'test')
-rw-r--r--test/record_casts.py937
1 files changed, 937 insertions, 0 deletions
diff --git a/test/record_casts.py b/test/record_casts.py
new file mode 100644
index 00000000..d0daff11
--- /dev/null
+++ b/test/record_casts.py
@@ -0,0 +1,937 @@
+#!/usr/bin/env python3
+"""Record the documentation site's terminal casts (asciicast v2).
+
+zig build -Dplatform=tty -Doptimize=ReleaseSafe --prefix <scratch dir>
+python3 -B test/record_casts.py <scratch dir>/bin/pardes [cast ...] [--show]
+
+Each cast runs the tty pardes in a pty this script owns, sized 100x30 with
+TIOCSWINSZ, and plays the part of a plain xterm: it answers the queries an
+xterm answers (device attributes, device status, cursor position,
+XTVERSION, DECRQM as "not recognised", window sizes) and nothing else, so
+kitty graphics and the kitty keyboard stay unanswered: images are drawn
+as PETSCII glyph art, and a PDF as its text layer. Keys and SGR mouse
+reports are scripted.
+
+A cast is cut into steps: after each scripted input the recorder waits
+until the screen shows what the step waits for and pardes has drawn and
+gone idle, then stamps the step at its time on a virtual clock (starting
+at 0, a scripted hold between steps, no gap over 2 s). pyte follows the
+screen pardes draws, and an event is the cells the step changed, drawn
+afresh: how many frames pardes took to get there (a shell's output racing
+them) never reaches the file, so the same input makes the same cast.
+Wall-clock time never reaches it either. The bytes pardes wrote, stamped
+the same way, go to <work>/<name>.raw.cast beside it.
+
+--show replays each finished cast through pyte, prints its last frame
+(or the one --at seconds in) and draws it to <work>/<name>.png.
+
+Nothing of the caller's environment reaches the recording: the session
+gets a home, config, runtime and cache dirs under --work, a PATH with the
+recorded pardes first, and no PARDES_*, 9P mount, display or bus. The demo
+project and pictures live under /tmp/cast (short paths for the tags, a
+few hundred KB) and are removed after. The PDF (--pdf) is only read,
+through a symlink.
+
+Needs pyte and Pillow (in a venv), cc, make and git.
+"""
+import argparse
+import codecs
+import contextlib
+import fcntl
+import json
+import math
+import os
+from pathlib import Path
+import re
+import shutil
+import signal
+import struct
+import subprocess
+import termios
+import threading
+import time
+
+import pyte
+
+REPO = Path(__file__).resolve().parent.parent
+OUT = REPO / 'docs/site/casts'
+COLS, ROWS = 100, 30
+CELL_W, CELL_H = 10, 20 # the pixels a cell claims to be, for TIOCSWINSZ and CSI 14/16 t
+MAX_GAP = 2.0
+PROJECT = Path('/tmp/cast/hello')
+
+# ---------------------------------------------------------------------------
+# the terminal: queries answered as a plain xterm answers them
+
+DA1 = b'\x1b[?64;1;2;6;9;15;16;17;18;21;22;28c'
+XTVERSION = b'\x1bP>|XTerm(390)\x1b\\'
+
+# Everything pardes asks its terminal, answered in _answer as an xterm
+# would; what xterm ignores (kitty's queries, OSC 66) goes unanswered.
+# None of it reaches the cast.
+QUERY = re.compile(rb'''
+ \x1b\[\?(?P<decrqm>\d+)\$p # DECRQM: answered "not recognised"
+ | \x1b\[(?P<cpr>6)n # cursor position
+ | \x1b\[(?P<dsr>5)n # device status: each frame ends asking it
+ | \x1b\[(?P<da1>0?)c # primary device attributes
+ | \x1b\[>(?P<xtv>0?)q # XTVERSION
+ | \x1b\[(?P<winop>1[468])t # window/cell size in pixels, size in cells
+ | \x1b\[>\x20q # kitty multi-cursor query
+ | \x1b\[\?u # kitty keyboard query
+ | \x1b_G[^\x1b]*\x1b\\ # kitty graphics
+ | \x1b\]66;[^\x07\x1b]*(?:\x07|\x1b\\) # explicit width / scaled text
+ | \x1b\[\?2048h # in-band resize reports
+''', re.X)
+
+SGR = re.compile(rb'\x1b\[([0-9:;]*)m')
+
+
+def sgr_semicolons(m):
+ """Colon-form SGR (38:2:r:g:b, 4:3) in the semicolon form xterm, pyte
+ and asciinema's player all read; an underline colour (58) is dropped."""
+ params = m.group(1)
+ if b':' not in params:
+ return m.group(0)
+ out = []
+ for p in params.split(b';'):
+ if b':' not in p:
+ out.append(p)
+ continue
+ parts = p.split(b':')
+ if parts[0] in (b'38', b'48'):
+ if parts[1] == b'2' and len(parts) == 6: # 38:2:<colorspace>:r:g:b
+ parts = parts[:2] + parts[3:]
+ out.append(b';'.join(parts))
+ elif parts[0] == b'4':
+ out.append(b'4' if parts[1] != b'0' else b'24')
+ # 58:... (underline colour) has no semicolon form a player reads
+ return b'\x1b[' + b';'.join(out) + b'm'
+
+
+def incomplete_tail(buf):
+ """Where an escape sequence the buffer ends inside of starts, or None."""
+ i = buf.rfind(b'\x1b')
+ if i < 0:
+ return None
+ tail = buf[i:]
+ if len(tail) == 1:
+ return i
+ kind = tail[1:2]
+ if kind == b'[':
+ return None if re.match(rb'\x1b\[[\x30-\x3f]*[\x20-\x2f]*[\x40-\x7e]', tail) else i
+ if kind in (b']', b'_', b'P', b'^'):
+ # Ended by BEL, else open: its ST (ESC \\) would be a later ESC.
+ return None if b'\x07' in tail[2:] else i
+ return None
+
+
+def utf8_tail(buf):
+ """How many bytes at the end of buf start a UTF-8 sequence not yet whole."""
+ for back in range(1, min(4, len(buf)) + 1):
+ b = buf[-back]
+ if b & 0xC0 == 0x80:
+ continue
+ need = 2 if b & 0xE0 == 0xC0 else 3 if b & 0xF0 == 0xE0 else 4 if b & 0xF8 == 0xF0 else 1
+ return back if need > back else 0
+ return 0
+
+
+def clean_env(home, binary):
+ """The environment from nothing: no session, mount, display or bus of
+ the caller's, and the recorded pardes first on PATH."""
+ runtime = home / 'run'
+ env = dict(
+ PATH=f'{binary.parent}:/usr/local/bin:/usr/bin:/bin',
+ HOME=str(home), USER='demo', LOGNAME='demo',
+ SHELL='/bin/sh', TERM='xterm-256color', LANG='C.UTF-8', LC_ALL='C.UTF-8', TZ='UTC',
+ XDG_CONFIG_HOME=str(home / 'config'), XDG_DATA_HOME=str(home / 'data'),
+ XDG_STATE_HOME=str(home / 'state'), XDG_CACHE_HOME=str(home / 'cache'), XDG_RUNTIME_DIR=str(runtime),
+ PS1='$ ', GIT_CONFIG_NOSYSTEM='1',
+ GIT_AUTHOR_NAME='Demo', GIT_AUTHOR_EMAIL='[email protected]',
+ GIT_COMMITTER_NAME='Demo', GIT_COMMITTER_EMAIL='[email protected]',
+ # The session's own, set for the recording: no clock in messages, no
+ # language server racing the keys.
+ PARDES_NOTIME='1',
+ )
+ for language in ['RS', 'C', 'GO', 'TS', 'PY', 'ZIG']:
+ env['PARDES_LSP_' + language] = ''
+ return env
+
+
+def descendants(pid):
+ """pid's descendants, by /proc's parent links."""
+ children = {}
+ for d in Path('/proc').iterdir():
+ if not d.name.isdigit():
+ continue
+ try:
+ stat = (d / 'stat').read_text()
+ except OSError:
+ continue
+ ppid = int(stat[stat.rfind(')') + 2:].split()[1])
+ children.setdefault(ppid, []).append(int(d.name))
+ out, todo = [], [pid]
+ while todo:
+ for c in children.get(todo.pop(), []):
+ out.append(c)
+ todo.append(c)
+ return out
+
+
+class Recording:
+ """pardes in a pty of ours, the screen it draws, and the cast so far."""
+
+ def __init__(self, binary, home, args, cwd, log=None):
+ self.binary = Path(binary).resolve()
+ self.log = log
+ self.screen = pyte.Screen(COLS, ROWS)
+ self.stream = pyte.ByteStream(self.screen)
+ self.lock = threading.Lock()
+ self.carry = b'' # an escape sequence or UTF-8 character not yet whole
+ self.pending = bytearray() # cleaned output not yet in an event
+ self.events = [] # (seconds, the screen's changes as text)
+ self.raw_events = [] # (seconds, bytes pardes wrote)
+ self.shown = None # the screen as the cast has drawn it
+ self.clock = 0.0
+ self.last_out = time.monotonic()
+ env = clean_env(home, self.binary)
+ master, slave = os.openpty()
+ fcntl.ioctl(slave, termios.TIOCSWINSZ, struct.pack('HHHH', ROWS, COLS, COLS * CELL_W, ROWS * CELL_H))
+ self.master = master
+ self.child = subprocess.Popen(
+ [str(self.binary), *map(str, args)], cwd=cwd, env=env,
+ stdin=slave, stdout=slave, stderr=open(home / 'stderr.log', 'ab'), start_new_session=True,
+ preexec_fn=lambda: fcntl.ioctl(0, termios.TIOCSCTTY, 0))
+ os.close(slave)
+ self.reader = threading.Thread(target=self._read, daemon=True)
+ self.reader.start()
+
+ # output
+
+ def _read(self):
+ while True:
+ try:
+ data = os.read(self.master, 65536)
+ except OSError:
+ return
+ if not data:
+ return
+ with self.lock:
+ self._take(data)
+ self.last_out = time.monotonic()
+
+ def _take(self, data):
+ buf = self.carry + data
+ cut = incomplete_tail(buf)
+ if cut is None:
+ cut = len(buf) - utf8_tail(buf)
+ buf, self.carry = buf[:cut], buf[cut:]
+ at = 0
+ for m in QUERY.finditer(buf):
+ self._show(buf[at:m.start()])
+ at = m.end()
+ self._answer(m)
+ self._show(buf[at:])
+
+ def _show(self, data):
+ if not data:
+ return
+ data = SGR.sub(sgr_semicolons, data)
+ self.pending += data
+ self.stream.feed(data)
+
+ def _answer(self, m):
+ reply = None
+ if m.group('decrqm') is not None:
+ reply = b'\x1b[?%s;0$y' % m.group('decrqm')
+ elif m.group('cpr') is not None:
+ reply = b'\x1b[%d;%dR' % (self.screen.cursor.y + 1, self.screen.cursor.x + 1)
+ elif m.group('dsr') is not None:
+ reply = b'\x1b[0n'
+ elif m.group('da1') is not None:
+ reply = DA1
+ elif m.group('xtv') is not None:
+ reply = XTVERSION
+ elif m.group('winop') == b'14':
+ reply = b'\x1b[4;%d;%dt' % (ROWS * CELL_H, COLS * CELL_W)
+ elif m.group('winop') == b'16':
+ reply = b'\x1b[6;%d;%dt' % (CELL_H, CELL_W)
+ elif m.group('winop') == b'18':
+ reply = b'\x1b[8;%d;%dt' % (ROWS, COLS)
+ if reply:
+ os.write(self.master, reply)
+
+ # the screen
+
+ def lines(self):
+ with self.lock:
+ return list(self.screen.display)
+
+ def text(self):
+ return '\n'.join(self.lines())
+
+ def find(self, text, after=0, nth=0, before=None):
+ """(col, row) of `text` on screen, searching rows from `after`."""
+ for r, line in enumerate(self.lines()):
+ if r < after or (before is not None and r >= before):
+ continue
+ at = line.find(text)
+ while at >= 0:
+ if nth == 0:
+ return at, r
+ nth -= 1
+ at = line.find(text, at + 1)
+ raise LookupError(f'{text!r} not on screen:\n' + self.text())
+
+ def cursor(self):
+ with self.lock:
+ return self.screen.cursor.x, self.screen.cursor.y
+
+ # input
+
+ def send(self, data):
+ os.write(self.master, data.encode() if isinstance(data, str) else data)
+
+ def mouse(self, button, col, row, release=False, motion=False):
+ """An SGR mouse report: button 0 left, 1 middle, 2 right, 3 none;
+ col and row from 0."""
+ code = button + (32 if motion else 0)
+ self.send(f'\x1b[<{code};{col + 1};{row + 1}{"m" if release else "M"}')
+
+ # steps
+
+ def busy(self):
+ """Whether a thread of pardes is running (or in disk wait)."""
+ tasks = Path(f'/proc/{self.child.pid}/task')
+ with contextlib.suppress(OSError):
+ for t in tasks.iterdir():
+ with contextlib.suppress(OSError):
+ stat = (t / 'stat').read_text()
+ if stat[stat.rfind(')') + 2] in 'RD':
+ return True
+ return False
+
+ def settle(self, until=None, quiet=.15, timeout=20, what='', sent=None):
+ """Wait for `until` and for pardes to go quiet and idle, after
+ drawing at least once since the input sent at `sent` (or for .8 s
+ to pass without its drawing: an input that changes nothing)."""
+ deadline = time.monotonic() + timeout
+ while True:
+ now = time.monotonic()
+ with self.lock:
+ drew = sent is None or self.last_out > sent or now - sent > .8
+ idle = drew and now - self.last_out >= quiet
+ idle = idle and not self.busy()
+ ok = until is None or until(self)
+ if ok and idle:
+ return
+ if now > deadline:
+ raise RuntimeError(f'timed out waiting for {what or until}\n' + self.text())
+ if self.child.poll() is not None:
+ raise RuntimeError('pardes exited\n' + self.text())
+ time.sleep(.01)
+
+ def step(self, keys=None, hold=.5, until=None, quiet=.15, timeout=20, what=''):
+ """Send `keys` (bytes, str or a callable), wait for `until` and quiet,
+ stamp the output at the clock and move the clock on by `hold`."""
+ sent = time.monotonic() if keys else None
+ if callable(keys):
+ keys(self)
+ elif keys:
+ self.send(keys)
+ self.settle(until, quiet, timeout, what, sent)
+ self.flush()
+ self.clock += min(hold, MAX_GAP)
+ if self.log:
+ self.log.write(f'--- t={self.clock:.2f} {keys!r} {what}\n' + self.text() + '\n')
+
+ def flush(self):
+ """Stamp what pardes wrote since the last step at the clock: as the
+ screen cells it changed (the cast), and as its raw bytes (--raw)."""
+ with self.lock:
+ data, self.pending = bytes(self.pending), bytearray()
+ now = snapshot(self.screen)
+ t = round(self.clock, 3)
+ if data:
+ self.raw_events.append((t, data))
+ delta = repaint(self.shown, now)
+ if delta:
+ self.events.append((t, delta))
+ self.shown = now
+
+ def type(self, text, per_key=.09, last_hold=.4, until=None):
+ for i, ch in enumerate(text):
+ last = i == len(text) - 1
+ self.step(ch, hold=last_hold if last else per_key, until=until if last else None, quiet=.06)
+
+ def click(self, button, col, row, hold=.4, until=None, what='', quiet=.15):
+ """Press and release `button` over one cell."""
+ def act(s):
+ s.mouse(3, col, row, motion=True)
+ time.sleep(.03)
+ s.mouse(button, col, row)
+ time.sleep(.03)
+ s.mouse(button, col, row, release=True)
+ self.step(act, hold=hold, until=until, what=what, quiet=quiet)
+
+ def sweep(self, button, start, end, show=.6, hold=.6, until=None, what='', quiet=.15):
+ """Press `button` at `start`, drag to `end`, hold it there for a
+ frame (the sweep's colour shows), then release."""
+ def press(s):
+ s.mouse(3, *start, motion=True)
+ time.sleep(.03)
+ s.mouse(button, *start)
+ c0, r0 = start
+ c1, r1 = end
+ n = max(abs(c1 - c0), abs(r1 - r0))
+ for i in range(1, n + 1):
+ time.sleep(.01)
+ s.mouse(button, c0 + (c1 - c0) * i // n, r0 + (r1 - r0) * i // n, motion=True)
+ self.step(press, hold=show)
+ self.step(lambda s: s.mouse(button, *end, release=True), hold=hold, until=until, what=what, quiet=quiet)
+
+ def hover(self, col, row, hold=.3):
+ self.step(lambda s: s.mouse(3, col, row, motion=True), hold=hold)
+
+ def close(self):
+ pids = descendants(self.child.pid)
+ if self.child.poll() is None:
+ self.child.terminate()
+ try:
+ self.child.wait(timeout=5)
+ except subprocess.TimeoutExpired:
+ self.child.kill()
+ self.child.wait()
+ for pid in pids + descendants(self.child.pid):
+ with contextlib.suppress(ProcessLookupError, PermissionError):
+ os.kill(pid, signal.SIGKILL)
+ with contextlib.suppress(ProcessLookupError, PermissionError):
+ os.killpg(self.child.pid, signal.SIGKILL)
+ with contextlib.suppress(OSError):
+ os.close(self.master)
+ self.reader.join(timeout=2)
+
+ def cast(self, title, raw=False):
+ """The asciicast v2 text: the events, then the last hold as an
+ empty event so the player keeps the last frame up."""
+ head = dict(version=2, width=COLS, height=ROWS, title=title,
+ env=dict(TERM='xterm-256color', SHELL='/bin/sh'))
+ out = [json.dumps(head)]
+ if raw:
+ decoder = codecs.getincrementaldecoder('utf-8')('replace')
+ events = [(t, decoder.decode(data)) for t, data in self.raw_events]
+ else:
+ events = self.events
+ # Steps that changed nothing leave their holds end to end: no gap
+ # longer than MAX_GAP survives.
+ last = shift = 0.0
+ for t, text in events + [(self.clock, '')]:
+ shift += max(0.0, t - last - MAX_GAP)
+ last = t
+ out.append(json.dumps([round(t - shift, 3), 'o', text], ensure_ascii=False))
+ return '\n'.join(out) + '\n'
+
+
+# ---------------------------------------------------------------------------
+# the cast's own drawing: pyte's screen, cell by cell
+#
+# pardes draws diffs against its last frame, and how many frames a step
+# took (a shell's output racing the frames) varies from run to run while
+# the screen it ends on does not. So an event is the cells the step left
+# changed, drawn afresh: the same steps make the same file.
+
+NAMED = {name: i for i, name in enumerate(['black', 'red', 'green', 'brown', 'blue', 'magenta', 'cyan', 'white'])}
+
+
+def snapshot(screen):
+ """The screen's cells as tuples, and its cursor."""
+ cells = [[tuple(c) for c in (screen.buffer[y][x] for x in range(screen.columns))]
+ for y in range(screen.lines)]
+ return cells, (screen.cursor.x, screen.cursor.y, screen.cursor.hidden)
+
+
+def colour(name, base):
+ """SGR parameters for a pyte colour: base 30 for fg, 40 for bg."""
+ if name == 'default':
+ return [str(base + 9)]
+ if name in NAMED:
+ return [str(base + NAMED[name])]
+ if name.startswith('bright') and name[6:] in NAMED:
+ return [str(base + 60 + NAMED[name[6:]])]
+ r, g, b = (int(name[i:i + 2], 16) for i in (0, 2, 4))
+ return [str(base + 8), '2', str(r), str(g), str(b)]
+
+
+def pen(cell):
+ _, fg, bg, bold, italics, underscore, strikethrough, reverse, blink = cell
+ params = ['0'] + colour(fg, 30) + colour(bg, 40)
+ for on, code in ((bold, '1'), (italics, '3'), (underscore, '4'), (blink, '5'), (reverse, '7'), (strikethrough, '9')):
+ if on:
+ params.append(code)
+ return '\x1b[' + ';'.join(params) + 'm'
+
+
+def repaint(old, new):
+ """The text that turns screen `old` (None: a fresh terminal) into `new`."""
+ cells, (cx, cy, hidden) = new
+ out = []
+ if old is None:
+ out.append('\x1b[?25l\x1b[0m\x1b[2J')
+ blank = (' ', 'default', 'default', False, False, False, False, False, False)
+ old_cells, old_cursor = [[blank] * len(row) for row in cells], None
+ else:
+ old_cells, old_cursor = old
+ at, drawn_pen = None, None
+ for y, (row, old_row) in enumerate(zip(cells, old_cells)):
+ for x, cell in enumerate(row):
+ if cell == old_row[x] or cell[0] == '': # unchanged, or the right half of a wide character
+ continue
+ if at != (x, y):
+ out.append(f'\x1b[{y + 1};{x + 1}H')
+ p = pen(cell)
+ if p != drawn_pen:
+ out.append(p)
+ drawn_pen = p
+ out.append(cell[0])
+ at = (x + (2 if x + 1 < len(row) and row[x + 1][0] == '' else 1), y)
+ if not out and old_cursor == (cx, cy, hidden):
+ return ''
+ out.append(f'\x1b[0m\x1b[{cy + 1};{cx + 1}H' + ('\x1b[?25l' if hidden else '\x1b[?25h'))
+ return ''.join(out)
+
+
+# ---------------------------------------------------------------------------
+# the fixtures
+
+MAIN_C = '''#include <stdio.h>
+
+static int square(int n)
+{
+ return n * n;
+}
+
+int main(void)
+{
+ int sum = 0;
+ for (int i = 1; i <= 4; i++)
+ sum += square(i);
+ printf("squares: %d\\n", sum);
+ return 0;
+}
+'''
+
+MAKEFILE = '''CFLAGS = -Wall -fno-diagnostics-show-caret -fno-diagnostics-show-option -fno-diagnostics-color
+
+hello: main.c
+\t@cc $(CFLAGS) -o hello main.c
+'''
+
+README = '''hello: prints the sum of the first four squares.
+
+Build it with make, run it with ./hello.
+'''
+
+NOTES = '''hello: notes
+
+The sum of the squares of 1 to 4 is 30.
+What ./hello prints:
+
+'''
+
+
+def git(*args, cwd=PROJECT, env=None, date=None):
+ e = dict(env or {})
+ if date:
+ e.update(GIT_AUTHOR_DATE=date, GIT_COMMITTER_DATE=date)
+ subprocess.run(['git', *args], cwd=cwd, env=e, check=True, capture_output=True)
+
+
+def make_project(home):
+ shutil.rmtree(PROJECT, ignore_errors=True)
+ PROJECT.mkdir(parents=True)
+ env = clean_env(home, Path('/nonexistent/pardes'))
+ env.pop('PS1')
+ git('init', '-q', '-b', 'main', env=env)
+ git('config', 'color.ui', 'always', env=env)
+ history = [
+ ('2026-09-21T10:00:00Z', 'Start hello', {'README': README}),
+ ('2026-09-22T11:30:00Z', 'Add a Makefile', {'Makefile': MAKEFILE}),
+ ('2026-09-24T09:15:00Z', 'Print the sum of squares', {'main.c': MAIN_C}),
+ ('2026-09-27T16:40:00Z', 'Keep notes', {'NOTES': NOTES}),
+ ]
+ for date, message, files in history:
+ for name, body in files.items():
+ (PROJECT / name).write_text(body)
+ git('add', '.', env=env)
+ git('commit', '-q', '-m', message, env=env, date=date)
+ git('tag', 'v0.1', env=env)
+ (PROJECT / '.gitignore').write_text('hello\n')
+
+
+ART = PROJECT.parent / 'art'
+SDM = Path('/home/goblin/01-resources/intel-devref-ii.pdf')
+
+# The escape-time bands, outside in: deep blue to white to orange, chosen
+# to land on distinct C64 colours when the matcher picks its 16.
+CYCLE = [(20, 40, 160), (40, 120, 230), (120, 210, 250), (250, 250, 250),
+ (250, 220, 60), (230, 90, 20), (120, 30, 40), (20, 40, 160)]
+
+
+def band(t):
+ t = t % 1.0 * (len(CYCLE) - 1)
+ i, u = int(t), t % 1.0
+ return tuple(round(a + (b - a) * u) for a, b in zip(CYCLE[i], CYCLE[i + 1]))
+
+
+def mandelbrot(path, cx, cy, width, iterations, period, size=(480, 320)):
+ """The Mandelbrot set around cx+cy·i, `width` across: the set black,
+ the outside in smooth escape-time bands that repeat every `period`
+ (in √iterations). Pure Python, a few seconds."""
+ from PIL import Image
+ w, h = size
+ height = width * h / w
+ im = Image.new('RGB', size)
+ px = im.load()
+ for j in range(h):
+ y0 = cy + height / 2 - height * (j + .5) / h
+ for i in range(w):
+ x0 = cx - width / 2 + width * (i + .5) / w
+ x = y = 0.0
+ n = 0
+ while x * x + y * y <= 256 and n < iterations:
+ x, y = x * x - y * y + x0, 2 * x * y + y0
+ n += 1
+ if n == iterations:
+ px[i, j] = (0, 0, 0)
+ else:
+ nu = n + 1 - math.log2(math.log2(math.sqrt(x * x + y * y)))
+ px[i, j] = band(math.sqrt(max(nu, 0)) / period)
+ im.save(path)
+
+
+def make_art(pdf):
+ """The classic view, a seahorse valley's spiral, and a short name for
+ the manual (a symlink: the PDF is only read)."""
+ shutil.rmtree(ART, ignore_errors=True)
+ ART.mkdir(parents=True)
+ mandelbrot(ART / 'mandelbrot.png', -0.65, 0.0, 3.2, 96, 10.5)
+ mandelbrot(ART / 'seahorse.png', -0.7436, 0.1318, 0.0035, 400, 6.0)
+ (ART / 'sdm.pdf').symlink_to(pdf)
+
+
+# ---------------------------------------------------------------------------
+# checking: replay a cast through pyte, print and draw its last frame
+
+def replay(path, until=None):
+ """The pyte screen after the cast's events (up to `until` seconds)."""
+ screen = pyte.Screen(COLS, ROWS)
+ stream = pyte.Stream(screen)
+ with open(path) as f:
+ head = json.loads(f.readline())
+ assert head['width'] == COLS and head['height'] == ROWS
+ for line in f:
+ t, kind, data = json.loads(line)
+ if until is not None and t > until:
+ break
+ stream.feed(data)
+ return screen
+
+
+PYTE_COLOURS = dict(black=(0, 0, 0), red=(205, 0, 0), green=(0, 205, 0), brown=(205, 205, 0),
+ blue=(0, 0, 238), magenta=(205, 0, 205), cyan=(0, 205, 205), white=(229, 229, 229))
+
+
+def rgb(colour, default):
+ if colour == 'default':
+ return default
+ if colour in PYTE_COLOURS:
+ return PYTE_COLOURS[colour]
+ if colour.startswith('bright') and colour[6:] in PYTE_COLOURS:
+ return tuple(min(255, v + 50) for v in PYTE_COLOURS[colour[6:]])
+ with contextlib.suppress(ValueError):
+ return tuple(int(colour[i:i + 2], 16) for i in (0, 2, 4))
+ return default
+
+
+def block_rects(cp, w, h):
+ """The filled rectangles of a block element or sextant, else None."""
+ if 0x1FB00 <= cp <= 0x1FB3B: # sextants, skipping the four that are blocks
+ n = cp - 0x1FB00 + 1
+ n += n >= 21
+ n += n >= 42
+ bits = n
+ elif cp in (0x258C, 0x2590, 0x2588):
+ bits = {0x258C: 21, 0x2590: 42, 0x2588: 63}[cp]
+ else:
+ halves = {0x2580: [(0, 0, 1, .5)], 0x2584: [(0, .5, 1, 1)], 0x2596: [(0, .5, .5, 1)],
+ 0x2597: [(.5, .5, 1, 1)], 0x2598: [(0, 0, .5, .5)], 0x259D: [(.5, 0, 1, .5)],
+ 0x2599: [(0, 0, .5, 1), (.5, .5, 1, 1)], 0x259B: [(0, 0, 1, .5), (0, .5, .5, 1)],
+ 0x259C: [(0, 0, 1, .5), (.5, .5, 1, 1)], 0x259F: [(.5, 0, 1, 1), (0, .5, .5, 1)],
+ 0x259A: [(0, 0, .5, .5), (.5, .5, 1, 1)], 0x259E: [(.5, 0, 1, .5), (0, .5, .5, 1)]}
+ if cp not in halves:
+ return None
+ return [(x0 * w, y0 * h, x1 * w, y1 * h) for x0, y0, x1, y1 in halves[cp]]
+ rects = []
+ for k in range(6):
+ if bits >> k & 1:
+ col, row = k % 2, k // 2
+ rects.append((col * w / 2, row * h / 3, (col + 1) * w / 2, (row + 1) * h / 3))
+ return rects
+
+
+def render_png(screen, path, cw=10, ch=20):
+ """A picture of a pyte screen: blocks and sextants drawn, the rest in
+ the repo's Adwaita Mono."""
+ from PIL import Image, ImageDraw, ImageFont
+ font = ImageFont.truetype(str(REPO / 'assets/AdwaitaMono-Regular.ttf'), 16)
+ im = Image.new('RGB', (screen.columns * cw, screen.lines * ch), (0, 0, 0))
+ d = ImageDraw.Draw(im)
+ for y in range(screen.lines):
+ row = screen.buffer[y]
+ for x in range(screen.columns):
+ c = row[x]
+ fg, bg = rgb(c.fg, (229, 229, 229)), rgb(c.bg, (0, 0, 0))
+ if c.reverse != (not screen.cursor.hidden and (x, y) == (screen.cursor.x, screen.cursor.y)):
+ fg, bg = bg, fg
+ x0, y0 = x * cw, y * ch
+ d.rectangle((x0, y0, x0 + cw - 1, y0 + ch - 1), fill=bg)
+ if not c.data or c.data == ' ':
+ continue
+ rects = block_rects(ord(c.data[0]), cw, ch) if len(c.data) == 1 else None
+ if rects is not None:
+ for a, b, e, f in rects:
+ d.rectangle((x0 + a, y0 + b, x0 + e - 1, y0 + f - 1), fill=fg)
+ else:
+ d.text((x0, y0 + 1), c.data, font=font, fill=fg)
+ im.save(path)
+
+
+# ---------------------------------------------------------------------------
+# the casts
+
+def tag_end(r, row):
+ """Two cells past the last word of the tag on `row`: a B1 click there
+ types at the tag's end."""
+ return len(r.lines()[row].rstrip()) + 1, row
+
+
+def type_in_tag(r, row, text):
+ r.click(0, *tag_end(r, row), hold=.3)
+ r.type(text, until=lambda s: text.strip() in s.lines()[row], last_hold=.3)
+ r.step('\x1b', hold=.4)
+
+
+def word_at(r, word, row):
+ """(start, end) cells of `word` on `row`, end exclusive, as a sweep takes it."""
+ c, w = r.find(word, after=row, before=row + 1)
+ return (c, w), (c + len(word), w)
+
+
+def keys(r, seq, hold, per_key=.22):
+ for i, k in enumerate(seq):
+ r.step(k, hold=hold if i == len(seq) - 1 else per_key)
+
+
+def live_text(r):
+ """Helix keys rename a variable and save; B2 on make runs it, B3 on the
+ error it prints lands on the line, and the rename goes on there."""
+ r.step(None, until=lambda s: '#include' in s.text(), quiet=.4, hold=1.6)
+ for _ in range(9):
+ r.step('j', hold=.13, quiet=.05)
+ r.clock += .3
+ keys(r, 'wwe', .35)
+ keys(r, 'miw', .7)
+ r.step('c', hold=.3)
+ r.type('total', until=lambda s: 'int total' in s.text(), last_hold=.3)
+ r.step('\x1b', hold=.6)
+ keys(r, ' fs', 1.1)
+ type_in_tag(r, 2, ' make')
+ start, end = word_at(r, 'make', 2)
+ r.sweep(1, start, end, show=.8, hold=2.0, until=lambda s: 'exit 2' in s.text(), what='make')
+ c, w = r.find('main.c:12:9')
+ r.sweep(2, (c, w), (c + len('main.c:12:9'), w), show=.8, hold=1.6,
+ until=lambda s: s.cursor()[1] < 15, what='the look')
+ keys(r, 'miw', .6)
+ r.step('c', hold=.3)
+ r.type('total', until=lambda s: 'total += square' in s.text(), last_hold=.3)
+ r.step('\x1b', hold=2.0)
+
+
+def terminals(r):
+ """A shell beside the notes: run, Ctrl-b, copy a line of output into
+ the file; then git log paged through pardes - into a +Pager."""
+ r.step(None, until=lambda s: 'What ./hello prints' in s.text(), quiet=.4, hold=1.2)
+ start, end = word_at(r, 'Tty', 2)
+ r.sweep(1, start, end, show=.5, hold=.6, until=lambda s: 'Tty+sh' in s.text(), what='the shell')
+ r.step(None, until=lambda s: s.text().count('$') >= 2, hold=.4)
+ # Its own column, beside the file: Ctrl-b to normal, Alt-c, Ctrl-b back.
+ r.step('\x02', hold=.3)
+ r.step('\x1bc', hold=.4, until=lambda s: s.lines()[1].count('Delcol') == 2, what='the new column')
+ r.step('\x02', hold=.6)
+ r.type('make && ./hello', until=lambda s: 'make && ./hello' in s.text(), per_key=.07)
+ r.step('\r', hold=1.2, until=lambda s: 'squares: 30' in s.text(), what='./hello')
+ r.step('\x02', hold=.6)
+ r.step('k', hold=.4)
+ r.step('x', hold=.6)
+ r.step('y', hold=.5)
+ r.step('\x17', hold=.2)
+ r.step('h', hold=.6)
+ keys(r, 'ge', .5)
+ r.step('p', hold=1.0, until=lambda s: 'squares: 30' in s.lines()[8][:48], what='the paste')
+ r.step('\x17', hold=.2)
+ r.step('l', hold=.4)
+ r.step('\x02', hold=.6)
+ command = 'git log --stat | pardes -'
+ r.type(command, until=lambda s: command in s.text(), per_key=.07)
+ r.step('\r', hold=2.0, until=lambda s: '+Pager' in s.text() and 'Keep notes' in s.text(), quiet=.3,
+ what='the pager')
+
+
+def petscii(r):
+ """Mandelbrot views as PETSCII glyph art; the Intel manual's sections,
+ a jump to CPUID and a search."""
+ def close(name, hold):
+ c, w = r.find('art/' + name, after=2)
+ start, end = word_at(r, 'Del', w)
+ r.sweep(1, start, end, show=.4, hold=hold, until=lambda s: 'art/' + name not in s.text(), what='Del ' + name)
+
+ r.step(None, until=lambda s: 'mandelbrot.png' in s.text(), quiet=.6, hold=2.2)
+ keys(r, ' tp', 1.5) # Petscii: the tag reads petscii:on
+ type_in_tag(r, 1, ' seahorse.png') # the column's tag; B3 there opens it in the column
+ start, end = word_at(r, 'seahorse.png', 1)
+ r.sweep(2, start, end, show=.5, hold=1.0, until=lambda s: 'art/seahorse.png' in s.text(), what='the seahorse')
+ close('mandelbrot.png', 2.2)
+ type_in_tag(r, 1, ' sdm.pdf')
+ start, end = word_at(r, 'sdm.pdf', 1)
+ r.sweep(2, start, end, show=.5, hold=1.0, until=lambda s: '[1/2515]' in s.text(), what='the PDF')
+ close('seahorse.png', 1.2)
+ c, w = r.find('PdfSections')
+ r.sweep(1, (c, w), (c + len('PdfSections'), w), show=.5, hold=1.3,
+ until=lambda s: 'Chapter 1' in s.text(), what='the sections')
+ c, w = r.find('+PdfSections')
+ type_in_tag(r, w, ' CPUID')
+ for _ in range(2): # B3 on a word in a tag finds it in the body
+ start, end = word_at(r, 'CPUID', w)
+ r.sweep(2, start, end, show=.4, hold=.9)
+ c, row = r.find('intel-devref-ii.pdf:317:200')
+ r.sweep(2, (c, row), (c + len('intel-devref-ii.pdf:317'), row), show=.5, hold=1.8,
+ until=lambda s: '[317/2515]' in s.text(), what='the jump')
+ start, end = word_at(r, 'Del', w)
+ r.sweep(1, start, end, show=.4, hold=.8, until=lambda s: '+PdfSections' not in s.text(), what='Del sections')
+ r.click(0, 40, 8, hold=.3)
+ r.step('/', hold=.3)
+ r.type('Vendor', until=lambda s: '/Vendor' in s.text(), last_hold=.3)
+ r.step('\r', hold=2.0, until=lambda s: '+Search' in s.text(), quiet=.4, timeout=60, what='the search')
+
+
+CASTS = {
+ 'live-text': dict(
+ run=live_text, cwd=PROJECT, args=['main.c'],
+ title='Live text',
+ caption='Helix keys edit; middle-click runs make; right-click the error opens its line.'),
+ 'terminals': dict(
+ run=terminals, cwd=PROJECT, args=['NOTES'],
+ title='Terminals as panes',
+ caption='A shell beside a file: Ctrl-b, copy a line, page git log.'),
+ 'petscii': dict(
+ run=petscii, cwd=ART, args=['mandelbrot.png'],
+ title='Images and PDFs in a plain terminal',
+ caption='No kitty graphics: Mandelbrots as PETSCII, then the Intel manual searched.'),
+}
+
+
+def make_home(work):
+ home = work / 'home'
+ shutil.rmtree(home, ignore_errors=True)
+ (home / 'config/pardes').mkdir(parents=True)
+ (home / 'run').mkdir(mode=0o700)
+ # No animation or blink: a frame drawn on a timer would land in the cast
+ # wherever the wall clock put it.
+ (home / 'config/pardes/init').write_text(
+ 'Shell /bin/sh\nMessageLinger 0\nMessageAnimation off\nCursorBlink off\n'
+ 'SelectionGlow off\nHoverGlow off\nJumpTrail off\nThumbFlash off\n')
+ (home / '.gitconfig').write_text('[log]\n\tabbrevCommit = true\n[color]\n\tui = always\n')
+ return home
+
+
+def record(binary, work, name, pdf):
+ cast = CASTS[name]
+ home = make_home(work)
+ make_project(home)
+ make_art(pdf)
+ log = open(work / f'{name}.steps.log', 'w')
+ r = Recording(binary, home, cast['args'], cast['cwd'], log=log)
+ try:
+ cast['run'](r)
+ finally:
+ r.close()
+ log.close()
+ OUT.mkdir(parents=True, exist_ok=True)
+ out = OUT / f'{name}.cast'
+ out.write_text(r.cast(cast['title']))
+ (work / f'{name}.raw.cast').write_text(r.cast(cast['title'], raw=True))
+ length = json.loads(out.read_text().splitlines()[-1])[0]
+ print(f'{name}: {length:.1f} s, {out.stat().st_size / 1024:.0f} KB')
+
+
+def write_toml():
+ lines = ['# The landing page\'s terminal recordings (asciicast v2), in the order',
+ '# shown: casts/<name>.cast for each. The first plays by itself; all loop',
+ '# and fit the page\'s width. While `ready` is false the cast shows',
+ '# placeholder.svg, so the site builds before a recording exists.',
+ '# test/record_casts.py writes the casts and this file.', '']
+ for name, cast in CASTS.items():
+ lines += ['[[cast]]', f'name = "{name}"', f'title = "{cast["title"]}"',
+ f'caption = "{cast["caption"]}"', f'cols = {COLS}', f'rows = {ROWS}',
+ f'ready = {"true" if (OUT / f"{name}.cast").exists() else "false"}', '']
+ (OUT / 'casts.toml').write_text('\n'.join(lines))
+
+
+def show(name, work, at=None):
+ """Print a cast's frame (the last, or the one at `at` seconds) as text
+ and draw it to <work>/<name>.png."""
+ screen = replay(OUT / f'{name}.cast', at)
+ print(f'--- {name}' + (f' at {at} s' if at is not None else ''))
+ for line in screen.display:
+ print(line.rstrip())
+ render_png(screen, work / f'{name}.png')
+
+
+def main():
+ parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
+ parser.add_argument('binary', type=Path, nargs='?', help='a pardes built with -Dplatform=tty')
+ parser.add_argument('casts', nargs='*', help='which casts (default: all): ' + ', '.join(CASTS))
+ parser.add_argument('--work', type=Path, default=REPO.parent / '.scratch/casts/work',
+ help='the session home, step logs and check pictures (default %(default)s)')
+ parser.add_argument('--pdf', type=Path, default=SDM, help='the manual the petscii cast reads (default %(default)s)')
+ parser.add_argument('--show', action='store_true', help='print and draw each cast\'s last frame after')
+ parser.add_argument('--show-only', action='store_true', help='record nothing; print and draw the casts as they are')
+ parser.add_argument('--at', type=float, help='with --show: the frame at this many seconds instead')
+ args = parser.parse_args()
+ if args.binary is not None and args.binary.name in CASTS and not args.binary.exists():
+ args.casts.insert(0, args.binary.name)
+ args.binary = None
+ names = args.casts or list(CASTS)
+ for name in names:
+ if name not in CASTS:
+ parser.error(f'no cast {name!r}: ' + ', '.join(CASTS))
+ work = args.work.resolve()
+ work.mkdir(parents=True, exist_ok=True)
+ if not args.show_only:
+ if args.binary is None:
+ parser.error('the pardes binary to record is required')
+ if not args.pdf.is_file():
+ parser.error(f'no PDF at {args.pdf}')
+ try:
+ for name in names:
+ record(args.binary, work, name, args.pdf.resolve())
+ finally:
+ shutil.rmtree(PROJECT.parent, ignore_errors=True)
+ write_toml()
+ if args.show or args.show_only:
+ for name in names:
+ show(name, work, args.at)
+
+
+if __name__ == '__main__':
+ main()