#!/usr/bin/env python3 """Record the documentation site's terminal casts (asciicast v2). zig build -Dplatform=tty -Doptimize=ReleaseSafe --prefix python3 -B test/record_casts.py /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 /.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 /.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\d+)\$p # DECRQM: answered "not recognised" | \x1b\[(?P6)n # cursor position | \x1b\[(?P5)n # device status: each frame ends asking it | \x1b\[(?P0?)c # primary device attributes | \x1b\[>(?P0?)q # XTVERSION | \x1b\[(?P1[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::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='demo@example.com', GIT_COMMITTER_NAME='Demo', GIT_COMMITTER_EMAIL='demo@example.com', # 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 static int square(int n) { return n * n; } int main(void) { int sum = 0; /* 1 + 4 + 9 + 16 */ 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) ART_README = '''pictures, right-click a name: mandelbrot.png the whole set seahorse.png a seahorse valley, zoomed in sdm.pdf Intel SDM vol. 2, 2,515 pages ''' def make_art(pdf): """The classic view, a seahorse valley's spiral, a short name for the manual (a symlink: the PDF is only read), and a README naming them.""" 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) (ART / 'README').write_text(ART_README) # --------------------------------------------------------------------------- # 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): """Right-click opens the Mandelbrot views, drawn as PETSCII glyph art; then the Intel manual: its sections, a jump to CPUID and a search.""" def look(name, until, hold): c, w = r.find(name) r.sweep(2, (c, w), (c + len(name), w), show=.5, hold=hold, until=until, quiet=.4, what=name) 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: 'sdm.pdf' in s.text(), quiet=.4, hold=1.6) look('mandelbrot.png', lambda s: 'art/mandelbrot.png' in s.text(), 2.0) close('mandelbrot.png', .5) look('seahorse.png', lambda s: 'art/seahorse.png' in s.text(), 2.0) close('seahorse.png', .5) look('sdm.pdf', lambda s: '[1/2515]' in s.text(), 1.5) 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') c, w = r.find('CPUID—CPU Identification') r.click(0, c, w + 3, hold=.3) r.step('/', hold=.3) r.type('bit 21', until=lambda s: '/bit 21' 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=['README'], title='Images and PDFs in a plain terminal', caption='No kitty graphics: a Mandelbrot in 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( 'Theme orchard\nShell /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/.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 /.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()