diff options
Diffstat (limited to 'test/record_demos.py')
| -rw-r--r-- | test/record_demos.py | 746 |
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)) |
