diff options
Diffstat (limited to 'test')
27 files changed, 2109 insertions, 156 deletions
@@ -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 |
