From e29faca73e07eb90aa0c8f6f33e55c8ace77a3c4 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 18:40:43 -0300 Subject: A blank whose ink alone changed is skipped in vaxis's own frame diff, not weighed up per cell in the tty --- build.zig | 52 ++++++++++++++++++++++++++++++++++++++++++++++++++-- 1 file changed, 50 insertions(+), 2 deletions(-) (limited to 'build.zig') diff --git a/build.zig b/build.zig index 8646be05..2419dd59 100644 --- a/build.zig +++ b/build.zig @@ -864,11 +864,59 @@ pub fn build(b: *std.Build) void { \\} \\ ; + // vaxis's render, patched the same way: a blank whose ink alone + // changed is not sent, since a blank's ink shows nowhere. A fade + // (InactiveDim on a focus change) sent every blank of every pane it + // touched again: 7 KB a click for three shell panes. The terminal + // keeps the old ink there, and so does vaxis's last frame. + const render_path = vaxis_dep.path("src/Vaxis.zig").getPath(b); + var render_src = std.Io.Dir.cwd().readFileAlloc(io, render_path, b.allocator, .limited(1 << 20)) catch @panic("read vaxis Vaxis.zig"); + const render_anchor = + \\ if ((!self.refresh and + \\ last.eql(cell) and + ; + if (std.mem.count(u8, render_src, render_anchor) != 1) std.debug.panic("vaxis's Vaxis.zig changed at `{s}`: redo its patch in build.zig", .{render_anchor}); + render_src = std.mem.replaceOwned(u8, b.allocator, render_src, render_anchor, + \\ if ((!self.refresh and + \\ (last.eql(cell) or blankInkOnly(&last, &cell)) and // pardes's patch (its build.zig) + \\ + ) catch @panic("OOM"); + const blank_ink = + \\ + \\/// pardes's patch (its build.zig): a blank cell (a space, no underline, + \\/// strike or reverse) that differs from the last frame's blank in its + \\/// ink alone, which shows nowhere. + \\inline fn blankInkOnly(last: *const InternalScreen.InternalCell, cell: *const Cell) bool { + \\ if (!(cell.char.grapheme.len == 1 and cell.char.grapheme[0] == ' ')) return false; + \\ if (last.default or cell.default) return false; + \\ if (!(last.char.items.len == 1 and last.char.items[0] == ' ')) return false; + \\ const s = cell.style; + \\ if (s.reverse or s.strikethrough or s.ul_style != .off) return false; + \\ if (!std.mem.eql(u8, last.uri.items, cell.link.uri)) return false; + \\ // Field by field: Style.eql is slow, and this runs for every blank + \\ // a scrolling shell moves. + \\ const l = last.style; + \\ return sameColor(l.bg, s.bg) and sameColor(l.ul, s.ul) and l.ul_style == s.ul_style and + \\ l.bold == s.bold and l.dim == s.dim and l.italic == s.italic and l.blink == s.blink and + \\ !l.reverse and !l.strikethrough and l.invisible == s.invisible; + \\} + \\ + \\inline fn sameColor(a: Cell.Color, b: Cell.Color) bool { + \\ return switch (a) { + \\ .default => b == .default, + \\ .index => |i| b == .index and b.index == i, + \\ .rgb => |rgb| b == .rgb and rgb[0] == b.rgb[0] and rgb[1] == b.rgb[1] and rgb[2] == b.rgb[2], + \\ }; + \\} + \\ + ; const files = b.addWriteFiles(); _ = files.add("Parser.zig", b.fmt("{s}{s}", .{ src, cursor_report })); + _ = files.add("Vaxis.zig", b.fmt("{s}{s}", .{ render_src, blank_ink })); _ = files.addCopyFile(vaxis_dep.path("src/widgets/terminal/Parser.zig"), "widgets/terminal/Parser.zig"); - // Suffixes: this leaves out both Parser.zig files, written above. - _ = files.addCopyDirectory(vaxis_dep.path("src"), "", .{ .exclude_extensions = &.{"Parser.zig"} }); + // Suffixes: this leaves out both Parser.zig files and Vaxis.zig, + // written above. + _ = files.addCopyDirectory(vaxis_dep.path("src"), "", .{ .exclude_extensions = &.{ "Parser.zig", "Vaxis.zig" } }); vaxis_mod.root_source_file = files.getDirectory().path(b, "main.zig"); } -- cgit v1.3 From 156891c9346ee15aea5e10edbc80850f0cf36797 Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 19:26:28 -0300 Subject: vaxis sends a frame with nothing changed as nothing: its secondary-cursor test read equal as changed --- build.zig | 6 ++++++ 1 file changed, 6 insertions(+) (limited to 'build.zig') diff --git a/build.zig b/build.zig index 2419dd59..8b7cb3b2 100644 --- a/build.zig +++ b/build.zig @@ -881,6 +881,12 @@ pub fn build(b: *std.Build) void { \\ (last.eql(cell) or blankInkOnly(&last, &cell)) and // pardes's patch (its build.zig) \\ ) catch @panic("OOM"); + // And the secondary cursors count as changed when they differ, not + // when they are the same: read the other way round, every render + // started a frame, ~40 bytes to the terminal with nothing changed. + const secondary_anchor = " std.meta.eql(self.screen.cursor_secondary, self.state.cursor_secondary);\n"; + if (std.mem.count(u8, render_src, secondary_anchor) != 1) std.debug.panic("vaxis's Vaxis.zig changed at `{s}`: redo its patch in build.zig", .{secondary_anchor}); + render_src = std.mem.replaceOwned(u8, b.allocator, render_src, secondary_anchor, " !std.meta.eql(self.screen.cursor_secondary, self.state.cursor_secondary); // pardes's patch (its build.zig)\n") catch @panic("OOM"); const blank_ink = \\ \\/// pardes's patch (its build.zig): a blank cell (a space, no underline, -- cgit v1.3 From a8b59e683e9713a790daf7d9423f386e25b976fe Mon Sep 17 00:00:00 2001 From: Gabriel Schneider Date: Tue, 29 Sep 2026 20:03:34 -0300 Subject: The GUI monkey lives in test/ and runs as zig build monkey-gui It was a scratch tool (.scratch/render/tools/gui_monkey.py) that the gates rely on and that imported fs.py from a workspace since removed. It is test/gui_monkey.py now, importing from its own directory, and zig build monkey-gui -Dplatform=gui runs it on the built window, the same hidden test windows; it exits 1 when a combination fails, and its outdir is optional. Co-Authored-By: Claude Opus 5.5 --- build.zig | 12 ++++ test/gui_monkey.py | 179 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+) create mode 100644 test/gui_monkey.py (limited to 'build.zig') diff --git a/build.zig b/build.zig index e9e64056..f6081dad 100644 --- a/build.zig +++ b/build.zig @@ -1580,6 +1580,18 @@ pub fn build(b: *std.Build) void { run_monkey9p.setCwd(b.path(".")); run_monkey9p.has_side_effects = true; b.step("monkey-9p", "random 9P operations checking the documented rules (-- [--seed N]... [--steps M] [--replay F] [--shrink F] [--no-shrink])").dependOn(&run_monkey9p.step); + // The GUI's effects under random input in a hidden test window, each + // Motion x Lift x Bloom (test/gui_monkey.py): a gate, not part of + // fs-test, since it needs a Wayland display. + const monkey_gui_step = b.step("monkey-gui", "random input to hidden GUI test windows across the effect settings (-Dplatform=gui; -- [--seed N] [--steps M])"); + if (platform == .gui) { + const run_monkey_gui = b.addSystemCommand(&.{ "python3", "-B", "test/gui_monkey.py" }); + run_monkey_gui.addArtifactArg(exe); + if (b.args) |args| run_monkey_gui.addArgs(args); + run_monkey_gui.setCwd(b.path(".")); + run_monkey_gui.has_side_effects = true; + monkey_gui_step.dependOn(&run_monkey_gui.step); + } else monkey_gui_step.dependOn(&b.addFail("monkey-gui needs -Dplatform=gui").step); const run_monkey9p_smoke = b.addSystemCommand(&.{ "python3", "-B", "test/monkey9p.py" }); run_monkey9p_smoke.addArtifactArg(exe); run_monkey9p_smoke.addArg("--smoke"); diff --git a/test/gui_monkey.py b/test/gui_monkey.py new file mode 100644 index 00000000..2b32f050 --- /dev/null +++ b/test/gui_monkey.py @@ -0,0 +1,179 @@ +#!/usr/bin/env python3 +"""A monkey over the GUI's effects: for each Motion x Lift x Bloom, a hidden +test window gets a random burst of clicks, drags, wheels, keys, new and +joined columns, opened files and theme changes, then must settle (its +frames stop changing) with no crash record and its process alive. + +gui_monkey.py [outdir] [--seed N] [--steps N]; `zig build +monkey-gui -Dplatform=gui -- [--seed N] [--steps N]` runs it on the built +window. Exits 1 when any combination failed; what failed is in outdir. +""" +import argparse +import hashlib +import itertools +import os +import random +import shutil +import sys +import tempfile +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parent)) +from fs import session # noqa: E402 + +MOTIONS = ['off', 'crisp', 'smooth', 'bouncy', 'playful'] +LIFTS = ['off', 'shadow', 'rim', 'auto'] +BLOOMS = ['0', '2'] +THEMES = ['lapis', 'forge', 'acme', 'dusk', 'daybreak'] +W, H = 1280, 840 + + +def settled(cap, timeout=20): + latest = cap / 'latest.ppm' + previous = None + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + time.sleep(.4) + try: + data = latest.read_bytes() + except FileNotFoundError: + continue + digest = hashlib.sha256(data).hexdigest() + if digest == previous: + return True + previous = digest + return False + + +def run(binary, out, motion, lift, bloom, seed, steps): + rng = random.Random(seed) + root = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-')) + cap = root / 'cap' + cap.mkdir() + (root / 'config/pardes').mkdir(parents=True) + (root / 'config/pardes/init').write_text( + f'Shell /bin/sh\nMessageLinger 0\nMotion {motion}\nLift {lift}\nBloom {bloom}\nPanelSlide\nSelectionGlow on\nHoverGlow on\n') + src = root / 'a.zig' + src.write_text(''.join(f'pub fn f{n}(x: u32) u32 {{ return x * {n}; }} // a line\n' for n in range(200))) + for name in ('b.txt', 'c.txt'): + (root / name).write_text(''.join(f'{name} line {n} with some words\n' for n in range(80))) + display = Path(os.environ.get('XDG_RUNTIME_DIR', '/run/user/%d' % os.getuid())) / os.environ.get('WAYLAND_DISPLAY', 'wayland-0') + env = {'PARDES_TEST': '1', 'PARDES_TEST_CLOCK': '1', 'PARDES_TEST_COLS': '80', 'PARDES_TEST_ROWS': '28', 'PARDES_TEST_PAD': '0', + 'PARDES_TEST_CAPTURE_DIR': str(cap), 'SDL_VIDEODRIVER': 'wayland', 'WAYLAND_DISPLAY': str(display)} + name = 'monkey' + launch = ['-c', 'stty cols 80 rows 28; exec "$@"', name, str(binary.resolve()), '--9p=' + name, str(src)] + handles = {} + verdict = 'ok' + log = [] + try: + with session('/bin/sh', root, name, socket_name=name, tty=True, launch=launch, inherited=env, terminal=handles) as (client, _): + fd = handles['input_fd'] + time.sleep(1.0) + + def mouse(kind, button, x, y): + os.write(fd, f'\x1b]777;mouse;{kind};{button};{x};{y}\x07'.encode()) + + def first(): + rows = client.read('/index').decode().splitlines() + return int(rows[0].split()[0]) if rows else None + + for _ in range(steps): + x, y = rng.randrange(W), rng.randrange(H) + action = rng.randrange(12) + try: + if action in (0, 1): + mouse('down', 1, x, y); mouse('up', 1, x, y); log.append(f'click {x},{y}') + elif action == 2: + x2, y2 = rng.randrange(W), rng.randrange(H) + mouse('down', 1, x, y) + for i in range(1, 6): + mouse('motion', 1, x + (x2 - x) * i // 5, y + (y2 - y) * i // 5) + mouse('up', 1, x2, y2); log.append(f'drag {x},{y}->{x2},{y2}') + elif action == 3: + amount = rng.choice([-6, -2, 2, 6]) + os.write(fd, f'\x1b]777;mouse;wheel;{amount};{x};{y}\x07'.encode()); log.append(f'wheel {amount}') + elif action == 4: + mouse('motion', 0, x, y); log.append(f'hover {x},{y}') + elif action == 5: + client.write('/ctl', b'Newcol\n'); log.append('Newcol') + elif action == 6: + serial = first() + if serial is not None: + target = root / rng.choice(['b.txt', 'c.txt', 'a.zig']) + client.write(f'/pane/{serial}/look', f'{target}\n'.encode()); log.append(f'look {target.name}') + elif action == 7: + serial = first() + if serial is not None: + # Delcol too: the last column's going leaves an + # empty window now (acme's), not a quit. + word = rng.choice(['Joincol', 'Collapse', 'Delcol']) + try: + client.write(f'/pane/{serial}/exec', (word + '\n').encode()) + except OSError: + pass + log.append(word) + else: + # An empty window: pane/new makes its column and a pane. + try: + client.read('/pane/new') + except OSError: + pass + log.append('pane/new (empty window)') + elif action == 8: + os.write(fd, rng.choice([b'j', b'k', b'10j', b'gg', b'w', b'%', b'v5j', b'\x1b'])) + log.append('keys') + elif action == 9: + theme = rng.choice(THEMES) + client.write('/ctl', f'Theme {theme}\n'.encode()); log.append(f'Theme {theme}') + elif action == 10: + mouse('down', 3, x, y); mouse('up', 3, x, y); log.append(f'look-click {x},{y}') + else: + time.sleep(rng.choice([0, .05, .2])); log.append('pause') + except OSError as why: + log.append(f'refused: {why}') + time.sleep(.03) + if not settled(cap): + verdict = 'never settled' + try: + os.kill(handles['pid'], 0) + except OSError: + verdict = 'process gone' + client.read('/index') + except Exception as why: # noqa: BLE001 + verdict = f'failed: {type(why).__name__}: {str(why)[:300]}' + crashes = list((root / 'config').rglob('*crash*')) + if crashes: + verdict = 'crash record: ' + ', '.join(str(c) for c in crashes) + label = f'{motion}-{lift}-bloom{bloom}' + if verdict != 'ok': + (out / f'{label}.log').write_text('\n'.join(log) + '\n' + verdict + '\n') + for c in crashes: + shutil.copy(c, out / f'{label}-{c.name}') + if (cap / 'latest.ppm').exists(): + shutil.copy(cap / 'latest.ppm', out / f'{label}-last.ppm') + shutil.rmtree(root, ignore_errors=True) + return label, verdict + + +def main(): + ap = argparse.ArgumentParser() + ap.add_argument('binary', type=Path) + ap.add_argument('out', type=Path, nargs='?') + ap.add_argument('--seed', type=int, default=1) + ap.add_argument('--steps', type=int, default=60) + args = ap.parse_args() + if args.out is None: + args.out = Path(tempfile.mkdtemp(prefix='pardes-gui-monkey-out-')) + args.out.mkdir(parents=True, exist_ok=True) + bad = 0 + for n, (motion, lift, bloom) in enumerate(itertools.product(MOTIONS, LIFTS, BLOOMS)): + label, verdict = run(args.binary, args.out, motion, lift, bloom, args.seed * 1000 + n, args.steps) + print(f'{label}: {verdict}', flush=True) + bad += verdict != 'ok' + print(f'{bad} of {len(MOTIONS) * len(LIFTS) * len(BLOOMS)} combinations failed; logs in {args.out}', flush=True) + sys.exit(1 if bad else 0) + + +if __name__ == '__main__': + main() -- cgit v1.3