summaryrefslogtreecommitdiff
path: root/test/record_demos.py
diff options
context:
space:
mode:
Diffstat (limited to 'test/record_demos.py')
-rw-r--r--test/record_demos.py746
1 files changed, 687 insertions, 59 deletions
diff --git a/test/record_demos.py b/test/record_demos.py
index 655ec475..3c64f55c 100644
--- a/test/record_demos.py
+++ b/test/record_demos.py
@@ -1,16 +1,23 @@
#!/usr/bin/env python3
-"""Record the documentation site's landing-page clips from a hidden window.
+"""Record the documentation site's clips and theme shots from a hidden window.
zig build -Dplatform=gui -Doptimize=ReleaseSafe --prefix <scratch dir>
-python3 -B test/record_demos.py <scratch dir>/bin/pardes-gui [clip ...]
+python3 -B test/record_demos.py <scratch dir>/bin/pardes-gui [clip ... | themes]
Each clip drives a throwaway session (PARDES_TEST: an offscreen capture,
the virtual clock) through its stdin's window-pixel mouse events, its keys
and its 9P files, keeps the settled frame after every step, and lays the
-frames out on a timeline with a drawn pointer. ffmpeg encodes the timeline
-to docs/site/media/<clip>.webm and .mp4, with <clip>.png as its poster and
-captions.txt beside them. Needs a Wayland display for the hidden window,
-gcc and git for the demo project, and 9ns (cloud9) for the scripting clip.
+frames out on a timeline with a drawn pointer: a click glides there,
+stops, and presses and releases still, a ring and the button's name drawn.
+ffmpeg encodes the timeline to docs/site/media/<clip>.webm and .mp4, with
+<clip>.png as its poster and captions.txt beside them (clips.toml, which
+the site reads, is kept by hand). `themes` instead writes
+docs/site/media/themes/<theme>.png for each theme of src/themes, in the
+Themes builtin's order, and themes.toml.
+
+Needs a Wayland display for the hidden window, gcc and git for the demo
+project, 9ns (cloud9) for the scripting clip, Pillow and numpy, and the
+Intel SDM vol. 2 for the PDF clips (--pdf; read where it is, never copied).
Frames and the intermediate video go under --work (default
../.scratch/rec/work, beside the checkout). The demo project lives at
@@ -103,6 +110,7 @@ def make_project():
(PROJECT / 'src/main.c').write_text(MAIN_C)
(PROJECT / 'Makefile').write_text(MAKEFILE)
(PROJECT / 'README').write_text(README)
+ (PROJECT / 'notes.txt').write_text(NOTES)
class Session:
@@ -150,6 +158,7 @@ class Session:
self.pointer = None # the drawn pointer's (x, y), None for none
self.button = None # the SDL button held, drawn as a ring and its name
self.badge = None # a key or two, drawn in the corner
+ self.held = [] # the buttons a chord holds, each drawn as a ring and on a mouse
self.settle()
def _drain(self):
@@ -241,6 +250,19 @@ class Session:
cw, ch = w / self.cols, h / self.rows
return int(start * cw + (col - start + offset) * TAG_W), int((row + .55) * ch)
+ def tag_word(self, serial, word):
+ """The pixel over the middle of `word` in pane `serial`'s tag, which
+ may run past the grid's columns (/screen cuts a tag at its width,
+ the window wraps it at its own): its rows hold (width - 3 cells) /
+ TAG_W characters each."""
+ tag = self.tag(serial)
+ w, h = self.size()
+ cw, ch = w / self.cols, h / self.rows
+ per_row = int((w - 3 * cw) // TAG_W)
+ top = self.find(tag[:min(len(tag), self.cols - 4)], 0)[1]
+ at = tag.index(word) + len(word) // 2
+ return int(3 * cw + (at % per_row + .5) * TAG_W), int((top + at // per_row + .55) * ch)
+
def size(self):
with open(self.caps / 'latest.ppm', 'rb') as f:
f.readline()
@@ -280,37 +302,70 @@ class Session:
# the timeline
- def shot(self, seconds, poster=False):
+ def shot(self, seconds, poster=False, ring=None, ring_button=None):
"""Hold the settled frame for `seconds`, the pointer where it is.
- `poster` marks the frame drawn as the clip's still."""
+ `poster` marks the frame drawn as the clip's still; `ring` draws a
+ click there ('press' while held, 'pulse' just after)."""
data = self.settle()
path = self.frames / f'{len(self.shots):04d}.ppm'
path.write_bytes(data)
self.shots.append(dict(frame=path, secs=seconds, start=self.pointer, end=self.pointer,
- button=self.button, badge=self.badge, poster=poster))
+ button=self.button, badge=self.badge, poster=poster, ring=ring,
+ ring_button=ring_button or self.button, held=tuple(self.held)))
def glide(self, x, y, seconds=.6):
"""Glide the drawn pointer to (x, y) over the frame on screen, then
move the window's pointer there (dragging any button held)."""
if self.shots and self.pointer is not None:
last = self.shots[-1]
- self.shots.append(dict(last, secs=seconds, start=self.pointer, end=(x, y), poster=False))
+ self.shots.append(dict(last, secs=seconds, start=self.pointer, end=(x, y), poster=False, ring=None,
+ held=tuple(self.held)))
self.pointer = (x, y)
- self.mouse('motion', self.button or 0, x, y)
+ self.mouse('motion', self.button or (self.held[0] if self.held else 0), x, y)
- def sweep(self, button, start, end, hold=.5):
- """Press `button` (SDL: 1 left, 2 middle, 3 right) at `start`,
- drag it to `end` and release: the sweep acme's B2 and B3 take."""
- if self.pointer != start:
- self.glide(*start)
- self.mouse('down', button, *start)
- self.button = button
- self.shot(.15)
- if end != start:
- self.glide(*end, seconds=.3)
+ def chord_press(self, x, y, rest=.4, hold=.5):
+ """Glide to (x, y), stop, and press B1 there, holding it."""
+ if self.pointer != (x, y):
+ self.glide(x, y)
+ self.shot(rest)
+ self.mouse('down', 1, x, y)
+ self.held = [1]
self.shot(hold)
+
+ def chord_click(self, button, done, what, hold=.45, after=1.0):
+ """With B1 held and the pointer still, click `button`: both rings
+ and the mouse's two buttons lit on the press, then a pulse."""
+ self.mouse('down', button, *self.pointer)
+ self.held.append(button)
+ self.shot(hold, ring='press', ring_button=button)
self.mouse('up', button, *self.pointer)
+ self.held.remove(button)
+ self.wait(done, what)
+ self.shot(after, ring='pulse', ring_button=button)
+
+ def chord_release(self, after=.6):
+ """Let B1 go, the pointer still."""
+ self.mouse('up', 1, *self.pointer)
+ self.held = []
+ self.shot(after)
+
+ def click(self, button, x, y, done=None, what='the click', rest=.4, hold=.35, after=1.0):
+ """Glide to (x, y) and stop; a beat later press `button` (SDL: 1
+ left, 2 middle, 3 right) and release it with the pointer still, a
+ ring and the button's name drawn on the press; once `done()` says
+ the click took, hold the result `after` seconds, the ring pulsing
+ out, before anything moves again."""
+ if self.pointer != (x, y):
+ self.glide(x, y)
+ self.shot(rest)
+ self.mouse('down', button, x, y)
+ self.button = button
+ self.shot(hold, ring='press')
+ self.mouse('up', button, x, y)
self.button = None
+ if done is not None:
+ self.wait(done, what)
+ self.shot(after, ring='pulse', ring_button=button)
def press(self, keys, per_key=.4):
"""Send each (key bytes, badge) of `keys`, a frame a key with the
@@ -342,8 +397,9 @@ def pointer_sprite(scale):
return im
-# acme's colours for the buttons: B2 red, B3 green.
-BUTTONS = {1: ('click', (70, 70, 70)), 2: ('middle-click', (170, 0, 0)), 3: ('right-click', (0, 110, 40))}
+# acme's colours for the buttons: B2 red, B3 green; B1, which acme leaves
+# black, is blue here so a held left button shows on the mouse.
+BUTTONS = {1: ('click', (30, 80, 190)), 2: ('middle-click', (170, 0, 0)), 3: ('right-click', (0, 110, 40))}
def draw_badge(img, text, xy, font, fill=(40, 40, 40), anchor='lt'):
@@ -360,21 +416,76 @@ class Painter:
self.font = ImageFont.truetype(str(BADGE_FONT), 24)
self.size = size
- def paint(self, img, pointer, button, badge):
+ def chord(self, img, x, y, held, ring, button, since):
+ """A chord: a ring for every button down (left inside, middle,
+ right outside), a mouse whose held buttons are lit, and its name."""
+ w, h = self.size
+ d = ImageDraw.Draw(img, 'RGBA')
+ radius = {1: 15, 2: 23, 3: 31}
+ for b in held:
+ r = radius[b]
+ d.ellipse((x - r, y - r, x + r, y + r), outline=BUTTONS[b][1] + (255,), width=4)
+ clicked = [b for b in held if b != 1]
+ if ring == 'pulse' and button and button not in held:
+ t = min(since / .45, 1)
+ r = radius[button] + 18 * t
+ if t < 1:
+ d.ellipse((x - r, y - r, x + r, y + r), outline=BUTTONS[button][1] + (int(255 * (1 - t)),), width=4)
+ if since < .7:
+ clicked = [button]
+ lit = set(held) | set(clicked)
+ names = {2: 'middle', 3: 'right'}
+ label = 'left held' + ''.join(f' + {names[b]}-click' for b in clicked)
+ width = self.font.getlength(label) + 14
+ # The mouse, below and right of the pointer (else left of it): three
+ # buttons over a body, the held ones lit, the name beside it.
+ mx, my, mw, mh = x + 40, y + 30, 52, 74
+ if my + mh + 8 > h:
+ my = y - 34 - mh
+ if mx + mw + 12 + width + 16 > w:
+ mx = x - 40 - mw
+ d.rounded_rectangle((mx, my, mx + mw, my + mh), radius=18, fill=(250, 250, 250, 245), outline=(30, 30, 30, 255),
+ width=3)
+ third = mw / 3
+ for b in (1, 2, 3):
+ box = (mx + third * (b - 1) + 3, my + 3, mx + third * b - 3, my + mh * .42)
+ if b in lit:
+ d.rectangle(box, fill=BUTTONS[b][1] + (255,))
+ d.line((mx + 3, my + mh * .42 + 2, mx + mw - 3, my + mh * .42 + 2), fill=(30, 30, 30, 255), width=2)
+ for b in (1, 2):
+ lx = mx + third * b
+ d.line((lx, my + 3, lx, my + mh * .42), fill=(30, 30, 30, 255), width=2)
+ colour = BUTTONS[clicked[-1]][1] if clicked else BUTTONS[1][1]
+ lx = mx + mw + 12 if mx > x else mx - 12 - width
+ draw_badge(img, label, (lx, my + mh / 2), self.font, fill=colour, anchor='lm')
+
+ def paint(self, img, pointer, badge, ring=None, button=None, since=0.0, held=()):
+ """`img` with the badge, the pointer, and a click: `ring` 'press'
+ (a ring and the button's name) or 'pulse' (`since` seconds after the
+ release: the ring widening and fading, the name kept a moment)."""
img = img.copy()
w, h = self.size
if badge:
draw_badge(img, badge, (w - 16, h - 16), self.font, anchor='rb')
if pointer is not None:
x, y = pointer
- if button:
+ if held:
+ self.chord(img, x, y, held, ring, button, since)
+ elif ring and button:
label, colour = BUTTONS[button]
d = ImageDraw.Draw(img, 'RGBA')
- r = 16
- d.ellipse((x - r, y - r, x + r, y + r), outline=colour + (255,), width=4)
- # The name sits below the pointer, kept inside the frame.
- lx = min(x + 22, w - 16 - self.font.getlength(label))
- draw_badge(img, label, (lx, y + 40), self.font, fill=colour)
+ if ring == 'press':
+ r, alpha, named = 16, 255, True
+ else:
+ t = min(since / .45, 1)
+ r, alpha, named = 16 + 22 * t, int(255 * (1 - t)), since < .6
+ if alpha > 0:
+ d.ellipse((x - r, y - r, x + r, y + r), outline=colour + (alpha,), width=4)
+ if named:
+ # The name sits below the pointer, kept inside the frame.
+ lx = min(x + 22, w - 16 - self.font.getlength(label))
+ ly = y + 40 if y + 70 < h else y - 60
+ draw_badge(img, label, (lx, ly), self.font, fill=colour)
img.paste(self.sprite, (int(x) - 1, int(y) - 1), self.sprite)
return img
@@ -409,22 +520,26 @@ def compose(shots, out):
n = round(clock * FPS) - emitted
for i in range(n):
t = ease((i + 1) / n)
- frame = painter.paint(img, lerp(shot['start'], shot['end'], t), shot['button'], shot['badge'])
+ frame = painter.paint(img, lerp(shot['start'], shot['end'], t), shot['badge'], shot['ring'],
+ shot['ring_button'], i / FPS, shot.get('held', ()))
ff.stdin.write(frame.tobytes())
if shot['poster'] and poster is None:
- poster = painter.paint(img, shot['end'], shot['button'], None)
+ # A chord's still keeps its rings and mouse; a click's has none.
+ chord = shot.get('held') or ()
+ poster = painter.paint(img, shot['end'], None, shot['ring'] if chord else None, shot['ring_button'], 0.0,
+ chord)
emitted += n
last = shots[-1]
n = round(.7 * FPS)
for i in range(n):
t = ease((i + 1) / n)
frame = Image.blend(loaded[1], first, t)
- ff.stdin.write(painter.paint(frame, lerp(last['end'], shots[0]['start'], t), None, None).tobytes())
+ ff.stdin.write(painter.paint(frame, lerp(last['end'], shots[0]['start'], t), None).tobytes())
emitted += n
ff.stdin.close()
if ff.wait() != 0:
raise RuntimeError('ffmpeg (lossless) failed')
- return emitted / FPS, poster or painter.paint(first, None, None, None)
+ return emitted / FPS, poster or painter.paint(first, None, None)
def encode(lossless, name, poster, crf_webm=30, crf_mp4=20):
@@ -447,20 +562,34 @@ def encode(lossless, name, poster, crf_webm=30, crf_mp4=20):
# the clips
+def hero_setup(s):
+ """README and main.c under it."""
+ s.write('/pane/1/look', f'{PROJECT}/src/main.c\n')
+ s.wait(lambda: len(s.panes()) == 2, 'main.c')
+ s.mouse('motion', 0, *s.cell_px(36, 9))
+
+
+def pane_rows(s, name, rows=6):
+ """The first `rows` screen rows under the tag that starts with `name`."""
+ screen = s.screen()
+ top = next(i for i, line in enumerate(screen) if line[3:].startswith(name))
+ return screen[top + 1:top + 1 + rows]
+
+
def text_is_the_interface(s):
- """B2 on `make` in a tag runs it; B3 on the error opens the file there."""
- s.write('/pane/1/tag', ' make')
+ """B2 on `make` in the README's text runs it, opening its output pane;
+ B3 on the error there puts main.c on line 12."""
s.pointer = s.cell_px(36, 9)
- s.shot(1.0)
- col, row = s.find('make', 0)
- s.sweep(2, s.tag_px(col, row, offset=.15), s.tag_px(col + 3, row, offset=.85))
- s.wait(lambda: any('exit' in s.tag(n) for n, kind in s.panes().items() if kind == 'cmd'), 'make to finish')
- s.shot(1.1)
+ s.shot(1.4)
+ col, row = s.find('with make,', 0)
+ s.click(2, *s.cell_px(col + 6, row),
+ done=lambda: any(kind == 'cmd' and '(make) exit' in s.tag(n) for n, kind in s.panes().items()),
+ what='make', after=1.2)
col, row = s.find('src/main.c:12:5', 0)
- s.sweep(3, s.cell_px(col, row), s.cell_px(col + 14, row))
- s.wait(lambda: len(s.panes()) == 3, 'main.c to open')
- s.glide(*s.cell_px(40, 9), seconds=.5)
- s.shot(2.6, poster=True)
+ s.click(3, *s.cell_px(col + 4, row),
+ done=lambda: any(line.startswith(' 12 ') for line in pane_rows(s, f'{PROJECT}/src/main.c')),
+ what='main.c at line 12', after=2.8)
+ s.shots[-1]['poster'] = True
def shell_setup(s):
@@ -543,32 +672,523 @@ def diff_review(s):
s.wait_text('+ printf')
s.shot(1.4)
col, row = s.find('+ printf', 0)
- s.sweep(3, s.cell_px(col, row), s.cell_px(col, row))
- s.wait(lambda: len(s.panes()) == 3, 'main.c to open')
- s.glide(*s.cell_px(42, 9), seconds=.5)
- s.shot(2.6, poster=True)
+ s.click(3, *s.cell_px(col, row), done=lambda: len(s.panes()) == 3, what='main.c to open', after=2.6)
+ s.shots[-1]['poster'] = True
+
+
+def mandelbrot(path, center=(-0.75, 0.0), span=3.2, limit=200, width=1200, height=800, bands=40):
+ """The Mandelbrot set around `center`, `span` wide, smoothly coloured:
+ a deep blue through white to gold, the inside black. `bands` is the
+ escape count one sweep of the palette takes (a deep zoom cycles it)."""
+ import numpy as np
+ x = np.linspace(center[0] - span / 2, center[0] + span / 2, width)
+ half = span * height / width / 2
+ y = np.linspace(center[1] + half, center[1] - half, height)
+ c = x[None, :] + 1j * y[:, None]
+ z = np.zeros_like(c)
+ count = np.zeros(c.shape)
+ alive = np.ones(c.shape, bool)
+ for n in range(limit):
+ z[alive] = z[alive] ** 2 + c[alive]
+ escaped = alive & (np.abs(z) > 4)
+ # The smooth (normalised) iteration count, so the bands blend.
+ count[escaped] = n + 1 - np.log2(np.log2(np.abs(z[escaped])))
+ alive &= ~escaped
+ if limit > 2 * bands:
+ t = (count / bands) % 1 # a zoom: the palette repeats down the spirals
+ t = np.where(t < .5, t * 2, (1 - t) * 2)
+ else:
+ t = np.sqrt(np.clip(count / bands, 0, 1))
+ stops = np.array([[0, 7, 100], [32, 107, 203], [237, 255, 255], [255, 170, 0], [80, 2, 0]], float)
+ pos = np.linspace(0, 1, len(stops))
+ rgb = np.stack([np.interp(t, pos, stops[:, k]) for k in range(3)], axis=-1)
+ rgb[alive] = 0
+ Image.fromarray(rgb.astype(np.uint8)).save(path)
+
+
+def image_setup(s):
+ """A lone terminal at the project, which holds two pictures: the
+ Mandelbrot set and a zoom into its seahorse valley."""
+ mandelbrot(PROJECT / 'mandelbrot.png')
+ mandelbrot(PROJECT / 'seahorse.png', center=(-0.7453, 0.1127), span=0.012, limit=600, bands=60)
+ shell_setup(s)
+ s.write('/pane/1/exec', 'Del\n')
+ s.wait(lambda: list(s.panes()) == [2], 'the README to close')
+
+
+def image_pane(s):
+ """ls in a shell; B3 on each PNG's name opens it in a pane."""
+ s.pointer = s.cell_px(40, 8)
+ s.shot(1.0)
+ s.type('ls', lambda typed: at_prompt(s, typed)())
+ s.keys('\r')
+ s.wait(lambda: any('seahorse.png' in line for line in s.terminal_rows()), 'ls')
+ s.shot(.9)
+ col, row = s.find('mandelbrot.png', 0)
+ s.click(3, *s.cell_px(col + 4, row), done=lambda: len(s.panes()) == 2, what='the set', after=2.4)
+ s.shots[-1]['poster'] = True
+ # Del gives its rows back, so the zoom opens as large.
+ image = next(n for n, kind in s.panes().items() if kind == 'image')
+ s.click(2, *s.tag_word(image, 'Del'), done=lambda: len(s.panes()) == 1, what='Del', after=.6)
+ col, row = s.find('seahorse.png', 0)
+ s.click(3, *s.cell_px(col + 4, row), done=lambda: len(s.panes()) == 2, what='the zoom', after=3.0)
+
+
+# The Intel 64 and IA-32 SDM, vol. 2 (2,515 pages), opened where it is.
+PDF = [Path('/home/goblin/01-resources/intel-devref-ii.pdf')]
+
+
+def pdf_args():
+ return [PDF[0]]
+
+
+def pdf_setup(s):
+ s.mouse('motion', 0, *s.cell_px(COLS - 2, ROWS - 1))
+
+
+def pdf_tag(s, word, pane=None):
+ """The pixel over `word` in the PDF pane's tag."""
+ pane = pane or next(n for n, kind in s.panes().items() if kind == 'pdf')
+ return s.tag_word(pane, word)
+
+
+def page_of(s, pane):
+ tag = s.tag(pane)
+ return int(tag[tag.index('[') + 1:tag.index('/', tag.index('['))])
+
+
+def pdf_navigation(s):
+ """Page through the Intel SDM, jump to CPUID from its sections, search
+ a word and step through the hits."""
+ pdf = next(n for n, kind in s.panes().items() if kind == 'pdf')
+ s.pointer = s.cell_px(44, 12)
+ s.shot(1.2)
+ s.press([('\x06', 'Ctrl-f'), ('\x06', 'Ctrl-f Ctrl-f')], per_key=.7)
+ s.click(2, *pdf_tag(s, 'PdfSections'), done=lambda: len(s.panes()) == 2, what='the sections', after=.8)
+ listing = next(n for n in s.panes() if n != pdf)
+ top = s.find('+PdfSections', 0)[1]
+ s.write('/focus', f'{listing}\n')
+ s.badge = '200G'
+ for key in '200G':
+ s.keys(key)
+ s.wait(lambda: any('CPUID' in line for line in s.screen()[top:]), 'the CPUID row')
+ s.shot(1.0)
+ s.badge = None
+ # A row's file.pdf:PAGE:SECTION is the place to look at.
+ col, row = s.find('pdf:317:200', after=top + 1)
+ s.click(3, *s.cell_px(col + 4, row), done=lambda: page_of(s, pdf) == 317, what='page 317', after=1.2)
+ s.click(2, *s.tag_word(listing, 'Del'), done=lambda: list(s.panes()) == [pdf], what='the listing to close',
+ after=.8)
+ s.write('/focus', f'{pdf}\n')
+ s.badge = '/'
+ s.keys('/')
+ s.shot(.25)
+ # Its first hits are on the pages after CPUID's first.
+ s.type('apic id', lambda typed: True, per_key=.08)
+ s.badge = '/apic id Enter'
+ s.keys('\r')
+ s.wait(lambda: any(kind == 'text' for kind in s.panes().values()), 'the hits')
+ s.shot(1.0)
+ s.badge = None
+ hits = next(n for n, kind in s.panes().items() if kind == 'text')
+ # The hit list folds to its tag, and the page has the column again.
+ s.click(2, *s.tag_word(hits, 'Collapse'), done=lambda: 'collapsed' in s.read(f'/pane/{hits}/ctl'),
+ what='the hits to fold', after=.8)
+ s.write('/focus', f'{pdf}\n')
+ for k in range(1, 4):
+ s.badge = ' '.join(['n'] * k)
+ s.keys('n')
+ s.shot(.8)
+ s.badge = None
+ s.shots[-1]['secs'] = 1.6
+ s.shots[-1]['poster'] = True
+
+
+def pdf_tint_setup(s):
+ """CPUID's page, and two words of the user's in the tag: PdfFit and a
+ page address (MOV's)."""
+ s.write('/pane/1/look', f'{PDF[0]}:317\n')
+ s.wait(lambda: page_of(s, 1) == 317, 'page 317')
+ s.write('/pane/1/tag', f' PdfFit {PDF[0].name}:762')
+ pdf_setup(s)
+
+
+def pdf_tint_fit(s):
+ """PdfTint through its three looks, PdfFit both ways, then a look at
+ a page address."""
+ s.pointer = s.cell_px(44, 12)
+ s.shot(1.2)
+ for _ in range(3):
+ before = s.read('/pane/1/ctl').split()[-1]
+ s.click(2, *pdf_tag(s, 'PdfTint'), done=lambda: s.read('/pane/1/ctl').split()[-1] != before,
+ what='the tint', after=1.0)
+ for fit in ('height', 'width'):
+ s.click(2, *pdf_tag(s, 'PdfFit'), done=lambda: f'fit:{fit}' in s.read('/pane/1/ctl'), what='the fit',
+ after=1.3)
+ s.click(3, *pdf_tag(s, f'{PDF[0].name}:762'), done=lambda: page_of(s, 1) == 762, what='page 762', after=2.0)
+ s.shots[-1]['poster'] = True
+
+
+THEMES = MEDIA / 'themes'
+THEME_WIDTH = 640
+THEME_MAX_BYTES = 120_000
+
+
+def theme_scene(s):
+ """One column: a C file with its syntax colours and a selection, a
+ shell that ran make (gcc's colours), and git diff paged into +Pager."""
+ env = dict(clean_env(), HOME=str(HOME), GIT_AUTHOR_NAME='demo', GIT_AUTHOR_EMAIL='[email protected]',
+ GIT_COMMITTER_NAME='demo', GIT_COMMITTER_EMAIL='[email protected]')
+ for args in (['init', '-q'], ['add', '.'], ['commit', '-qm', 'hello'], ['config', 'diff.context', '1']):
+ subprocess.run(['git', '-C', str(PROJECT), *args], check=True, env=env, capture_output=True)
+ main = PROJECT / 'src/main.c'
+ main.write_text(main.read_text().replace('i <= 4', 'i <= 5'))
+ s.write('/pane/1/exec', 'Tty\n')
+ s.wait(lambda: 2 in s.panes(), 'the terminal')
+ s.write('/pane/2/pty/data', f"cd {PROJECT}; PS1='$ '; clear; make; git diff\r")
+ s.wait(lambda: len(s.panes()) == 3, 'the pager')
+ s.wait(lambda: 'Error 1' in s.read('/pane/2/body'), 'make')
+ s.write('/pane/1/dot', '12\n')
+ # The pager shows its hunk, the end of the diff.
+ pager = next(n for n, kind in s.panes().items() if n not in (1, 2))
+ s.write(f'/pane/{pager}/dot', '$\n')
+
+
+def theme_order(s):
+ """The themes as the Themes builtin lists them, up to the imported
+ ones: pardes's own, the ports and the legacy pair (src/themes)."""
+ s.write('/ctl', 'Themes\n')
+ s.wait(lambda: any(s.read(f'/pane/{n}/name').strip().endswith('+Themes') for n in s.panes()), '+Themes')
+ pane = next(n for n in s.panes() if s.read(f'/pane/{n}/name').strip().endswith('+Themes'))
+ names = []
+ for line in s.read(f'/pane/{pane}/body').splitlines():
+ if line.startswith('# Imported'):
+ break
+ if line.startswith('Theme '):
+ names.append(line.split()[1])
+ s.write(f'/pane/{pane}/ctl', 'Del\n')
+ s.wait(lambda: pane not in s.panes(), '+Themes to close')
+ return names
+
+
+def save_theme_png(data, path):
+ """The capture at THEME_WIDTH, as small a PNG as reads: full colour if it
+ fits THEME_MAX_BYTES, else 256 colours. Answers light or dark, from the
+ colour most of the window is."""
+ import io
+ img = Image.open(io.BytesIO(data)).convert('RGB')
+ ground = max(img.getcolors(1 << 24), key=lambda c: c[0])[1]
+ lum = sum(w * (v / 255) ** 2.2 for w, v in zip((.2126, .7152, .0722), ground))
+ small = img.resize((THEME_WIDTH, round(img.height * THEME_WIDTH / img.width)), Image.LANCZOS)
+ small.save(path, optimize=True)
+ if path.stat().st_size > THEME_MAX_BYTES:
+ small.quantize(256, method=Image.Quantize.MEDIANCUT, dither=Image.Dither.NONE).save(path, optimize=True)
+ return 'light' if lum > .18 else 'dark'
+
+
+def record_themes(binary, work):
+ """docs/site/media/themes/<theme>.png for every theme of src/themes, in
+ the Themes builtin's order, and themes.toml listing them."""
+ make_project()
+ s = Session(binary, work / 'themes', 'rec-themes', [PROJECT / 'src/main.c'], rows=22)
+ try:
+ theme_scene(s)
+ names = theme_order(s)
+ THEMES.mkdir(parents=True, exist_ok=True)
+ rows = []
+ for name in names:
+ s.write('/ctl', f'Theme {name}\n')
+ # A pointer move puts away the word's flash that a command
+ # leaves up until the pointer moves.
+ s.mouse('motion', 0, *s.cell_px(COLS - 2, 1))
+ s.mouse('motion', 0, *s.cell_px(COLS - 3, 1))
+ kind = save_theme_png(s.settle(), THEMES / f'{name}.png')
+ rows.append((name, kind))
+ print(f'{name}.png {kind} {(THEMES / f"{name}.png").stat().st_size / 1e3:.0f} KB', flush=True)
+ finally:
+ s.close()
+ toml = ['# The Themes page gallery, in the order the Themes builtin walks them:',
+ '# media/themes/<file> for each, made by test/record_demos.py themes.',
+ 'ready = true', '']
+ for name, kind in rows:
+ toml += ['[[theme]]', f'name = "{name}"', f'file = "{name}.png"', f'kind = "{kind}"', '']
+ (THEMES / 'themes.toml').write_text('\n'.join(toml))
+
+
+NOTES = '''To buy: apples plums pears
+
+
+
+In the bowl: bananas
+'''
+
+
+def chords_setup(s):
+ s.mouse('motion', 0, *s.cell_px(COLS - 2, ROWS - 1))
+
+
+def body_text(s):
+ return s.read('/pane/1/body')
+
+
+def chords(s):
+ """acme's chords: B1 sweeps and stays down; B2 cuts, B3 pastes, B2 then
+ B3 copies."""
+ s.pointer = s.cell_px(40, 12)
+ s.shot(.8)
+ # Cut: sweep " plums", B2 with B1 still down.
+ col, row = s.find(' plums', 0)
+ s.chord_press(*s.cell_px(col, row), rest=.25, hold=.3)
+ s.glide(*s.cell_px(col + 7, row), seconds=.45)
+ s.shot(.3)
+ s.chord_click(2, lambda: 'plums' not in body_text(s), 'the cut', after=.75)
+ s.shots[-2]['poster'] = True
+ s.chord_release(after=.25)
+ # Paste after "bananas": B1 down there, B3.
+ col, row = s.find('bananas', 0)
+ s.chord_press(*s.cell_px(col + 7, row), rest=.25, hold=.3)
+ s.chord_click(3, lambda: 'bananas plums' in body_text(s), 'the paste', after=.8)
+ s.chord_release(after=.25)
+ # Copy: sweep "apples", B2 then B3 with B1 down: it is back, and kept.
+ col, row = s.find('apples', 0)
+ s.chord_press(*s.cell_px(col, row), rest=.25, hold=.3)
+ s.glide(*s.cell_px(col + 6, row), seconds=.45)
+ s.shot(.3)
+ s.chord_click(2, lambda: 'apples' not in body_text(s), 'the copy\'s cut', after=.45)
+ s.chord_click(3, lambda: 'apples' in body_text(s), 'the copy\'s paste', after=.8)
+ s.chord_release(after=1.0)
+
+
+def column(s, serial):
+ """Column `serial`'s (first cell, width) from /layout."""
+ for line in s.read('/layout').splitlines():
+ parts = line.split()
+ if parts and parts[0] == str(serial):
+ return int(parts[2]), int(parts[3])
+ raise LookupError(f'no column {serial}')
+
+
+def col_tag_px(s, serial, at, offset=.5):
+ """The pixel over character `at` of column `serial`'s tag (row 1 of the
+ window, wrapping at the column's width in the tagline font)."""
+ x0, width = column(s, serial)
+ w, h = s.size()
+ cw, ch = w / s.cols, h / s.rows
+ per_row = int((width * cw - 3 * cw) // TAG_W)
+ return int((x0 + 3) * cw + (at % per_row + offset) * TAG_W), int((1 + at // per_row + .55) * ch)
+
+
+def columns_setup(s):
+ s.write('/pane/1/look', f'{PROJECT}/src/main.c\n')
+ s.wait(lambda: len(s.panes()) == 2, 'main.c')
+ s.mouse('motion', 0, *s.cell_px(s.cols - 2, s.rows - 1))
+
+
+def columns(s):
+ """Newcol makes a column; a name typed in its tag and looked at opens
+ there; Alt-c gives a pane a column of its own."""
+ s.pointer = s.cell_px(30, 12)
+ s.shot(1.0)
+ s.click(2, *s.tag_px(3, 0, start=0), done=lambda: len(s.read('/layout').splitlines()) == 3, what='Newcol',
+ after=.8)
+ new = s.read('/layout').splitlines()[1].split()[0]
+ tag = s.read(f'/col/{new}/tag').rstrip('\n')
+ # B1 just past the tag's last word puts the typing there.
+ s.click(1, *col_tag_px(s, new, len(tag), offset=.4), after=.2)
+ s.type(' Makefile', lambda typed: s.read(f'/col/{new}/tag').rstrip('\n').endswith(typed), per_key=.06)
+ s.shot(.3)
+ at = s.read(f'/col/{new}/tag').index('Makefile') + 4
+ s.click(3, *col_tag_px(s, new, at), done=lambda: len(s.panes()) == 3, what='the Makefile', after=1.3)
+ # Alt-c on main.c: it leaves for a column of its own.
+ main = next(n for n in s.panes() if s.read(f'/pane/{n}/name').strip().endswith('main.c'))
+ col, row = s.find('int main', 0)
+ s.click(1, *s.cell_px(col + 4, row), done=lambda: s.read('/focus').strip() == str(main), what='focus',
+ after=.3)
+ s.badge = 'Alt-c'
+ s.keys('\x1bc')
+ s.wait(lambda: len(s.read('/layout').splitlines()) == 4, 'the new column')
+ s.shot(2.2, poster=True)
+ s.badge = None
+
+
+def search_setup(s):
+ (PROJECT / 'tests').mkdir()
+ (PROJECT / 'tests/check.sh').write_text('#!/bin/sh\n# 1 + 4 + 9 + 16: the sum of the squares is 30\n'
+ './hello | grep -q "sum: 30"\n')
+ s.write('/pane/1/look', f'{PROJECT}/src/main.c\n')
+ s.wait(lambda: len(s.panes()) == 2, 'main.c')
+ s.write('/focus', '2\n')
+ s.mouse('motion', 0, *s.cell_px(COLS - 2, ROWS - 1))
+
+
+def search(s):
+ """Grep (SPC f g) lists the hits in +Search; n and N walk them; B3 on
+ one opens its file there."""
+ s.pointer = s.cell_px(44, 8)
+ s.shot(1.0)
+ s.press([(' ', 'SPC'), ('f', 'SPC f'), ('g', 'SPC f g')], per_key=.35)
+ s.badge = 'SPC f g square'
+ s.type('square', lambda typed: True, per_key=.07)
+ s.badge = 'Enter'
+ s.keys('\r')
+ s.wait(lambda: any(s.read(f'/pane/{n}/name').strip().endswith('+Search') for n in s.panes()), '+Search')
+ s.shot(1.0)
+ s.press([('n', 'n'), ('n', 'n n'), ('N', 'n n N')], per_key=.7)
+ s.shots[-2]['poster'] = True # the listing whole, the second hit current
+ s.badge = None
+ col, row = s.find('check.sh:', 0)
+ s.click(3, *s.cell_px(col + 3, row), done=lambda: len(s.panes()) == 4, what='check.sh', after=2.2)
+
+
+def where_setup(s):
+ """main.c, with pwd in the column's tag and in main.c's."""
+ s.write('/col/1/tag', ' pwd')
+ s.write('/pane/1/tag', ' pwd')
+ s.mouse('motion', 0, *s.cell_px(COLS - 2, ROWS - 1))
+
+
+def where_commands_run(s):
+ """pwd from the column's tag prints the session's directory; from
+ main.c's tag, main.c's."""
+ s.pointer = s.cell_px(40, 12)
+ s.shot(1.2)
+ tag = s.read('/col/1/tag')
+ s.click(2, *col_tag_px(s, 1, tag.index('pwd') + 1),
+ done=lambda: any(kind == 'cmd' and 'exit' in s.tag(n) for n, kind in s.panes().items()), what='pwd',
+ after=1.6)
+ s.click(2, *s.tag_word(1, 'pwd'),
+ done=lambda: any(kind == 'cmd' and s.read(f'/pane/{n}/body').startswith('% pwd\n/tmp/hello/src')
+ and 'exit' in s.tag(n) for n, kind in s.panes().items()),
+ what='pwd in src', after=2.6)
+ s.shots[-1]['poster'] = True
+
+
+GIT_ID = dict(GIT_AUTHOR_NAME='demo', GIT_AUTHOR_EMAIL='[email protected]',
+ GIT_COMMITTER_NAME='demo', GIT_COMMITTER_EMAIL='[email protected]')
+
+
+def edit_setup(s):
+ """main.c over a shell that sees the session as files ($m, as in the
+ scripting chapter: 9ns mounts the pane's $PARDES_9P), and Undo in
+ main.c's tag."""
+ shell_setup(s)
+ mount = HOME / 'n'
+ mount.mkdir()
+ s.write('/pane/2/pty/data', f'exec 9ns --unix "$PARDES_9P" --mount {mount} -- /bin/sh\r')
+ time.sleep(1.5) # 9ns mounts and starts the shell that reads the next line
+ s.write('/pane/2/pty/data', f"m=$NINE_MOUNT; cd {PROJECT}; PS1='$ '; clear\r")
+ s.wait(lambda: [line.rstrip() for line in s.terminal_rows() if line.strip()] == [' $'], 'the mounted shell')
+ s.write('/pane/1/tag', ' Undo')
+ s.write('/pane/1/dot', '$\n')
+ s.mouse('motion', 0, *s.cell_px(COLS - 2, 1))
+
+
+def edit_via_ctl(s):
+ """An Edit written to the pane's ctl changes every sum; one Undo puts
+ them all back."""
+ s.pointer = s.cell_px(44, 7)
+ s.shot(1.0)
+ command = "echo 'Edit ,x/sum/c/total/' > $m/pane/1/ctl"
+ s.type(command, lambda typed: at_prompt(s, typed)(), per_key=.055)
+ s.keys('\r')
+ s.wait(lambda: 'sum' not in s.read('/pane/1/body'), 'the edit')
+ s.shot(1.6, poster=True)
+ s.click(2, *s.tag_word(1, 'Undo'), done=lambda: s.read('/pane/1/body').count('sum') == 4, what='Undo',
+ after=2.0)
+
+
+def wait_setup(s):
+ """A git repo with a change staged, and a lone shell whose EDITOR is
+ pardes --wait (a `pardes` on its PATH: this pardes-gui)."""
+ env = dict(clean_env(), HOME=str(HOME), **GIT_ID)
+ for args in (['init', '-q'], ['add', '.'], ['commit', '-qm', 'hello']):
+ subprocess.run(['git', '-C', str(PROJECT), *args], check=True, env=env, capture_output=True)
+ main = PROJECT / 'src/main.c'
+ main.write_text(main.read_text().replace('pritnf', 'printf'))
+ (HOME / 'bin').mkdir()
+ (HOME / 'bin/pardes').symlink_to(s.binary)
+ shell_setup(s)
+ ids = ' '.join(f'{k}={v}' for k, v in GIT_ID.items())
+ s.write('/pane/2/pty/data', f"export PATH=$HOME/bin:$PATH EDITOR='pardes --wait' {ids}; git add -A; clear\r")
+ s.write('/pane/1/exec', 'Del\n')
+ s.wait(lambda: list(s.panes()) == [2], 'the README to close')
+ s.wait(lambda: [line.rstrip() for line in s.terminal_rows() if line.strip()] == [' $'], 'the prompt')
+
+
+def wait_editor(s):
+ """git commit opens its message in a pane of this session; Save and
+ Del, and git commit returns."""
+ s.pointer = s.cell_px(44, 9)
+ s.shot(1.0)
+ s.type('git commit', lambda typed: at_prompt(s, typed)(), per_key=.07)
+ s.keys('\r')
+ s.wait(lambda: len(s.panes()) == 2, 'the message pane')
+ msg = next(n for n in s.panes() if n != 2)
+ s.shot(1.0)
+ s.press([('i', 'i')], per_key=.3)
+ s.badge = 'i'
+ s.type('Fix the printf typo', lambda typed: typed in s.read(f'/pane/{msg}/body'), per_key=.05)
+ # Esc as a CSI u sequence: a bare ESC byte waits in the stdin feed's
+ # parser for what follows, and the next mouse event would join it.
+ s.press([('\x1b[27u', 'Esc')], per_key=.4)
+ s.shots[-1]['poster'] = True # git waiting, its message typed in the pane
+ s.click(2, *s.tag_word(msg, 'Save'), done=lambda: s.read(f'/pane/{msg}/dirty').strip() == '0', what='Save',
+ after=.6)
+ s.click(2, *s.tag_word(msg, 'Del'), done=lambda: list(s.panes()) == [2], what='Del', after=.3)
+ s.wait(lambda: any('Fix the printf typo' in line for line in s.terminal_rows()), 'git commit')
+ s.wait(lambda: [line.rstrip() for line in s.terminal_rows() if line.strip()][-1] == ' $', 'the prompt')
+ s.shot(2.4)
CLIPS = {
+ 'pdf-navigation': dict(
+ setup=pdf_setup, run=pdf_navigation, args=pdf_args,
+ caption='A 2,515-page PDF: page, jump to CPUID, search, step hits.'),
+ 'image-pane': dict(
+ setup=image_setup, run=image_pane, args=[PROJECT / 'README'],
+ caption="Right-click an image's name, even in a shell, to open it."),
+ 'pdf-tint-fit': dict(
+ setup=pdf_tint_setup, run=pdf_tint_fit, args=pdf_args,
+ caption='PdfTint tints the page, PdfFit fits it, a page address jumps.'),
'text-is-the-interface': dict(
- run=text_is_the_interface, args=[PROJECT / 'README'],
- caption='Middle-click runs a word; right-click an error opens the file.'),
+ setup=hero_setup, run=text_is_the_interface, args=[PROJECT / 'README'],
+ caption='Middle-click make in the README; right-click the error.'),
'terminals-are-panes': dict(
setup=shell_setup, run=terminals_are_panes, args=[PROJECT / 'src/main.c'],
- caption='A shell is a pane: Ctrl-b, then move over its output.'),
+ caption='A terminal is a pane: Ctrl-b, then move over its output.'),
'scripting-over-9p': dict(
setup=mounted_shell_setup, run=scripting_over_9p, args=[PROJECT / 'README'],
- caption='The session is files: a shell script reads and writes its panes.'),
+ caption='A script drives the session through its virtual filesystem (9P).'),
'diff-review': dict(
setup=diff_setup, run=diff_review, args=[PROJECT / 'README'],
- caption='git diff pages into a pane; right-click a change to open it.'),
+ caption='Right-click a diff line to open that line of the file.'),
+ 'chords': dict(
+ setup=chords_setup, run=chords, args=[PROJECT / 'notes.txt'],
+ caption='Hold left, click middle to cut; click right to paste.'),
+ 'columns': dict(
+ setup=columns_setup, run=columns, args=[PROJECT / 'README'], cols=80, rows=26,
+ caption='Newcol adds a column; a look opens there; Alt-c moves a pane.'),
+ 'search': dict(
+ setup=search_setup, run=search, args=[PROJECT / 'README'],
+ caption='Grep lists the hits; n and N walk them; right-click opens one.'),
+ 'where-commands-run': dict(
+ setup=where_setup, run=where_commands_run, args=[PROJECT / 'src/main.c'],
+ caption="A file's tag runs in its directory; the column tag, in the session's."),
+ 'edit-via-ctl': dict(
+ setup=edit_setup, run=edit_via_ctl, args=[PROJECT / 'src/main.c'],
+ caption='An Edit written to ctl changes every sum; one Undo restores.'),
+ 'wait-editor': dict(
+ setup=wait_setup, run=wait_editor, args=[PROJECT / 'README'],
+ caption="git commit's message opens in a pane; Del returns it to git."),
}
def record(binary, work, name):
clip = CLIPS[name]
make_project()
- s = Session(binary, work / name, 'rec-' + name, clip['args'], init=clip.get('init', ''))
+ args = clip['args']() if callable(clip['args']) else clip['args']
+ s = Session(binary, work / name, 'rec-' + name, args, init=clip.get('init', ''),
+ cols=clip.get('cols', COLS), rows=clip.get('rows', ROWS))
try:
if 'setup' in clip:
clip['setup'](s)
@@ -586,19 +1206,26 @@ def record(binary, work, name):
def main():
parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
parser.add_argument('binary', type=Path, help='a pardes-gui built with -Dplatform=gui')
- parser.add_argument('clips', nargs='*', help='which clips (default: all): ' + ', '.join(CLIPS))
+ parser.add_argument('clips', nargs='*',
+ help='which clips (default: all), or themes for the theme gallery: ' + ', '.join(CLIPS))
parser.add_argument('--work', type=Path, default=REPO.parent / '.scratch/rec/work',
help='frames and intermediates (large; default %(default)s)')
parser.add_argument('--theme', default=THEME[0], help='a theme name, or default (default %(default)s)')
+ parser.add_argument('--pdf', type=Path, default=PDF[0],
+ help='the PDF the pdf clips page through, opened read-only (default %(default)s)')
args = parser.parse_args()
THEME[0] = args.theme
+ PDF[0] = args.pdf.resolve()
names = args.clips or list(CLIPS)
for name in names:
- if name not in CLIPS:
+ if name not in CLIPS and name != 'themes':
parser.error(f'no clip {name!r}: ' + ', '.join(CLIPS))
try:
for name in names:
- record(args.binary, args.work.resolve(), name)
+ if name == 'themes':
+ record_themes(args.binary, args.work.resolve())
+ else:
+ record(args.binary, args.work.resolve(), name)
finally:
shutil.rmtree(PROJECT, ignore_errors=True)
shutil.rmtree(HOME, ignore_errors=True)
@@ -606,7 +1233,8 @@ def main():
lines = dict(line.split(': ', 1) for line in captions.read_text().splitlines() if ': ' in line) \
if captions.exists() else {}
for name in names:
- lines[name] = CLIPS[name]['caption']
+ if name in CLIPS:
+ lines[name] = CLIPS[name]['caption']
captions.write_text(''.join(f'{name}: {lines[name]}\n' for name in CLIPS if name in lines))