summaryrefslogtreecommitdiff
path: root/test/record_casts.py
diff options
context:
space:
mode:
Diffstat (limited to 'test/record_casts.py')
-rw-r--r--test/record_casts.py947
1 files changed, 947 insertions, 0 deletions
diff --git a/test/record_casts.py b/test/record_casts.py
new file mode 100644
index 00000000..13893517
--- /dev/null
+++ b/test/record_casts.py
@@ -0,0 +1,947 @@
+#!/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; /* 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/<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()