summaryrefslogtreecommitdiff
path: root/test
diff options
context:
space:
mode:
Diffstat (limited to 'test')
-rw-r--r--test/fs.py169
-rw-r--r--test/panes.zig9
-rw-r--r--test/perf-baseline-gui-Debug.json2
-rw-r--r--test/perf-baseline-tty-Debug.json2
-rw-r--r--test/perf-baseline-tty-ReleaseFast.json2
-rw-r--r--test/perf.zig34
-rw-r--r--test/record_casts.py947
-rw-r--r--test/record_demos.py746
-rw-r--r--test/selfmount.py115
-rw-r--r--test/snapshots/builtins.golden62
-rw-r--r--test/snapshots/cmdword.snap3
-rw-r--r--test/snapshots/dirpane.golden13
-rw-r--r--test/snapshots/dirpane.snap27
-rw-r--r--test/snapshots/dump.golden1
-rw-r--r--test/snapshots/images.golden18
-rw-r--r--test/snapshots/layout-open.golden2
-rw-r--r--test/snapshots/leader.golden52
-rw-r--r--test/snapshots/look-dir.snap3
-rw-r--r--test/snapshots/lookpanes.snap3
-rw-r--r--test/snapshots/nested.golden8
-rw-r--r--test/snapshots/shrinktag.golden10
-rw-r--r--test/snapshots/tagalign.snap5
-rw-r--r--test/snapshots/tagbottomimage.golden6
-rw-r--r--test/snapshots/tagpath.snap3
-rw-r--r--test/snapshots/ttyedit.golden2
-rw-r--r--test/snapshots/ttylook.snap3
-rw-r--r--test/snapshots/tutor.golden18
27 files changed, 2109 insertions, 156 deletions
diff --git a/test/fs.py b/test/fs.py
index 204f4b37..97af9e6b 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -605,6 +605,56 @@ def unattended_messages_expire(binary):
assert not shown(), 'an unattended message outlived MessageLinger'
+def directory_panes(binary):
+ """A look at a directory opens a pane listing it, as acme's directory
+ window: a text pane named with a trailing /, its body the entries (a
+ directory's marked /), ctl's isdir 1, and get reads it again."""
+ with tempfile.TemporaryDirectory(prefix='pardes-dirpane-') as directory:
+ root = Path(directory).resolve()
+ listed = root / 'listed'
+ (listed / 'sub').mkdir(parents=True)
+ (listed / '.dot').write_bytes(b'')
+ with session(binary, root, 'dirpane') as (client, _):
+ serial = look(client, str(listed))
+ rows = [row.split() for row in client.read('/index').decode().splitlines()]
+ row = next(r for r in rows if int(r[0]) == serial)
+ assert row[1:4] == ['text', '0', f'{listed}/'], row
+ body = client.read(f'/pane/{serial}/body').decode()
+ assert body.split() == ['.dot', 'sub/'], body
+ assert client.read(f'/pane/{serial}/ctl').split()[3] == b'1'
+ (listed / 'later').write_bytes(b'')
+ client.write(f'/pane/{serial}/ctl', b'get\n')
+ assert client.read(f'/pane/{serial}/body').decode().split() == ['.dot', 'later', 'sub/']
+
+
+def putall_answers_after_its_saves(binary):
+ """Putall with one pane refused (its file changed on disk under unsaved
+ edits) fails, and is answered only once the other panes' saves have
+ landed."""
+ with tempfile.TemporaryDirectory(prefix='pardes-putall-') as directory:
+ root = Path(directory).resolve()
+ (root / 'a.txt').write_bytes(b'a\n')
+ (root / 'b.txt').write_bytes(b'b\n')
+ with session(binary, root, 'putall') as (client, _):
+ a = look(client, str(root / 'a.txt'))
+ b = look(client, str(root / 'b.txt'))
+ for serial in (a, b):
+ client.write(f'/pane/{serial}/body', b'edited\n')
+ (root / 'b.txt').write_bytes(b'changed on disk\n')
+ deadline = time.monotonic() + 10
+ while f'changed {b}' not in client.read('/log').decode():
+ assert time.monotonic() < deadline, 'the watch never saw b.txt change'
+ time.sleep(.05)
+ try:
+ client.write('/ctl', b'Putall\n')
+ except OSError as why:
+ assert 'modified on disk' in str(why), why
+ else:
+ raise AssertionError('Putall over a file changed on disk was taken')
+ assert (root / 'a.txt').read_bytes() == b'a\nedited\n', (root / 'a.txt').read_bytes()
+ assert (root / 'b.txt').read_bytes() == b'changed on disk\n'
+
+
def new_terminals_named_once(binary):
"""A terminal started outside the session's directory is named by that
directory from its `new` on: no rename to the session's and back."""
@@ -612,6 +662,9 @@ def new_terminals_named_once(binary):
root = Path(directory)
(root / 'sub').mkdir()
with session(binary, root, 'names') as (client, _):
+ # A look at a directory starts a terminal there (DirLook
+ # terminal); DirLook pane, the default, lists it in a pane.
+ client.write('/ctl', b'DirLook terminal\n')
made = [look(client, str(root / 'sub'))]
before = {int(row.split()[0]) for row in client.read('/index').splitlines()}
client.write('/pane/1/ctl', b'Tty\n')
@@ -983,15 +1036,128 @@ def cut_writes(binary):
assert (screen['cols'], screen['rows']) == (132, 40), (screen['cols'], screen['rows'])
+def edit_commands(binary):
+ """Edit's `< | >` run off the loop and answer the write that ran them
+ once applied; a failing one changes nothing and fails the write in its
+ words; X edits every open file it matches; w writes. The editor answers
+ other requests while a slow command runs."""
+ with tempfile.TemporaryDirectory(prefix='pardes-edit-') as directory:
+ root = Path(directory)
+ (root / 'a.txt').write_bytes(b'pear\napple\nfig\n')
+ (root / 'b.txt').write_bytes(b'apple pie\n')
+ with session(binary, root, 'edit') as (client, address):
+ a = look(client, str(root / 'a.txt'))
+ b = look(client, str(root / 'b.txt'))
+ # The write returns with the text sorted, through the session's
+ # shell in the file's directory.
+ client.write(f'/pane/{a}/ctl', b'Edit , | sort\n')
+ assert client.read(f'/pane/{a}/body') == b'apple\nfig\npear\n'
+ client.write(f'/pane/{a}/ctl', b'Edit $ < pwd\n')
+ assert client.read(f'/pane/{a}/body') == f'apple\nfig\npear\n{root}\n'.encode()
+ client.write(f'/pane/{a}/ctl', b'Undo\n')
+ assert client.read(f'/pane/{a}/body') == b'apple\nfig\npear\n'
+ # A per-match command, one undo step.
+ client.write(f'/pane/{a}/ctl', b'Edit ,x/[a-z]+/ | tr a-z A-Z\n')
+ assert client.read(f'/pane/{a}/body') == b'APPLE\nFIG\nPEAR\n'
+ # A failing command: nothing changed, the write fails in words,
+ # its stderr in +Errors.
+ try:
+ client.write(f'/pane/{a}/ctl', b'Edit , | echo nope >&2; exit 4\n')
+ raise AssertionError('a failing Edit command was taken')
+ except OSError as why:
+ assert 'exit status 4' in str(why), why
+ assert client.read(f'/pane/{a}/body') == b'APPLE\nFIG\nPEAR\n'
+ index = client.read('/index').decode()
+ errors = [line.split()[0] for line in index.splitlines() if line.endswith(('+Errors', f'{root}/+Errors')) or '+Errors' in line]
+ assert errors and any(b'nope' in client.read(f'/pane/{e}/body') for e in errors), index
+ # While a slow command runs, the editor answers.
+ done = threading.Event()
+
+ def slow():
+ with Client(address) as other:
+ other.write(f'/pane/{b}/ctl', b'Edit , | sleep 1; cat\n')
+ done.set()
+ worker = threading.Thread(target=slow)
+ worker.start()
+ time.sleep(.2)
+ began = time.monotonic()
+ client.read('/index')
+ assert time.monotonic() - began < .5 and not done.is_set(), 'the editor waited on the command'
+ worker.join(timeout=15)
+ assert done.is_set()
+ # On the same connection: the Edit's write is held, and a read sent
+ # after it is answered first; a Tflush of it stops it, changes
+ # nothing, and frees the way for the next Edit.
+ def send(kind, tag, payload):
+ client.socket.sendall(struct.pack('<IBH', 7 + len(payload), kind, tag) + payload)
+
+ def answer():
+ size, kind, tag = struct.unpack('<IBH', client.receive(7))
+ return kind, tag, client.receive(size - 7)
+ ctl = client.open(f'/pane/{a}/ctl', 1)
+ status = client.open('/status', 0)
+ line = b'Edit , | sleep 1; cat\n'
+ began = time.monotonic()
+ send(118, 900, struct.pack('<IQI', ctl, 0, len(line)) + line)
+ send(116, 901, struct.pack('<IQI', status, 0, 4096))
+ first = answer()
+ assert first[1] == 901 and first[0] == 117 and time.monotonic() - began < .5, (first[:2], time.monotonic() - began)
+ second = answer()
+ assert second[1] == 900 and second[0] == 119, second
+ line = b'Edit , | sleep 5; tr a-z A-Z\n'
+ send(118, 902, struct.pack('<IQI', ctl, 0, len(line)) + line)
+ time.sleep(.3)
+ send(108, 903, struct.pack('<H', 902))
+ tags = sorted(answer()[1] for _ in range(2))
+ assert tags == [902, 903], tags
+ client.close(ctl)
+ client.close(status)
+ time.sleep(1)
+ client.write(f'/pane/{a}/ctl', b'Edit , | cat\n')
+ time.sleep(4.5)
+ assert client.read(f'/pane/{a}/body') == b'APPLE\nFIG\nPEAR\n', client.read(f'/pane/{a}/body')
+ # X: each open file whose line matches; w writes it.
+ client.write(f'/pane/{a}/ctl', b'Edit X/b\\.txt$/ ,s/apple/APPLE/\n')
+ assert client.read(f'/pane/{b}/body') == b'APPLE pie\n'
+ client.write(f'/pane/{b}/ctl', b'Edit w\n')
+ assert (root / 'b.txt').read_bytes() == b'APPLE pie\n'
+ client.write(f'/pane/{a}/ctl', b'Edit 1 w first.txt\n')
+ assert (root / 'first.txt').read_bytes() == b'APPLE\n'
+ # A file changed on disk under unsaved edits: Edit's w refuses
+ # once, as Save does, and the next w overwrites it.
+ w1 = root / 'w1.txt'
+ w1.write_bytes(b'one\n')
+ w = look(client, str(w1))
+ client.write(f'/pane/{w}/ctl', b'Edit $a/two/\n')
+ seen = client.read('/log').decode().count(f'\nchanged {w}')
+ with open(w1, 'ab') as f:
+ f.write(b'external\n')
+ deadline = time.monotonic() + 10
+ while client.read('/log').decode().count(f'\nchanged {w}') == seen:
+ assert time.monotonic() < deadline, 'the change on disk was not heard'
+ time.sleep(.05)
+ try:
+ client.write(f'/pane/{w}/ctl', b'Edit w\n')
+ raise AssertionError('w overwrote a file changed on disk')
+ except OSError as why:
+ assert 'modified on disk since read (w again to overwrite)' in str(why), why
+ assert w1.read_bytes() == b'one\nexternal\n'
+ client.write(f'/pane/{w}/ctl', b'Edit w\n')
+ assert w1.read_bytes() == b'one\ntwo', w1.read_bytes()
+
+
def test(binary, quic=False):
started = time.monotonic()
cut_writes(binary)
+ edit_commands(binary)
odd_paths(binary)
delcol_last_keeps_window(binary)
run_file(binary)
exit_answers(binary)
unattended_messages_expire(binary)
new_terminals_named_once(binary)
+ directory_panes(binary)
+ putall_answers_after_its_saves(binary)
workflow_canary(binary)
recent_survives_restart(binary)
python_repl_runs_one_line(binary)
@@ -1429,6 +1595,9 @@ def test(binary, quic=False):
dead_env = dict(outside_env, PARDES_9P=str(root / 'pardes-9p-gone.sock'))
gone = subprocess.run([binary, '--wait', str(far)], cwd=root, env=dead_env, capture_output=True, timeout=10)
assert gone.returncode == 1 and b'does not answer' in gone.stderr, gone
+ # --wait with no FILE says it needs one, and waits on nothing.
+ bare = subprocess.run([binary, '--wait'], cwd=root, env=env, capture_output=True, timeout=10)
+ assert bare.returncode == 1 and b'--wait needs a FILE' in bare.stderr, bare
# `--` ends the options: a name starting with a dash is a name.
dash = root / '-dash.txt'
dash.write_bytes(b'dash\n')
diff --git a/test/panes.zig b/test/panes.zig
index 935defb5..41061bea 100644
--- a/test/panes.zig
+++ b/test/panes.zig
@@ -367,6 +367,8 @@ const TagNavigationTests = struct {
p.update(.{ .key = .{ .cp = 'x', .text = "x" } });
try std.testing.expect(pane.prompt == .name);
try std.testing.expectEqualStrings("x/test.txt", pane.input.own.?);
+ // Esc keeps the draft pending; a second drops it.
+ key(p, Key.escape);
key(p, Key.escape);
try std.testing.expect(pane.prompt == .none);
try std.testing.expectEqualStrings("/test.txt", pane.file.?.path);
@@ -1621,7 +1623,9 @@ test "pane filename drafts commit and cancel without changing body or command ta
try std.testing.expectEqualStrings("/new name.zig", pane.input.own.?);
try std.testing.expectEqualStrings(tail, pardes.tagline.curTail(pane));
try std.testing.expectEqualStrings("original body\n", pane.file.?.content);
+ // Esc keeps the draft pending; a second drops it.
p.update(.{ .key = .{ .cp = if (commit) Key.enter else Key.escape } });
+ if (!commit) p.update(.{ .key = .{ .cp = Key.escape } });
try std.testing.expect(pane.focus == .body);
try std.testing.expect(pane.prompt == .none);
try std.testing.expectEqualStrings(if (commit) "/new name.zig" else original, pane.file.?.path);
@@ -1649,6 +1653,9 @@ test "pane filename grapheme edits and invalid empty commits preserve the buffer
try std.testing.expect(pane.prompt == .name);
try std.testing.expectEqualStrings("/test.txt", pane.file.?.path);
try std.testing.expectEqualStrings("untouched\n", pane.file.?.content);
+ // Esc keeps the draft pending; a second drops it.
+ p.update(.{ .key = .{ .cp = Key.escape } });
+ try std.testing.expect(pane.prompt == .name);
p.update(.{ .key = .{ .cp = Key.escape } });
try std.testing.expect(pane.prompt == .none);
}
@@ -3142,7 +3149,7 @@ const ImageTests = struct {
toggleGlyphArt(&state);
togglePalette(&state);
toggleAscii(&state);
- const tag = try tagPrefix(std.testing.allocator, &state);
+ const tag = try tagPrefix(std.testing.allocator, &state, state.glyph_art);
defer std.testing.allocator.free(tag);
try std.testing.expectEqualStrings(
config.tag_image ++ " petscii:on palette:terminal ascii:off /tmp/picture.ppm",
diff --git a/test/perf-baseline-gui-Debug.json b/test/perf-baseline-gui-Debug.json
index ace9a967..39f5913c 100644
--- a/test/perf-baseline-gui-Debug.json
+++ b/test/perf-baseline-gui-Debug.json
@@ -1 +1 @@
-{"harness":"e63a8b27a0bcb261","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2100,"med_us":2437,"p90_us":2875,"max_us":3207},{"op":"render","fixture":"medium","min_us":82,"med_us":84,"p90_us":88,"max_us":89},{"op":"key-down","fixture":"medium","min_us":88,"med_us":95,"p90_us":102,"max_us":114},{"op":"key-right","fixture":"medium","min_us":84,"med_us":88,"p90_us":94,"max_us":105},{"op":"page-down","fixture":"medium","min_us":83,"med_us":86,"p90_us":88,"max_us":89},{"op":"wheel","fixture":"medium","min_us":82,"med_us":85,"p90_us":90,"max_us":99},{"op":"edit-char","fixture":"medium","min_us":1276,"med_us":1769,"p90_us":2239,"max_us":2754},{"op":"body-2m","fixture":"medium","min_us":5333,"med_us":9734,"p90_us":12257,"max_us":14666},{"op":"body-appends","fixture":"medium","min_us":135052,"med_us":262059,"p90_us":295829,"max_us":304576},{"op":"deep-sel","fixture":"medium","min_us":77,"med_us":79,"p90_us":83,"max_us":118}]}
+{"harness":"303736f83a625e87","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"gui","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1818,"med_us":2104,"p90_us":2287,"max_us":2293},{"op":"render","fixture":"medium","min_us":78,"med_us":82,"p90_us":87,"max_us":87},{"op":"key-down","fixture":"medium","min_us":79,"med_us":81,"p90_us":89,"max_us":109},{"op":"key-right","fixture":"medium","min_us":79,"med_us":84,"p90_us":85,"max_us":89},{"op":"page-down","fixture":"medium","min_us":79,"med_us":81,"p90_us":84,"max_us":87},{"op":"wheel","fixture":"medium","min_us":77,"med_us":78,"p90_us":82,"max_us":82},{"op":"edit-char","fixture":"medium","min_us":1019,"med_us":1349,"p90_us":1597,"max_us":1632},{"op":"edit-twin","fixture":"medium","min_us":2029,"med_us":2353,"p90_us":2455,"max_us":2676},{"op":"body-2m","fixture":"medium","min_us":5978,"med_us":9708,"p90_us":11361,"max_us":11666},{"op":"body-appends","fixture":"medium","min_us":117933,"med_us":211247,"p90_us":268014,"max_us":275006},{"op":"deep-sel","fixture":"medium","min_us":73,"med_us":75,"p90_us":80,"max_us":84}]}
diff --git a/test/perf-baseline-tty-Debug.json b/test/perf-baseline-tty-Debug.json
index 6b5c0bd5..9110398c 100644
--- a/test/perf-baseline-tty-Debug.json
+++ b/test/perf-baseline-tty-Debug.json
@@ -1 +1 @@
-{"harness":"e63a8b27a0bcb261","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1830,"med_us":2222,"p90_us":2264,"max_us":2287},{"op":"render","fixture":"medium","min_us":78,"med_us":83,"p90_us":86,"max_us":94},{"op":"key-down","fixture":"medium","min_us":80,"med_us":84,"p90_us":89,"max_us":98},{"op":"key-right","fixture":"medium","min_us":78,"med_us":81,"p90_us":88,"max_us":96},{"op":"page-down","fixture":"medium","min_us":78,"med_us":82,"p90_us":89,"max_us":95},{"op":"wheel","fixture":"medium","min_us":77,"med_us":85,"p90_us":96,"max_us":109},{"op":"edit-char","fixture":"medium","min_us":1125,"med_us":1307,"p90_us":1558,"max_us":1584},{"op":"body-2m","fixture":"medium","min_us":5189,"med_us":9224,"p90_us":10859,"max_us":14741},{"op":"body-appends","fixture":"medium","min_us":108477,"med_us":198709,"p90_us":227756,"max_us":252672},{"op":"deep-sel","fixture":"medium","min_us":73,"med_us":77,"p90_us":80,"max_us":91}]}
+{"harness":"303736f83a625e87","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"Debug","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseSafe","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1881,"med_us":2148,"p90_us":2324,"max_us":2644},{"op":"render","fixture":"medium","min_us":79,"med_us":87,"p90_us":90,"max_us":93},{"op":"key-down","fixture":"medium","min_us":81,"med_us":84,"p90_us":86,"max_us":90},{"op":"key-right","fixture":"medium","min_us":80,"med_us":85,"p90_us":89,"max_us":102},{"op":"page-down","fixture":"medium","min_us":85,"med_us":86,"p90_us":88,"max_us":88},{"op":"wheel","fixture":"medium","min_us":79,"med_us":85,"p90_us":90,"max_us":91},{"op":"edit-char","fixture":"medium","min_us":1037,"med_us":1300,"p90_us":1639,"max_us":1647},{"op":"edit-twin","fixture":"medium","min_us":2046,"med_us":2378,"p90_us":2529,"max_us":2585},{"op":"body-2m","fixture":"medium","min_us":5790,"med_us":9510,"p90_us":12226,"max_us":12462},{"op":"body-appends","fixture":"medium","min_us":132421,"med_us":224849,"p90_us":257368,"max_us":274890},{"op":"deep-sel","fixture":"medium","min_us":75,"med_us":80,"p90_us":84,"max_us":86}]}
diff --git a/test/perf-baseline-tty-ReleaseFast.json b/test/perf-baseline-tty-ReleaseFast.json
index 87a31754..4cbc8b95 100644
--- a/test/perf-baseline-tty-ReleaseFast.json
+++ b/test/perf-baseline-tty-ReleaseFast.json
@@ -1 +1 @@
-{"harness":"e63a8b27a0bcb261","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":2042,"med_us":2280,"p90_us":2451,"max_us":2478},{"op":"render","fixture":"medium","min_us":82,"med_us":84,"p90_us":87,"max_us":104},{"op":"key-down","fixture":"medium","min_us":83,"med_us":86,"p90_us":90,"max_us":93},{"op":"key-right","fixture":"medium","min_us":84,"med_us":87,"p90_us":91,"max_us":104},{"op":"page-down","fixture":"medium","min_us":84,"med_us":87,"p90_us":93,"max_us":96},{"op":"wheel","fixture":"medium","min_us":81,"med_us":85,"p90_us":89,"max_us":89},{"op":"edit-char","fixture":"medium","min_us":1309,"med_us":1613,"p90_us":1840,"max_us":2128},{"op":"body-2m","fixture":"medium","min_us":5386,"med_us":11064,"p90_us":11706,"max_us":12374},{"op":"body-appends","fixture":"medium","min_us":144772,"med_us":232307,"p90_us":273586,"max_us":281169},{"op":"deep-sel","fixture":"medium","min_us":79,"med_us":83,"p90_us":87,"max_us":130}]}
+{"harness":"303736f83a625e87","build":{"compiler":"0.16.0","target":"x86_64-linux.5.10...6.19-gnu.2.38","cpu":"x86_64","driver_optimize":"ReleaseFast","core_optimize":"ReleaseFast","c_optimize":"ReleaseFast","terminal_optimize":"ReleaseFast","platform":"tty","grammars":"full","mupdf":true,"jpx":true,"tracy":false,"quic":false,"theme_animation":true,"terminal_simd":true,"prebuilt_shaders":false},"cols":120,"rows":40,"reps":10,"only":"medium","fixtures":[{"name":"small","lines":1000,"cols":60,"bytes":62694},{"name":"medium","lines":50000,"cols":60,"bytes":3201944},{"name":"large","lines":300000,"cols":60,"bytes":19464444},{"name":"longline","lines":400,"cols":8000,"bytes":3200400},{"name":"esp32p4","lines":12,"cols":240,"bytes":2892}],"cells":[{"op":"open","fixture":"medium","min_us":1775,"med_us":1995,"p90_us":2092,"max_us":2094},{"op":"render","fixture":"medium","min_us":80,"med_us":86,"p90_us":89,"max_us":95},{"op":"key-down","fixture":"medium","min_us":81,"med_us":83,"p90_us":95,"max_us":101},{"op":"key-right","fixture":"medium","min_us":79,"med_us":84,"p90_us":85,"max_us":98},{"op":"page-down","fixture":"medium","min_us":80,"med_us":82,"p90_us":96,"max_us":97},{"op":"wheel","fixture":"medium","min_us":78,"med_us":80,"p90_us":87,"max_us":88},{"op":"edit-char","fixture":"medium","min_us":1036,"med_us":1377,"p90_us":1602,"max_us":1621},{"op":"edit-twin","fixture":"medium","min_us":2266,"med_us":2715,"p90_us":3695,"max_us":3927},{"op":"body-2m","fixture":"medium","min_us":4739,"med_us":8675,"p90_us":11518,"max_us":13657},{"op":"body-appends","fixture":"medium","min_us":111613,"med_us":214959,"p90_us":264859,"max_us":283506},{"op":"deep-sel","fixture":"medium","min_us":74,"med_us":76,"p90_us":89,"max_us":96}]}
diff --git a/test/perf.zig b/test/perf.zig
index 94076a25..c9235954 100644
--- a/test/perf.zig
+++ b/test/perf.zig
@@ -46,6 +46,9 @@ const Op = enum {
page_down,
wheel,
edit_char,
+ /// edit_char with a Zerox twin open on the same file: the twin takes
+ /// the splice, not a copy of the text (File.setContentSpan).
+ edit_twin,
/// 2 MB appended to the file through 9P, on one open, in 256 KB
/// writes: linear, not a copy of the whole buffer per write.
body_write,
@@ -67,6 +70,7 @@ const Op = enum {
.page_down => "page-down",
.wheel => "wheel",
.edit_char => "edit-char",
+ .edit_twin => "edit-twin",
.body_write => "body-2m",
.body_appends => "body-appends",
.deep_selection => "deep-sel",
@@ -511,6 +515,19 @@ fn gate(cells: *const [std.enums.values(Op).len][fixtures.len]Cell, base: *const
failed = true;
}
};
+ // Typing beside a twin costs about what typing alone does, whatever the
+ // baseline: the twin takes the splice, never a copy of the text.
+ const twin_op = @intFromEnum(Op.edit_twin);
+ const alone_op = @intFromEnum(Op.edit_char);
+ for (fixtures, 0..) |fx, fi| {
+ const twin = cells[twin_op][fi].min_us;
+ const alone = cells[alone_op][fi].min_us;
+ if (twin == 0 or alone == 0) continue;
+ if (@as(f64, @floatFromInt(twin)) > ratio * @as(f64, @floatFromInt(alone))) {
+ std.debug.print("perf gate: edit-twin on {s} took {d} us at best, over {d:.1}x edit-char's {d} us\n", .{ fx.name, twin, ratio, alone });
+ failed = true;
+ }
+ }
if (failed) return error.PerformanceRegression;
std.debug.print("perf gate: every cell within {d:.1}x of the baseline\n", .{ratio});
}
@@ -545,7 +562,13 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
core.active = id;
const pane = core.panes[id].?;
_ = try frame(core);
- if (op == .edit_char) {
+ if (op == .edit_twin) {
+ if (!core.executeBuiltinLine(id, "Zerox")) return error.ZeroxRefused;
+ pump(core);
+ core.active = id;
+ _ = try frame(core);
+ }
+ if (op == .edit_char or op == .edit_twin) {
core.update(.{ .key = .{ .cp = 'i', .text = "i" } });
pump(core);
}
@@ -572,7 +595,7 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
.key_right => core.update(.{ .key = .{ .cp = 'l', .text = "l" } }),
.page_down => core.update(.{ .key = .{ .cp = pardes.Key.page_down } }),
.wheel => core.update(.{ .mouse = .{ .button = .wheel_down, .kind = .press, .col = wheel_col, .row = wheel_row } }),
- .edit_char => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }),
+ .edit_char, .edit_twin => core.update(.{ .key = .{ .cp = 'x', .text = "x" } }),
.body_write => bodyWrite(core, pane.serial),
.body_appends => bodyAppends(core, pane.serial),
}
@@ -591,11 +614,16 @@ fn measure(op: Op, fx: Fixture, path: []const u8, expected_bytes: usize, reps: u
return error.PageDidNotScroll,
.wheel => if (pane.scroll() <= before.scroll and before.scroll < @as(i32, @intCast(fx.lines)) - pane.rows)
return error.WheelDidNotScroll,
- .edit_char => {
+ .edit_char, .edit_twin => {
if (pane.file.?.content.len != before.len + 1 or pane.body.cur_row != before.row or pane.body.cur_col != before.col + 1)
return error.CharacterWasNotInserted;
const line = pardes.panes.File.sourceLine(pane, before.row);
if (line[@intCast(before.col)] != 'x') return error.CharacterWasNotInserted;
+ if (op == .edit_twin) for (core.panes, 0..) |slot, other| {
+ const twin = slot orelse continue;
+ if (other == id or twin.file == null or twin.file.?.twin != pane.file.?.twin) continue;
+ if (twin.file.?.content.ptr != pane.file.?.content.ptr) return error.TwinMissedTheEdit;
+ } else if (pane.file.?.twin == 0) return error.NoTwin;
},
}
if (n >= warmup) samples[n - warmup] = dt / 1000;
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()
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))
diff --git a/test/selfmount.py b/test/selfmount.py
index 0111e6ac..61d32523 100644
--- a/test/selfmount.py
+++ b/test/selfmount.py
@@ -256,6 +256,53 @@ def run(binary):
time.sleep(.05)
check(got == '0' and all(f' held-{i}\n' in log for i in range(1, 41)),
f'40 closes holding a last line each through the mount, all answered, every line run ({got!r})')
+ # An Edit whose filter writes the very ctl the Edit came by: 9ns
+ # opens with FOPEN_PARALLEL_DIRECT_WRITES, so the filter's write
+ # is not queued behind the one waiting for it. (Its own open may
+ # not truncate, `1<>`, not `>`: Linux takes the file's lock
+ # outright for an O_TRUNC open, and the waiting write holds it
+ # shared; nothing a server answers can change that.)
+ started = time.monotonic()
+ got = run(f"printf 'Edit ,>echo Msg filter-hi 1<> {mount}/pane/{scratch}/ctl\\n' > {mount}/pane/{scratch}/ctl\n".encode())
+ took = time.monotonic() - started
+ check(got == '0' and took < 5 and 'filter-hi' in client.read('/log').decode(),
+ f'an Edit filter writing its own ctl through the mount answers promptly ({got!r}, {took:.1f}s)')
+ # Another session dies under a burst of reads of it through the
+ # registry mount: every read returns (an error is fine), none
+ # hangs. 9ns invalidates the dead entry from a thread of its own,
+ # never the one that must answer the reads holding its parent.
+ other = name + 'k'
+ other_sock = f'{runtime}/pardes-9p-{other}.sock'
+ other_env = dict(env, PARDES_DUMP=f'/tmp/{other}.dump.zon')
+ other_pid, other_master = pty.fork()
+ if other_pid == 0:
+ os.execve(binary, [binary, '--tty', '--9p=' + other], other_env)
+ os.set_blocking(other_master, False)
+ deadline = time.monotonic() + 15
+ while not os.path.exists(other_sock) and time.monotonic() < deadline:
+ try:
+ os.read(other_master, 65536)
+ except OSError:
+ pass
+ time.sleep(.05)
+ codes = Path(f'/tmp/{name}.codes')
+ codes.unlink(missing_ok=True)
+
+ def kill_other():
+ time.sleep(1.5)
+ os.kill(other_pid, signal.SIGKILL)
+ killer = threading.Thread(target=kill_other, daemon=True)
+ killer.start()
+ ran = run((f"for k in 1 2 3 4 5; do (end=$((SECONDS+4)); while [ $SECONDS -lt $end ]; do "
+ f"timeout 10 cat /mnt/9p/pardes/{other}/index > /dev/null 2>&1; echo $? >> {codes}; done) & done; wait\n").encode())
+ killer.join(5)
+ os.waitpid(other_pid, 0)
+ got_codes = codes.read_text().split() if codes.exists() else []
+ codes.unlink(missing_ok=True)
+ for path in (other_sock, f'/tmp/{other}.dump.zon'):
+ Path(path).unlink(missing_ok=True)
+ check(ran == '0' and got_codes and '124' not in got_codes,
+ f'reads of a session killed under them all return ({ran!r}, {len(got_codes)} reads, {got_codes.count("124")} hung)')
# Forty event reads held through the mount, a follower each, and
# the mount still answers the rest: 9ns keeps workers past what
# the editor holds (128), so `cat layout` is not queued behind
@@ -291,9 +338,75 @@ def run(binary):
said_many = many.read_text() if many.exists() else ''
many.unlink(missing_ok=True)
check(ran == '9' and 'Too many open files' in said_many, f'an open past the 64 records is EMFILE ({ran!r}, {said_many[-80:]!r})')
+ # An Edit whose command runs is held, not waited on: through the
+ # mount, a status read beside a slow one answers at once, and a
+ # command that reads this session's own tree runs.
+ edited = int(client.read('/pane/new'))
+ client.write(f'/pane/{edited}/body', b'pear\napple\n')
+ took = Path(f'/tmp/{name}.took')
+ ran = run((f"echo 'Edit , | sleep 3; sort' > {mount}/pane/{edited}/ctl & sleep .5; "
+ f"s=$(date +%s%N); timeout 5 cat {mount}/status > /dev/null; e=$(date +%s%N); "
+ f"echo $(( (e - s) / 1000000 )) > {took}; wait\n").encode())
+ ms = int(took.read_text()) if took.exists() else -1
+ took.unlink(missing_ok=True)
+ check(ran == '0' and 0 <= ms < 1000, f'a status read beside a slow Edit answers at once ({ms} ms)')
+ check(client.read(f'/pane/{edited}/body') == b'apple\npear\n', 'and the slow Edit applied')
+ ran = run(f"echo 'Edit $ < cat {mount}/pane/{edited}/body' > {mount}/pane/{edited}/ctl\n".encode())
+ check(ran == '0' and client.read(f'/pane/{edited}/body') == b'apple\npear\napple\npear\n',
+ f'an Edit whose command reads the session through its mount ({ran!r})')
+ # A filter gets what a pane's command gets: the session's mount,
+ # socket and pid.
+ env_pane = int(client.read('/pane/new'))
+ ran = run(f"echo 'Edit $ < printf \"%s|%s|%s\" \"$PARDES_MOUNT\" \"$PARDES_9P\" \"$PARDES_PID\"' > {mount}/pane/{env_pane}/ctl\n".encode())
+ got_env = client.read(f'/pane/{env_pane}/body').decode().split('|')
+ check(ran == '0' and len(got_env) == 3 and got_env[0] == mount and got_env[1] == sock and got_env[2].isdigit(),
+ f'an Edit filter has $PARDES_MOUNT, $PARDES_9P and $PARDES_PID ({got_env!r})')
+ # An interrupted Edit write is flushed: its command is stopped and
+ # nothing changes, and the next Edit runs.
+ flushed = Path(f'/tmp/{name}.flushed')
+ ran = run((f"timeout -s INT 1 sh -c \"echo 'Edit , | sleep 4; tr a-z A-Z' > {mount}/pane/{edited}/ctl\"; "
+ f"echo $? > {flushed}; sleep 1; echo 'Edit 1 | tr p P' > {mount}/pane/{edited}/ctl; echo $? >> {flushed}\n").encode())
+ codes = flushed.read_text().split() if flushed.exists() else []
+ flushed.unlink(missing_ok=True)
+ time.sleep(4.5)
+ check(codes == ['124', '0'] and client.read(f'/pane/{edited}/body') == b'aPPle\npear\napple\npear\n',
+ f'an interrupted Edit changes nothing, and the next runs ({codes!r}, {client.read(f"/pane/{edited}/body")!r})')
client.write('/exec', b'Msg still here\n')
- text = ''.join(cell[0] for cell in client.screen()['cells'])
+ # The terminal is read as it is waited on: a frame the editor
+ # cannot write out (its pty full) is a frame /screen does not have.
+ deadline = time.monotonic() + 8
+ while 'still here' not in (text := ''.join(cell[0] for cell in client.screen()['cells'])) and time.monotonic() < deadline:
+ drain()
+ time.sleep(.1)
check('still here' in text, 'the editor answers after all of that')
+ # A filter that writes its own pane's ctl through the mount. Both
+ # sides with `<>` run at once; a `>` or `>>` open on either side
+ # waits in the kernel for the other write, and the command is
+ # stopped at its 10 s limit: the Edit's write is answered then,
+ # never held by a command it cannot kill.
+ loop_pane = env_pane # the screen has no room for another
+ client.write(f'/pane/{loop_pane}/body', b'x\n', truncate=True)
+ inner = Path(f'/tmp/{name}.inner')
+ timed = Path(f'/tmp/{name}.timed')
+ for outer_open, inner_open, quick in [('3<>', '1<>', True), ('>', '>', False), ('>>', '>', False),
+ ('3<>', '>>', False), ('>', '>>', False)]:
+ # `clean`, which says nothing: a stopped filter's write still
+ # lands once the Edit's is answered.
+ inner.write_text(f'echo clean {inner_open} "$PARDES_MOUNT/pane/$winid/ctl"\n')
+ edit = f'Edit 1 > sh {inner}'
+ write = (f"exec 3<>{mount}/pane/{loop_pane}/ctl; echo '{edit}' >&3; rc=$?; exec 3>&-"
+ if outer_open == '3<>' else f"echo '{edit}' {outer_open} {mount}/pane/{loop_pane}/ctl; rc=$?")
+ ran = run((f"s=$(date +%s%N); {write}; e=$(date +%s%N); "
+ f"echo $rc $(( (e - s) / 1000000 )) > {timed}\n").encode())
+ rc_ms = timed.read_text().split() if timed.exists() else ['?', '-1']
+ timed.unlink(missing_ok=True)
+ ms = int(rc_ms[1])
+ ok = ms >= 0 and (ms < 2000 if quick else ms < 13000)
+ check(ran == '0' and ok, f'outer {outer_open}, filter {inner_open} on its own ctl: answered in {ms} ms (rc {rc_ms[0]})')
+ inner.unlink(missing_ok=True)
+ client.write('/exec', b'Msg after the stopped filters\n')
+ check('msg ' in client.read('/log').decode().split('after the stopped filters')[0][-40:],
+ 'and the editor answers after them')
finally:
os.kill(pid, signal.SIGTERM)
time.sleep(.2)
diff --git a/test/snapshots/builtins.golden b/test/snapshots/builtins.golden
index abeedd4b..87a515aa 100644
--- a/test/snapshots/builtins.golden
+++ b/test/snapshots/builtins.golden
@@ -30,38 +30,38 @@
|15: /tmp/pardes-snap/builtins/cwd/+Tutor Save Tty Collapse Del
|16: 1
|17: 2 PARDES TUTOR
-|18: 3 tmux + vi + acme, by doing.
-|19: 4
-|20: 5 This pane is +Tutor, a copy: edit it freely; there is no need to
-|21: 6 Save it (a bare Save asks for a path to write a copy to).
-|22: 7 `Del` in its tag closes it; once you have edited it, `Del` refuses
-|23: 8 once and the second `Del` discards. `Tutor` or SPC h t opens a
-|24: 9 fresh copy.
-|25: 10
-|26: 11 The guide (docs/typ/guide.typ) explains; this is the hands-on part.
-|27: 12 Each lesson is a few things to do, then names its guide section.
-|28: 13 Words in backticks, like `Save`, are builtins. Two carry the rest:
-|29: 14 `Exec` (middle-click, B2) runs the builtin a word names, or else
-|30: 15 runs it as a shell line; `Look` (right-click, B3) opens what the
-|31: 16 text names, or else finds it. Type a builtin in any tag and `Exec`
-|32: 17 it. Lines starting with "| " are tags.
-|33: 18
-|34: 19 Press j until you reach part 1.
+|18: 3
+|19: 4 This pane is +Tutor, a copy: edit it freely; there is no need to
+|20: 5 Save it (a bare Save asks for a path to write a copy to).
+|21: 6 `Del` in its tag closes it; once you have edited it, `Del` refuses
+|22: 7 once and the second `Del` discards. `Tutor` or SPC h t opens a
+|23: 8 fresh copy.
+|24: 9
+|25: 10 The guide (docs/typ/guide.typ) explains; this is the hands-on part.
+|26: 11 Each lesson is a few things to do, then names its guide section.
+|27: 12 Words in backticks, like `Save`, are builtins.
+|28: 13 `Exec` (B2) runs the builtin a word names, or else runs it as a
+|29: 14 shell line; `Look` (B3) opens what the text names, or else finds
+|30: 15 it. Type a builtin in any tag and `Exec` it. Lines starting with
+|31: 16 "| " are tags.
+|32: 17
+|33: 18 Press j until you reach part 1.
+|34: 19
|35: 20
-|36: 21
-|37: 22 =================================================================
-|38: 23 = PART 1 โ€” MODES =
-|39: 24 =================================================================
-|40: 25
-|41: 26 1. The box left of this pane's tag is blank, so you are in
-|42: 27 NORMAL mode. Press i: it shows ^ (INSERT). Type a word, then
-|43: 28 Esc: blank again.
-|44: 29 2. Press Esc once more, in NORMAL: the keyboard goes back to the
-|45: 30 pane you were in before (not what helix does). Come back with
-|46: 31 Esc again.
-|47: 32 3. Press Alt-n: a terminal opens, in NORMAL mode. Press Ctrl-b: its
-|48: 33 box shows $ (RAW), and keys go to the shell. At its empty prompt,
-|49: 34 Esc brings you back here; the terminal stays $.
+|36: 21 =================================================================
+|37: 22 = PART 1 โ€” MODES =
+|38: 23 =================================================================
+|39: 24
+|40: 25 1. The box left of this pane's tag is blank, so you are in
+|41: 26 NORMAL mode. Press i: it shows ^ (INSERT). Type a word, then
+|42: 27 Esc: blank again.
+|43: 28 2. Press Esc once more, in NORMAL: the keyboard goes back to the
+|44: 29 pane you were in before (not what helix does). Come back with
+|45: 30 Esc again.
+|46: 31 3. Press Alt-n: a terminal opens, in NORMAL mode, and pardes types
+|47: 32 ls at its first prompt. Press Ctrl-b: its box shows $ (RAW), and
+|48: 33 keys go to the shell. At its empty prompt, Esc brings you back
+|49: 34 here; the terminal stays $.
|50: 35
|51: 36 -> Guide: Modes
|52: 37
diff --git a/test/snapshots/cmdword.snap b/test/snapshots/cmdword.snap
index 631fab8b..42a91e18 100644
--- a/test/snapshots/cmdword.snap
+++ b/test/snapshots/cmdword.snap
@@ -23,6 +23,9 @@ file zzmark.txt x
# the top of the body -- which would move every row this script clicks. The
# verbs are what is under test here, not the announcement.
config Verbose off
+# a directory looked at opens a terminal there (DirLook terminal), as this
+# script was written for; DirLook pane, the default, is dirpane.snap's
+config DirLook terminal
start 31 120 cmds.txt
wait 8000 Newcol Joincol
wait 8000 cmds.txt
diff --git a/test/snapshots/dirpane.golden b/test/snapshots/dirpane.golden
new file mode 100644
index 00000000..3118079f
--- /dev/null
+++ b/test/snapshots/dirpane.golden
@@ -0,0 +1,13 @@
+== snap dir-open grid=80x24 cursor=7,6
+|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
+| New Tty Find Grep Joincol Delcol
+| /tmp/pardes-snap/dirpane/cwd/top.txt Save Tty Collapse Del
+| 1 sub
+|
+| /tmp/pardes-snap/dirpane/cwd/sub/ Get Tty Collapse Del
+| 1 .hidden beta-gamma.txt inner.txt
+| 2 alpha.txt deeper/ zeta
+| 3
+== snap entry-open grid=80x24 cursor=7,9
+|8: /tmp/pardes-snap/dirpane/cwd/sub/inner.txt Save Tty Collapse Del
+|9: 1 inner-marker
diff --git a/test/snapshots/dirpane.snap b/test/snapshots/dirpane.snap
new file mode 100644
index 00000000..a884b12e
--- /dev/null
+++ b/test/snapshots/dirpane.snap
@@ -0,0 +1,27 @@
+# a look at a directory opens a pane named for it with a trailing /, its
+# entries in columns as acme's directory window lays them out: down each
+# column first, a directory's marked /, dotfiles shown. A look at an entry
+# opens it from that directory. No shell anywhere: nothing here races a prompt.
+dirmk sub
+dirmk sub/deeper
+file sub/inner.txt inner-marker
+file sub/.hidden x
+file sub/alpha.txt x
+file sub/beta-gamma.txt x
+file sub/zeta x
+file top.txt sub
+config Verbose off
+start 24 80 top.txt
+wait 8000 top.txt
+stable 700 20000
+press right @sub 4
+release right @sub 4
+wait 8000 inner.txt
+stable 700 20000
+snap dir-open
+# an entry, by its name alone: sub's inner.txt
+press right @inner.txt 7
+release right @inner.txt 7
+wait 8000 inner-marker
+stable 700 20000
+snap entry-open
diff --git a/test/snapshots/dump.golden b/test/snapshots/dump.golden
index ea6e5912..571bacbc 100644
--- a/test/snapshots/dump.golden
+++ b/test/snapshots/dump.golden
@@ -108,5 +108,6 @@
"MessageLinger 0",
"Shell bash",
},
+ .incl = .{ "/usr/include", "/usr/local/include" },
}
== end file
diff --git a/test/snapshots/images.golden b/test/snapshots/images.golden
index e888460d..0f15804b 100644
--- a/test/snapshots/images.golden
+++ b/test/snapshots/images.golden
@@ -5,8 +5,8 @@
| ls ls
| pic.ppm pic.ppm
|
-| img petscii:off palette:commodore ascii:on /tmp/pardes-snap/images/cwd/p
-| ic.ppm Tty Collapse Del
+| img petscii:on palette:commodore ascii:on /tmp/pardes-snap/images/cwd/pi
+| c.ppm Tty Collapse Del
| x๐Ÿฌฑ\โ•ฒ๐ŸฌŒ\~โ–„๐Ÿฌฅ4โ–Ÿg*๐Ÿฌบ๐Ÿฌถ_๐Ÿฌ†๐Ÿฌป
| >A8๐ŸฌŒCq&.\H&๐Ÿฌน๐ŸฌœCโ–›๐Ÿฌป
| ><e'๐Ÿฌทy๐Ÿฌ™โ–„Fso๐Ÿฌด๐Ÿฌ†๐Ÿฌน
@@ -26,8 +26,8 @@
|3: 0-0 d,#608269, 1-1 d,#0d1410, 2-74 #e5eee5,#0d1410, 75-75 #e5eee5,#608269, 76-149 #e5eee5,#0d1410,
|4: 0-0 d,#608269, 1-1 d,#0d1410, 2-74 #e5eee5,#0d1410, 75-75 #e5eee5,#608269, 76-149 #e5eee5,#0d1410,
|5: 0-0 d,#608269, 1-74 d,#0d1410, 75-75 d,#608269, 76-149 d,#0d1410,
-|6: 0-1 d,#add38a, 2-2 d,#314738, 3-73 #becfc4,#314738, 74-74 #dfbce5,#314738, 75-75 #dfbce5,#608269, 76-149 #dfbce5,#0d1410,
-|7: 0-2 d,#314738, 3-9 #dfbce5,#314738, 10-74 #becfc4,#314738, 75-75 #becfc4,#608269, 76-149 #becfc4,#0d1410,
+|6: 0-1 d,#add38a, 2-2 d,#314738, 3-72 #becfc4,#314738, 73-74 #dfbce5,#314738, 75-75 #dfbce5,#608269, 76-149 #dfbce5,#0d1410,
+|7: 0-2 d,#314738, 3-8 #dfbce5,#314738, 9-74 #becfc4,#314738, 75-75 #becfc4,#608269, 76-149 #becfc4,#0d1410,
|8: 0-0 d,#152019, 1-28 d,#0d1410, 29-30 #959595,#6c6c6c, 31-31 #6c6c6c,#959595, 32-32 #959595,#6c6c6c, 33-33 #6c6c6c,#959595, 34-35 #959595,#6c6c6c, 36-36 #6c6c6c,#959595, 37-37 #959595,#6c6c6c, 38-40 #6c6c6c,#959595, 41-41 #959595,#6c6c6c, 42-43 #6c6c6c,#959595, 44-44 #959595,#6c6c6c, 45-45 #6c6c6c,#6c5eb5, 46-46 #352879,#959595, 47-74 #352879,#0d1410, 75-75 #352879,#608269, 76-149 #352879,#0d1410,
|9: 0-0 d,#152019, 1-28 d,#0d1410, 29-29 #959595,#6c6c6c, 30-31 #6c6c6c,#959595, 32-35 #959595,#6c6c6c, 36-36 #6c6c6c,#959595, 37-38 #959595,#6c6c6c, 39-42 #6c6c6c,#959595, 43-43 #6c6c6c,#352879, 44-44 #352879,#6c6c6c, 45-46 #352879,#352879, 47-74 #352879,#0d1410, 75-75 #352879,#608269, 76-149 #352879,#0d1410,
|10: 0-0 d,#152019, 1-28 d,#0d1410, 29-30 #959595,#6c6c6c, 31-31 #6c6c6c,#959595, 32-32 #959595,#6c6c6c, 33-33 #6c6c6c,#959595, 34-34 #959595,#6c6c6c, 35-36 #6c6c6c,#959595, 37-39 #959595,#6c6c6c, 40-40 #6c6c6c,#959595, 41-41 #6c6c6c,#6c5eb5, 42-42 #352879,#6c5eb5, 43-46 #352879,#352879, 47-74 #352879,#0d1410, 75-75 #352879,#608269, 76-149 #352879,#0d1410,
@@ -52,8 +52,8 @@
|29: 0-0 d,#608269, 1-74 d,#0d1410, 75-75 d,#608269, 76-149 d,#0d1410,
|30: 0-0 d,#608269, 1-74 d,#0d1410, 75-75 d,#608269, 76-149 d,#0d1410,
== style term-palette grid=150x31
-|6: 0-1 d,#add38a, 2-2 d,#314738, 3-72 #becfc4,#314738, 73-74 #dfbce5,#314738, 75-75 #dfbce5,#608269, 76-149 #dfbce5,#0d1410,
-|7: 0-2 d,#314738, 3-8 #dfbce5,#314738, 9-74 #becfc4,#314738, 75-75 #becfc4,#608269, 76-149 #becfc4,#0d1410,
+|6: 0-1 d,#add38a, 2-2 d,#314738, 3-71 #becfc4,#314738, 72-74 #dfbce5,#314738, 75-75 #dfbce5,#608269, 76-149 #dfbce5,#0d1410,
+|7: 0-2 d,#314738, 3-7 #dfbce5,#314738, 8-74 #becfc4,#314738, 75-75 #becfc4,#608269, 76-149 #becfc4,#0d1410,
|8: 0-0 d,#152019, 1-65 d,#0d1410, 66-66 d,#1d2824, 67-74 #b2c3b8,#1d2824, 75-75 #b2c3b8,#608269, 76-149 #b2c3b8,#0d1410,
|9: 0-0 d,#152019, 1-29 d,#0d1410, 30-33 p8,p4, 34-34 p4,p8, 35-35 p8,p4, 36-36 p4,p8, 37-43 p8,p4, 44-44 p8,p12, 45-45 p12,p8, 46-74 p12,#0d1410, 75-75 p12,#608269, 76-149 p12,#0d1410,
|10: 0-0 d,#152019, 1-29 d,#0d1410, 30-30 p4,p8, 31-31 p8,p4, 32-32 p4,p8, 33-41 p8,p4, 42-43 p12,p8, 44-45 p12,p12, 46-74 p12,#0d1410, 75-75 p12,#608269, 76-149 p12,#0d1410,
@@ -64,7 +64,7 @@
|15: 0-0 d,#152019, 1-29 d,#0d1410, 30-30 p4,p8, 31-31 p8,p4, 32-32 p8,p12, 33-33 p12,p8, 34-45 p12,p12, 46-74 p12,#0d1410, 75-75 p12,#608269, 76-149 p12,#0d1410,
|16: 0-0 d,#152019, 1-29 d,#0d1410, 30-30 p8,p12, 31-31 p12,p8, 32-45 p12,p12, 46-74 p12,#0d1410, 75-75 p12,#608269, 76-149 p12,#0d1410,
== snap no-ascii grid=150x31 cursor=11,8
-|6: img petscii:off palette:terminal ascii:off /tmp/pardes-snap/images/cwd/p
+|6: img petscii:on palette:terminal ascii:off /tmp/pardes-snap/images/cwd/pi
|8: Ascii
|9: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ๐Ÿฌ๐Ÿฌน
|10: โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ๐Ÿฌ›โ–ˆโ–ˆโ–ˆโ–ˆโ–ˆ๐Ÿฌโ–„
@@ -74,14 +74,14 @@
|14: โ–ˆโ–ˆโ–ˆโ–ˆ๐Ÿฌโ–„
|15: โ”‚โ–ˆ๐Ÿฌโ–„
|16: ๐Ÿฌ๐Ÿฌน
-== snap tag-edit grid=400x31 cursor=87,6
+== snap tag-edit grid=400x31 cursor=86,6
|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/images/cwd Tty+bash Save Mode Filter Collapse Del /tmp/pardes-snap/images/cwd Tty+bash Save Mode Filter Collapse Del
| ls ls
| pic.ppm pic.ppm
|
-| img petscii:off palette:terminal ascii:off /tmp/pardes-snap/images/cwd/pic.ppm Zork Tty Collapse Del
+| img petscii:on palette:terminal ascii:off /tmp/pardes-snap/images/cwd/pic.ppm Zork Tty Collapse Del
| โ–ˆโ–ˆโ–ˆโ•ฑโ–ˆ๐Ÿฌปโ–ˆโ–ˆ๐Ÿฌบโ–ˆโ–ˆ๐Ÿฌปโ–ˆโ–ˆโ–ˆโ•ฑโ–ˆโ–ˆ๐Ÿฌดโ–„
| ๐Ÿฌฌโ–ˆโ–ˆโ•ฑโ–ˆโ–ˆโ–ˆโ•ฑ๐Ÿฌป๐Ÿฌ๐Ÿฌป๐Ÿฌฌโ–ˆโ–ˆโ–ˆโ–ˆ๐Ÿฌ๐Ÿฌตโ–ˆ
| โ–ˆโ•ฒโ–ˆ๐Ÿฌดโ–ˆโ–ˆโ–ˆโ–ˆโ•ฒโ•ฑโ–ˆ๐Ÿฌโ–ˆโ–ˆโ–Ÿโ–„โ–ˆ
diff --git a/test/snapshots/layout-open.golden b/test/snapshots/layout-open.golden
index a3d9dfdd..d2cbb9a9 100644
--- a/test/snapshots/layout-open.golden
+++ b/test/snapshots/layout-open.golden
@@ -1,4 +1,4 @@
-== snap before grid=100x31 cursor=2,9
+== snap before grid=100x31 cursor=4,9
|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/layout-open/cwd Tty+bash Save /tmp/pardes-snap/layout-open/cwd Tty+bash Save
diff --git a/test/snapshots/leader.golden b/test/snapshots/leader.golden
index 5bd7e284..cdc78e66 100644
--- a/test/snapshots/leader.golden
+++ b/test/snapshots/leader.golden
@@ -21,33 +21,33 @@
|11: /tmp/pardes-snap/leader/cwd/+Tutor Save Tty Collapse Del
|12: 1
|13: 2 PARDES TUTOR
-|14: 3 tmux + vi + acme, by doing.
-|15: 4
-|16: 5 This pane is +Tutor, a copy: edit it freely; there is no need to
-|17: 6 Save it (a bare Save asks for a path to write a copy to).
-|18: 7 `Del` in its tag closes it; once you have edited it, `Del` refuses
-|19: 8 once and the second `Del` discards. `Tutor` or SPC h t opens a
-|20: 9 fresh copy.
-|21: 10
-|22: 11 The guide (docs/typ/guide.typ) explains; this is the hands-on part.
-|23: 12 Each lesson is a few things to do, then names its guide section.
-|24: 13 Words in backticks, like `Save`, are builtins. Two carry the rest:
-|25: 14 `Exec` (middle-click, B2) runs the builtin a word names, or else
-|26: 15 runs it as a shell line; `Look` (right-click, B3) opens what the
-|27: 16 text names, or else finds it. Type a builtin in any tag and `Exec`
-|28: 17 it. Lines starting with "| " are tags.
-|29: 18
-|30: 19 Press j until you reach part 1.
+|14: 3
+|15: 4 This pane is +Tutor, a copy: edit it freely; there is no need to
+|16: 5 Save it (a bare Save asks for a path to write a copy to).
+|17: 6 `Del` in its tag closes it; once you have edited it, `Del` refuses
+|18: 7 once and the second `Del` discards. `Tutor` or SPC h t opens a
+|19: 8 fresh copy.
+|20: 9
+|21: 10 The guide (docs/typ/guide.typ) explains; this is the hands-on part.
+|22: 11 Each lesson is a few things to do, then names its guide section.
+|23: 12 Words in backticks, like `Save`, are builtins.
+|24: 13 `Exec` (B2) runs the builtin a word names, or else runs it as a
+|25: 14 shell line; `Look` (B3) opens what the text names, or else finds
+|26: 15 it. Type a builtin in any tag and `Exec` it. Lines starting with
+|27: 16 "| " are tags.
+|28: 17
+|29: 18 Press j until you reach part 1.
+|30: 19
|31: 20
-|32: 21
-|33: 22 =================================================================
-|34: 23 = PART 1 โ€” MODES =
-|35: 24 =================================================================
-|36: 25
-|37: 26 1. The box left of this pane's tag is blank, so you are in
-|38: 27 NORMAL mode. Press i: it shows ^ (INSERT). Type a word, then
-|39: 28 Esc: blank again.
-|40: 29 2. Press Esc once more, in NORMAL: the keyboard goes back to the
+|32: 21 =================================================================
+|33: 22 = PART 1 โ€” MODES =
+|34: 23 =================================================================
+|35: 24
+|36: 25 1. The box left of this pane's tag is blank, so you are in
+|37: 26 NORMAL mode. Press i: it shows ^ (INSERT). Type a word, then
+|38: 27 Esc: blank again.
+|39: 28 2. Press Esc once more, in NORMAL: the keyboard goes back to the
+|40: 29 pane you were in before (not what helix does). Come back with
== snap deleted grid=100x41 cursor=7,3
|5:
|6:
diff --git a/test/snapshots/look-dir.snap b/test/snapshots/look-dir.snap
index caf29da8..2032d0c0 100644
--- a/test/snapshots/look-dir.snap
+++ b/test/snapshots/look-dir.snap
@@ -2,6 +2,9 @@
dirmk sub
file sub/inner.txt inner-marker
file top.txt x
+# a directory looked at opens a terminal there (DirLook terminal), as this
+# script was written for; DirLook pane, the default, is dirpane.snap's
+config DirLook terminal
start 31 100 -n 3
wait 8000 top.txt
stable 700 20000
diff --git a/test/snapshots/lookpanes.snap b/test/snapshots/lookpanes.snap
index e1db907a..2834a4a9 100644
--- a/test/snapshots/lookpanes.snap
+++ b/test/snapshots/lookpanes.snap
@@ -24,6 +24,9 @@ file beta/bmark.txt both.txt\nb-marker
# the top of the body -- which moves every row this script clicks. What the
# verbs do is what is under test, not the announcement.
config Verbose off
+# a directory looked at opens a terminal there (DirLook terminal), as this
+# script was written for; DirLook pane, the default, is dirpane.snap's
+config DirLook terminal
start 51 130 beta/bmark.txt
wait 8000 b-marker
stable 700 20000
diff --git a/test/snapshots/nested.golden b/test/snapshots/nested.golden
index 07d6148d..44ab4b82 100644
--- a/test/snapshots/nested.golden
+++ b/test/snapshots/nested.golden
@@ -46,15 +46,15 @@
|
| /tmp/pardes-snap/nested/cwd/+New Save Tty Collapse Del
| 1
-== snap dir-arg grid=100x31 cursor=4,8
+== snap dir-arg grid=100x31 cursor=7,6
|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
| New Tty Find Grep Joincol Delcol
|$ /tmp/pardes-snap/nested/cwd Tty+bash Save Mode Filter Collapse Del
| $ $(readlink /proc/$PPID/exe) nestdir
| $
-| /tmp/pardes-snap/nested/cwd/nestdir Tty+bash Save Mode Filter Collapse Del
-| ls
-| inside-nested.txt
+| /tmp/pardes-snap/nested/cwd/nestdir/ Get Tty Collapse Del
+| 1 inside-nested.txt
+| 2
|
|
|
diff --git a/test/snapshots/shrinktag.golden b/test/snapshots/shrinktag.golden
index d73c0072..6e64d89b 100644
--- a/test/snapshots/shrinktag.golden
+++ b/test/snapshots/shrinktag.golden
@@ -1,4 +1,4 @@
-== snap before grid=100x31 cursor=2,9
+== snap before grid=100x31 cursor=4,9
|Newcol Joincol Find Grep Help Changelog Tutor Dump Themes Config Debug Exit
| New Tty Find Grep Joincol Delcol New Tty Find Grep Joincol Delcol
| /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
@@ -20,7 +20,7 @@
| de Filter Collapse Del
| ls
| w.txt
-== snap shrunk grid=100x31 cursor=2,9
+== snap shrunk grid=100x31 cursor=4,9
|4: w.txt ls
|5: w.txt
|6: /tmp/pardes-snap/shrinktag/cwd Tty+bash Save Mo
@@ -31,9 +31,9 @@
|18:
|19:
|20:
-== snap move-preview grid=100x31 cursor=2,9
+== snap move-preview grid=100x31 cursor=4,9
|8: ls โ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ–Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œโ•Œ
-== snap restored grid=100x31 cursor=2,9
+== snap restored grid=100x31 cursor=4,9
|4: ls ls
|5: w.txt w.txt
|6:
@@ -44,7 +44,7 @@
|18: de Filter Collapse Del
|19: ls
|20: w.txt
-== snap bottom-shrunk grid=100x31 cursor=2,9
+== snap bottom-shrunk grid=100x31 cursor=4,9
|17:
|18:
|19:
diff --git a/test/snapshots/tagalign.snap b/test/snapshots/tagalign.snap
index 3bb3922e..7237167a 100644
--- a/test/snapshots/tagalign.snap
+++ b/test/snapshots/tagalign.snap
@@ -22,12 +22,15 @@ press left 12 3
release left 12 3
stable 400 5000
snap clicked
-# The click was on the path, so it drafted a name; Esc drops the draft. `:`
+# The click was on the path, so it drafted a name; Esc keeps it pending and
+# a second Esc drops the draft. `:`
# then `A` puts the caret after the tag's end, which reveals the complete
# command suffix; a scrolled Del still executes.
resize 31 70
key esc
settle 100
+key esc
+settle 100
key :
key A
stable 400 5000
diff --git a/test/snapshots/tagbottomimage.golden b/test/snapshots/tagbottomimage.golden
index 2bf9231e..ce6948bf 100644
--- a/test/snapshots/tagbottomimage.golden
+++ b/test/snapshots/tagbottomimage.golden
@@ -5,7 +5,7 @@
| ls
| pic.ppm
|
-| img petscii:off palette:commodore ascii:on /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm Tty Collapse Del
+| img petscii:on palette:commodore ascii:on /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm Tty Collapse Del
| 4*๐Ÿฌน~F~N๐Ÿฌฐ|G๐Ÿฌโ–šY~KF๐Ÿฌดs<fcSf๐Ÿฌฐb^๐Ÿฌ˜J%๐ŸฌZH|S_"Z}โ•ฒ%'"qA๐Ÿฌ—a๐Ÿฌ๐Ÿฌป
| 8jDStGโ–„40e~.>>T&zZ/๐ŸฌปiTY=`S~โ•ณbkZR37v๐Ÿฌฆ๐Ÿฌตd4QHZ8Xโ–›โ–Ÿ
| ^๐Ÿฌช๐Ÿฌ›$}DrydNU&wsz=`*r๐Ÿฌ’๐Ÿฌ“๐Ÿฌณ๐ŸฌŸ*Y๐ŸฌดG6๐Ÿฌงf๐Ÿฌฎ/>t๐Ÿฌงu๐Ÿฌฒ1Rg1t๐Ÿฌ๐Ÿฌปโ–ˆ
@@ -59,7 +59,7 @@
|27: P4๐Ÿฌœ1๐Ÿฌ~โ–ˆ
|28: ๐ŸฌฎG๐Ÿฌ๐Ÿฌน
|29: ๐Ÿฌญโ–Ÿโ–ˆ
-|30: img petscii:off palette:commodore ascii:on /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm Tty Collapse Del
+|30: img petscii:on palette:commodore ascii:on /tmp/pardes-snap/tagbottomimage/cwd/pic.ppm Tty Collapse Del
== style img-bottom grid=150x31
|0: 0-149 #b2c3b8,#1d2824,
|1: 0-1 d,#887455, 2-2 d,#314738, 3-149 #becfc4,#314738,
@@ -91,7 +91,7 @@
|27: 0-0 d,#152019, 1-52 d,#0d1410, 53-54 #959595,#6c6c6c, 55-56 #6c6c6c,#959595, 57-57 #6c6c6c,#352879, 58-58 #959595,#352879, 59-59 #352879,#6c5eb5, 60-98 #352879,#352879, 99-149 #352879,#0d1410,
|28: 0-0 d,#152019, 1-52 d,#0d1410, 53-54 #959595,#6c6c6c, 55-55 #6c6c6c,#352879, 56-56 #352879,#6c6c6c, 57-98 #352879,#352879, 99-149 #352879,#0d1410,
|29: 0-0 d,#152019, 1-52 d,#0d1410, 53-53 #352879,#6c6c6c, 54-54 #352879,#959595, 55-55 #352879,#6c5eb5, 56-98 #352879,#352879, 99-149 #352879,#0d1410,
-|30: 0-1 d,#add38a, 2-2 d,#314738, 3-81 #becfc4,#314738, 82-89 #dfbce5,#314738, 90-149 #becfc4,#314738,
+|30: 0-1 d,#add38a, 2-2 d,#314738, 3-80 #becfc4,#314738, 81-88 #dfbce5,#314738, 89-149 #becfc4,#314738,
== snap search-prompt grid=150x31 cursor=149,6
|6: /ZZ
== snap del-image grid=150x31 cursor=5,7
diff --git a/test/snapshots/tagpath.snap b/test/snapshots/tagpath.snap
index f02769d9..841d4fed 100644
--- a/test/snapshots/tagpath.snap
+++ b/test/snapshots/tagpath.snap
@@ -41,6 +41,9 @@ key i
text /renamed
stable 400 5000
snap name-draft
+# Esc keeps the draft pending; a second drops it
+key esc
+settle 100
key esc
stable 400 5000
snap name-cancelled
diff --git a/test/snapshots/ttyedit.golden b/test/snapshots/ttyedit.golden
index ab1b1b5b..70ab8504 100644
--- a/test/snapshots/ttyedit.golden
+++ b/test/snapshots/ttyedit.golden
@@ -22,12 +22,14 @@
| words.txt
== snap split grid=100x31 cursor=2,9
|2: ^ /tmp/pardes-snap/ttyedit/cwd Tty+bash Save Mode /tmp/pardes-snap/ttyedit/cwd Tty+bash Save Mode
+|4: next replaces it (Ctrl-b types to the program) ls
|8: one t
|9: wo three
|10: four five six
|11: seven eight nine
== snap opened grid=100x31 cursor=6,10
|2: /tmp/pardes-snap/ttyedit/cwd Tty+bash Save Mode /tmp/pardes-snap/ttyedit/cwd Tty+bash Save Mode
+|4: ls ls
|10: OPEN
|11: four five six
|12: seven eight nine
diff --git a/test/snapshots/ttylook.snap b/test/snapshots/ttylook.snap
index 5b82bd89..91401a64 100644
--- a/test/snapshots/ttylook.snap
+++ b/test/snapshots/ttylook.snap
@@ -9,6 +9,9 @@
lines big.txt 40
file plain.txt just text
dirmk sub
+# a directory looked at opens a terminal there (DirLook terminal), as this
+# script was written for; DirLook pane, the default, is dirpane.snap's
+config DirLook terminal
start 31 120 -n 3
wait 8000 big.txt
stable 700 20000
diff --git a/test/snapshots/tutor.golden b/test/snapshots/tutor.golden
index f3585801..d861fcb9 100644
--- a/test/snapshots/tutor.golden
+++ b/test/snapshots/tutor.golden
@@ -7,14 +7,14 @@
| /tmp/pardes-snap/tutor/cwd/+Tutor Save Tty Collapse Del
| 1
| 2 PARDES TUTOR
-| 3 tmux + vi + acme, by doing.
-| 4
-| 5 This pane is +Tutor, a copy: edit it freely; there is no need to
-| 6 Save it (a bare Save asks for a path to write a copy to).
-| 7 `Del` in its tag closes it; once you have edited it, `Del` refuses
-| 8 once and the second `Del` discards. `Tutor` or SPC h t opens a
-| 9 fresh copy.
-| 10
-| 11 The guide (docs/typ/guide.typ) explains; this is the hands-on part
+| 3
+| 4 This pane is +Tutor, a copy: edit it freely; there is no need to
+| 5 Save it (a bare Save asks for a path to write a copy to).
+| 6 `Del` in its tag closes it; once you have edited it, `Del` refuses
+| 7 once and the second `Del` discards. `Tutor` or SPC h t opens a
+| 8 fresh copy.
+| 9
+| 10 The guide (docs/typ/guide.typ) explains; this is the hands-on partโ†ฉ
+| .
| /tmp/pardes-snap/tutor/cwd Tty+bash Save Mode Filter Collapse Del
| ls