summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
-rw-r--r--build.zig70
-rw-r--r--src/9p.zig2
-rw-r--r--src/ninep/tree.zig49
-rw-r--r--src/pardes.zig9
-rw-r--r--src/tty/tty.zig280
-rw-r--r--test/e2e_harness.zig28
-rw-r--r--test/fs.py14
-rw-r--r--test/gui_monkey.py179
-rw-r--r--test/monkey9p.py16
9 files changed, 545 insertions, 102 deletions
diff --git a/build.zig b/build.zig
index e9e64056..e549a53e 100644
--- a/build.zig
+++ b/build.zig
@@ -864,11 +864,65 @@ 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");
+ // 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,
+ \\/// 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");
}
@@ -1580,6 +1634,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/src/9p.zig b/src/9p.zig
index 5ffe9058..837525cb 100644
--- a/src/9p.zig
+++ b/src/9p.zig
@@ -53,7 +53,7 @@ pub const msize_min = cloud9.fs.msize_min;
/// The smallest msize a native listener offers (src/9p_io.zig).
pub const min_msize: u32 = 4096;
/// What a native listener negotiates.
-pub const msize: u32 = 8192;
+pub const msize: u32 = 65536;
/// The editor's engine: native fid count, native file names, and room to
/// park a write of any size a frame can carry -- a request that would change
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index f6cb115a..5f3ffc9c 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -1628,6 +1628,55 @@ test "editor paths resolve to the same nodes the wire serves" {
try testing.expectEqual(before, p.next_serial);
}
+test "random placements, refused or not, leave no pane under its tag and two rows" {
+ var tmp = testing.tmpDir(.{});
+ defer tmp.cleanup();
+ try tmp.dir.writeFile(testing.io, .{ .sub_path = "f.txt", .data = "one\ntwo\n" });
+ var dir_buf: [4096]u8 = undefined;
+ const dir = dir_buf[0..try tmp.dir.realPath(testing.io, &dir_buf)];
+ var look_line: [4200]u8 = undefined;
+ const looked = try std.fmt.bufPrint(&look_line, "{s}/f.txt\n", .{dir});
+ var seed: u64 = 0;
+ while (seed < 300) : (seed += 1) {
+ var prng = std.Random.DefaultPrng.init(seed);
+ const r = prng.random();
+ const rows = r.intRangeAtMost(u16, 6, 40);
+ const p = try pardes.Pardes.init(testing.allocator, .{ .tty_only = true, .cols = r.intRangeAtMost(u16, 60, 240), .rows = rows });
+ defer p.deinit();
+ while (p.nextEffect()) |_| {}
+ _ = try p.setTestFile("x\n");
+ while (p.nextEffect()) |_| {}
+ for (0..r.uintLessThan(usize, 3)) |_| _ = th.wr(p, Node.of(serialOf(p), .exec), "Newcol\n");
+ if (r.boolean()) p.settings.placement = .pardes;
+ for (0..40) |step| {
+ var serials: [pardes.MAX_PANES]u32 = undefined;
+ var n: usize = 0;
+ for (p.panes) |slot| if (slot) |pn| {
+ serials[n] = pn.serial;
+ n += 1;
+ };
+ if (n == 0) break;
+ const at = serials[r.uintLessThan(usize, n)];
+ const action = r.uintLessThan(u8, 8);
+ switch (action) {
+ 0 => _ = th.wr(p, Node.of(at, .exec), "New\n"),
+ 1 => _ = call(p, .{ .tag = 1, .op = .open, .node = @intFromEnum(TopFile.new) }),
+ 2 => _ = th.wr(p, Node.of(at, .exec), "Newcol\n"),
+ 3 => _ = th.wr(p, Node.of(at, .exec), "Edit =\n"),
+ 4 => _ = th.wr(p, Node.of(at, .exec), "Tty\n"),
+ 5 => _ = th.wr(p, Node.of(at, .look), looked),
+ 6 => _ = th.wr(p, Node.of(at, .exec), "Delcol\n"),
+ else => _ = th.wr(p, Node.of(at, .ctl), "delete\n"),
+ }
+ for (0..p.ncol) |c| if (!layout.columnAtMinimums(p, c)) {
+ std.debug.print("seed {d} rows {d} step {d} action {d}: column {d} under its minimums\n", .{ seed, rows, step, action, c });
+ for (p.col_panes[c][0..p.col_n[c]]) |id| std.debug.print(" pane {d} h {d} min {d}\n", .{ id, p.rects[id].h, layout.minRows(p, id) });
+ return error.TestUnexpectedResult;
+ };
+ }
+ }
+}
+
test "at the pane cap, pane/new, look and New each say so, and look reads back empty" {
const p = try th.withFile(testing.allocator, "x\n");
defer p.deinit();
diff --git a/src/pardes.zig b/src/pardes.zig
index 19fe51d1..22d5f7a1 100644
--- a/src/pardes.zig
+++ b/src/pardes.zig
@@ -5082,6 +5082,15 @@ pub const Pardes = struct {
else
"Newcol: no space for a column: this one is too narrow to split");
};
+ // A narrower column wraps long tags onto more rows, which can leave
+ // a pane under its tag and two rows: its column's rows are shared
+ // out again, and where they cannot hold every minimum there is no
+ // new column, as a size too small is refused.
+ for (0..p.ncol) |k| if (!layout.columnAtMinimums(p, k) and !layout.shareColumn(p, k)) {
+ layout.dropColumn(p, c);
+ layout.compute(p);
+ return p.reportFailure(from_id, "Newcol: no space for a column: the panes' tags would not fit");
+ };
tagline.enterHeader(p, c);
}
diff --git a/src/tty/tty.zig b/src/tty/tty.zig
index efccd98e..f5ae078a 100644
--- a/src/tty/tty.zig
+++ b/src/tty/tty.zig
@@ -132,7 +132,14 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
if (carried == buf.len) return error.InputSequenceTooLong;
const received = try tty.read(buf[carried..]);
if (received == 0) return;
- const end = carried + received;
+ // The terminal's answers to frames (FrameAck) come out first,
+ // wherever they sit: never events, and never glued to a key (an
+ // Escape pressed just before one would read as Alt on it).
+ const end = FrameAck.take(buf[0 .. carried + received], loop);
+ if (end == 0) {
+ carried = 0;
+ continue;
+ }
if (kitty_shm_probing.load(.acquire)) if (std.mem.indexOf(u8, buf[0..end], std.fmt.comptimePrint("\x1b_Gi={d};", .{kitty_shm_probe_id}))) |at| {
kitty_shm_probing.store(false, .release);
kitty_shm.store(std.mem.startsWith(u8, buf[at..end], std.fmt.comptimePrint("\x1b_Gi={d};OK", .{kitty_shm_probe_id})), .release);
@@ -153,7 +160,8 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
// its writes whole, so no sequence arrives cut right after its ESC.
// Waiting on it cost every Escape press 50 ms on a legacy terminal
// (tmux, most ssh sessions), and 1 s before kitty's flags were on.
- var waited = carried == 0 and received == 1;
+ // (A terminal's answer to a frame is one write too: FrameAck.)
+ var waited = carried == 0 and end == 1;
while (consumed < parse_end) {
// A lone ESC that ends a longer read is the Escape key, or the
// first byte of a sequence whose rest the read boundary held
@@ -162,6 +170,9 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
// kitty keyboard protocol Escape is `CSI 27 u`, so there the ESC
// can only start a sequence and the wait can be long.
const rest = buf[consumed..parse_end];
+ // A frame's answer cut short by the read waits for its rest.
+ if (parse_end == end and rest.len > 1 and rest.len < FrameAck.answer.len and
+ std.mem.startsWith(u8, FrameAck.answer, rest)) break;
if (!waited and parse_end == end and rest[0] == 0x1b and (rest.len == 1 or (rest.len == 2 and rest[1] == 0x1b))) {
waited = true;
if (try inputFollows(tty, loop.io, if (loop.vaxis.caps.kitty_keyboard) 1000 else 50)) break;
@@ -191,6 +202,63 @@ fn readInput(loop: *Loop, tty: anytype, cache: *vaxis.GraphemeCache) !void {
}
}
+/// One frame in flight at most. Each frame the tty draws ends with a device
+/// status request (`CSI 5 n`), and no other frame is drawn until the
+/// terminal's `CSI 0 n` comes back: over ssh the frames a burst of scrolling
+/// or typing would queue behind each other (sshd's window holds megabytes)
+/// are never drawn, and the burst ends one frame and one round trip after
+/// its last input. On a local terminal the answer is back in well under a
+/// millisecond. A terminal that never answers turns this off for good after
+/// `timeout_ns`.
+const FrameAck = struct {
+ const request = "\x1b[5n";
+ const answer = "\x1b[0n";
+ const timeout_ns: u64 = std.time.ns_per_s;
+ var waiting = std.atomic.Value(bool).init(false);
+ var off = std.atomic.Value(bool).init(false);
+ /// When the request went out (the loop's thread only).
+ var sent_ns: u64 = 0;
+
+ /// Take every answer out of `bytes`, closing up the rest; the new
+ /// length. The loop is woken for the frame that waited on one.
+ fn take(bytes: []u8, loop: *Loop) usize {
+ var len = bytes.len;
+ var found = false;
+ while (std.mem.indexOf(u8, bytes[0..len], answer)) |at| {
+ std.mem.copyForwards(u8, bytes[at..], bytes[at + answer.len .. len]);
+ len -= answer.len;
+ found = true;
+ }
+ if (found and waiting.swap(false, .acq_rel)) loop.postEvent(.nop) catch {};
+ return len;
+ }
+
+ /// Whether a new frame must wait (and the loop has a wake at the
+ /// timeout); past the timeout the terminal is taken to never answer.
+ fn holds(now_ns: u64, io: std.Io) bool {
+ if (off.load(.acquire) or !waiting.load(.acquire)) return false;
+ const waited_ns = now_ns -| sent_ns;
+ if (waited_ns >= timeout_ns) {
+ off.store(true, .release);
+ waiting.store(false, .release);
+ return false;
+ }
+ requestWake(io, @intCast((timeout_ns - waited_ns + std.time.ns_per_ms - 1) / std.time.ns_per_ms));
+ return true;
+ }
+
+ /// After a frame that wrote something: ask for the answer.
+ fn ask(w: *std.Io.Writer, now_ns: u64) void {
+ if (off.load(.acquire)) return;
+ // Waiting before the request goes: a local terminal can answer
+ // before the write returns, and an answer nobody waited for is lost.
+ sent_ns = now_ns;
+ waiting.store(true, .release);
+ w.writeAll(request) catch return waiting.store(false, .release);
+ w.flush() catch return waiting.store(false, .release);
+ }
+};
+
/// Whether more input is readable within `ms`, polled in short slices so
/// that a cancel of the input thread is still seen. A test reader answers
/// for itself.
@@ -521,6 +589,98 @@ test "an ESC read by itself is the Escape key at once" {
vx.caps.kitty_keyboard = false;
}
+test "a frame's answer is taken wherever the reads cut it, and never typed" {
+ if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
+ const gpa = std.testing.allocator;
+ const io = std.testing.io;
+ var env = try std.testing.environ.createMap(gpa);
+ defer env.deinit();
+ var vx = try vaxis.init(io, gpa, &env, .{});
+ var output: std.Io.Writer.Allocating = .init(gpa);
+ defer output.deinit();
+ defer vx.deinit(gpa, &output.writer);
+ var tty: vaxis.Tty = undefined;
+ var loop: Loop = .init(io, &tty, &vx);
+ var cache: vaxis.GraphemeCache = .{};
+ const Reader = struct {
+ parts: [2][]const u8,
+ next: usize = 0,
+ fn getWinsize(_: *@This()) !vaxis.Winsize {
+ return .{ .rows = 24, .cols = 80, .x_pixel = 0, .y_pixel = 0 };
+ }
+ fn inputFollows(self: *@This(), _: u32) bool {
+ return self.next < self.parts.len;
+ }
+ fn read(self: *@This(), buf: []u8) !usize {
+ while (self.next < self.parts.len) {
+ const part = self.parts[self.next];
+ self.next += 1;
+ if (part.len == 0) continue;
+ @memcpy(buf[0..part.len], part);
+ return part.len;
+ }
+ return 0;
+ }
+ };
+ const all = "x" ++ FrameAck.answer ++ "y";
+ defer FrameAck.waiting.store(false, .release);
+ for (0..all.len) |cut| {
+ FrameAck.waiting.store(true, .release);
+ var reader: Reader = .{ .parts = .{ all[0..cut], all[cut..] } };
+ try readInput(&loop, &reader, &cache);
+ try std.testing.expect(!FrameAck.waiting.load(.acquire));
+ var typed: std.ArrayList(u8) = .empty;
+ defer typed.deinit(gpa);
+ var woke = false;
+ while (try loop.tryEvent()) |event| switch (event) {
+ .winsize => {},
+ .nop => woke = true,
+ .key_press => |key| try typed.appendSlice(gpa, key.text orelse return error.UnexpectedInputKey),
+ else => return error.UnexpectedInputEvent,
+ };
+ std.testing.expectEqualStrings("xy", typed.items) catch |err| {
+ std.debug.print("cut at {d}\n", .{cut});
+ return err;
+ };
+ try std.testing.expect(woke);
+ }
+ // An Escape pressed just before an answer, in the same read, is the
+ // Escape key, not Alt on the answer.
+ FrameAck.waiting.store(true, .release);
+ var glued: Reader = .{ .parts = .{ "\x1b" ++ FrameAck.answer, "j" } };
+ try readInput(&loop, &glued, &cache);
+ try std.testing.expectEqual(.winsize, std.meta.activeTag((try loop.tryEvent()).?));
+ try std.testing.expectEqual(.nop, std.meta.activeTag((try loop.tryEvent()).?));
+ const escape = (try loop.tryEvent()).?.key_press;
+ try std.testing.expectEqual(vaxis.Key.escape, escape.codepoint);
+ try std.testing.expect(!escape.mods.alt);
+ try std.testing.expectEqual(@as(u21, 'j'), (try loop.tryEvent()).?.key_press.codepoint);
+ try std.testing.expect(try loop.tryEvent() == null);
+}
+
+test "a terminal that never answers a frame stops being asked after one timeout" {
+ const io = std.testing.io;
+ defer {
+ FrameAck.off.store(false, .release);
+ FrameAck.waiting.store(false, .release);
+ FrameAck.sent_ns = 0;
+ }
+ var out: std.Io.Writer.Allocating = .init(std.testing.allocator);
+ defer out.deinit();
+ const t0: u64 = 5 * std.time.ns_per_s;
+ FrameAck.ask(&out.writer, t0);
+ try std.testing.expectEqualStrings(FrameAck.request, out.written());
+ // Unanswered: the next frame waits, until the timeout.
+ try std.testing.expect(FrameAck.holds(t0 + 10 * std.time.ns_per_ms, io));
+ try std.testing.expect(FrameAck.holds(t0 + 999 * std.time.ns_per_ms, io));
+ try std.testing.expect(!FrameAck.holds(t0 + FrameAck.timeout_ns, io));
+ // Then never again: no request, no wait.
+ out.clearRetainingCapacity();
+ FrameAck.ask(&out.writer, t0 + 2 * std.time.ns_per_s);
+ try std.testing.expectEqual(@as(usize, 0), out.written().len);
+ try std.testing.expect(!FrameAck.holds(t0 + 2 * std.time.ns_per_s, io));
+}
+
test "terminal input cancellation joins blocked reads and queued EOF" {
if (comptime builtin.os.tag == .windows) return error.SkipZigTest;
const gpa = std.testing.allocator;
@@ -1438,6 +1598,12 @@ const Shell = struct {
const s = of(ctx);
const vx = s.vx;
s.tracks = canonical.panelTracks();
+ // The last frame is not answered yet: this one stays owed, and the
+ // answer's wake draws the state as it is then.
+ if (FrameAck.holds(host_io.monotonicNs(), s.io)) {
+ s.core.present_skipped = true;
+ return;
+ }
_ = s.frame.reset(.retain_capacity);
const surface = panel_compositor.compose(
s.frame.allocator(),
@@ -1453,7 +1619,7 @@ const Shell = struct {
const shadowed = chipShadows(s.frame.allocator(), surface, s.core, truecolor) catch surface;
const flashed = s.flash.apply(s.frame.allocator(), shadowed, s.core, truecolor) catch shadowed;
const trailed = s.trail.apply(s.frame.allocator(), flashed, s.core, truecolor) catch flashed;
- paintCells(vx, win, trailed.cells, trailed.cols, trailed.rows);
+ paintCells(win, trailed.cells, trailed.cols, trailed.rows);
tz_cells.end();
for (surface.images[0..surface.nimages]) |maybe| {
const place = maybe orelse continue;
@@ -1523,7 +1689,9 @@ const Shell = struct {
}
if (surface.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar);
const tz_render = tracy.zone(@src(), "vx.render");
+ const before = s.tty.tty_writer.pos + s.tty.writer().end;
vx.render(s.tty.writer()) catch {};
+ if (s.tty.tty_writer.pos + s.tty.writer().end != before) FrameAck.ask(s.tty.writer(), host_io.monotonicNs());
tz_render.end();
}
@@ -2170,94 +2338,26 @@ fn mouseEvent(m: vaxis.Mouse) ?pardes.Event {
} };
}
-fn paintCells(vx: *const vaxis.Vaxis, win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void {
+/// A blank whose ink alone changed is not sent: vaxis's render skips it
+/// (pardes's patch to Vaxis.zig in build.zig), since a blank's ink shows
+/// nowhere. Deciding that here cost every cell of every frame a look at the
+/// frame before; vaxis already makes that comparison.
+fn paintCells(win: vaxis.Window, cells: []const pardes.Cell, cols: u16, rows: u16) void {
win.clear();
var y: u16 = 0;
while (y < rows) : (y += 1) {
- const row = cells[@as(usize, y) * cols ..][0..cols];
var x: u16 = 0;
- // The cell to the left as written, for a blank that needs it.
- var left: ?Written = null;
while (x < cols) : (x += 1) {
- const cell = &row[x];
- if (cell.default) {
- left = null;
- continue;
- }
- const grapheme = cell.grapheme();
- var style = vaxisStyle(cell.style);
- if (inkless(grapheme, style)) blankInk(vx, row, x, y, &style, left);
- left = .{ .grapheme = grapheme, .style = style };
- win.writeCell(x, y, .{ .char = .{ .grapheme = grapheme }, .style = style });
+ const cell = &cells[@as(usize, y) * cols + x];
+ if (cell.default) continue;
+ win.writeCell(x, y, .{
+ .char = .{ .grapheme = cell.grapheme() },
+ .style = vaxisStyle(cell.style),
+ });
}
}
}
-const Written = struct { grapheme: []const u8, style: vaxis.Style };
-
-/// A blank's ink shows nowhere (no underline, strike or reverse to show it).
-fn inkless(grapheme: []const u8, style: vaxis.Style) bool {
- return std.mem.eql(u8, grapheme, " ") and !style.reverse and !style.strikethrough and style.ul_style == .off;
-}
-
-/// A blank whose ink alone changed (a fade: InactiveDim on a focus change)
-/// keeps the ink the terminal has there and so is not sent at all:
-/// otherwise every blank of every pane a fade touched went, 7 KB a click
-/// for three shells. Only a short gap between two cells that are sent
-/// anyway is cheaper sent than jumped (a cursor move): it takes the ink of
-/// the cell before it, so the run needs no colour change either.
-fn blankInk(vx: *const vaxis.Vaxis, row: []const pardes.Cell, x: u16, y: u16, style: *vaxis.Style, left: ?Written) void {
- const last = lastCell(vx, x, y) orelse return;
- if (vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, " ")) return;
- if (sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style.*)) return;
- var kept = style.*;
- kept.fg = last.style.fg;
- if (left) |l| if (sends(vx, x - 1, y, l.grapheme, l.style)) {
- var end = x + 1;
- while (end < row.len and end - x < 8) : (end += 1) {
- const next = &row[end];
- if (next.default) break;
- const next_style = vaxisStyle(next.style);
- if (inkless(next.grapheme(), next_style)) continue;
- if (sends(vx, end, y, next.grapheme(), next_style)) {
- style.fg = l.style.fg;
- return;
- }
- break;
- }
- };
- style.* = kept;
-}
-
-/// `Style.eql` but for the ink, and cheap: this runs for every blank of
-/// every frame.
-fn sameButInk(a: vaxis.Style, b: vaxis.Style) bool {
- return sameColor(a.bg, b.bg) and sameColor(a.ul, b.ul) and a.ul_style == b.ul_style and
- a.bold == b.bold and a.dim == b.dim and a.italic == b.italic and a.blink == b.blink and
- a.reverse == b.reverse and a.invisible == b.invisible and a.strikethrough == b.strikethrough;
-}
-
-fn sameColor(a: vaxis.Color, b: vaxis.Color) bool {
- return switch (a) {
- .default => b == .default,
- .index => |i| b == .index and b.index == i,
- .rgb => |rgb| b == .rgb and std.mem.eql(u8, &rgb, &b.rgb),
- };
-}
-
-/// Whether vaxis sends this cell: it differs from what the terminal shows.
-fn sends(vx: *const vaxis.Vaxis, x: u16, y: u16, grapheme: []const u8, style: vaxis.Style) bool {
- const last = lastCell(vx, x, y) orelse return true;
- return vx.refresh or last.default or last.skipped or !std.mem.eql(u8, last.char.items, grapheme) or
- !sameColor(last.style.fg, style.fg) or !sameButInk(last.style, style);
-}
-
-fn lastCell(vx: *const vaxis.Vaxis, x: u16, y: u16) ?*const vaxis.AllocatingScreen.InternalCell {
- if (x >= vx.screen.width) return null;
- const i = @as(usize, y) * vx.screen.width + x;
- return if (i < vx.screen_last.buf.len) &vx.screen_last.buf[i] else null;
-}
-
test "a fade sends the text it dims, not the blanks beside it" {
const gpa = std.testing.allocator;
var env = try std.testing.environ.createMap(gpa);
@@ -2267,8 +2367,8 @@ test "a fade sends the text it dims, not the blanks beside it" {
defer out.deinit();
defer vx.deinit(gpa, &out.writer);
try vx.resize(gpa, &out.writer, .{ .rows = 1, .cols = 32, .x_pixel = 0, .y_pixel = 0 });
- // `a`, a long gap, `b`, a two-cell gap, `c`, a long gap, an underlined
- // blank (which shows its ink) and one more blank.
+ // `a`, a gap, `b`, a two-cell gap, `c`, a gap, an underlined blank
+ // (which shows its ink) and one more blank.
var cells: [32]pardes.Cell = @splat(.{ .default = false });
cells[0].text[0] = 'a';
cells[12].text[0] = 'b';
@@ -2279,16 +2379,16 @@ test "a fade sends the text it dims, not the blanks beside it" {
for ([_][3]u8{ .{ 200, 200, 200 }, .{ 90, 90, 90 } }, 0..) |ink, frame| {
for (&cells) |*cell| cell.style.fg = .{ .rgb = ink };
out.clearRetainingCapacity();
- paintCells(&vx, vx.window(), &cells, 32, 1);
+ paintCells(vx.window(), &cells, 32, 1);
try vx.render(&out.writer);
sent[frame] = try out.toOwnedSlice();
}
// Against a cleared screen every cell goes. After the fade the letters
- // go, the short gap between two of them (cheaper than a cursor move),
- // and the underlined blank; the long gaps are jumped.
+ // go and the underlined blank; every plain blank is jumped.
try std.testing.expectEqual(@as(usize, 29), std.mem.count(u8, sent[0], " "));
- try std.testing.expectEqual(@as(usize, 3), std.mem.count(u8, sent[1], " "));
- try std.testing.expect(std.mem.indexOf(u8, sent[1], "b c") != null);
+ try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, sent[1], " "));
+ try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'b') != null);
+ try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'c') != null);
try std.testing.expect(std.mem.indexOf(u8, sent[1], "90:90:90") != null);
try std.testing.expect(std.mem.indexOfScalar(u8, sent[1], 'a') != null);
}
@@ -2688,7 +2788,7 @@ const Attach = struct {
fn paint(a: *Attach) void {
a.dirty = false;
const win = a.vx.window();
- paintCells(a.vx, win, a.client.grid.items, a.client.cols, a.client.rows);
+ paintCells(win, a.client.grid.items, a.client.cols, a.client.rows);
if (a.client.cursor) |cur| paintCursor(win, cur.x, cur.y, cur.bar);
a.vx.render(a.tty.writer()) catch {};
}
diff --git a/test/e2e_harness.zig b/test/e2e_harness.zig
index eaa19b43..9e67c49f 100644
--- a/test/e2e_harness.zig
+++ b/test/e2e_harness.zig
@@ -64,6 +64,8 @@ pub const Harness = struct {
/// when true, print the captured screen state after each pump/waitFor and on
/// every assertion, so live test runs can be inspected (zig build test -Dtrace).
trace: bool = false,
+ /// How much of a `CSI 5 n` the output has ended on (`feed`).
+ status_request: u8 = 0,
/// every raw byte the app has emitted (accumulated in pump). Lets tests assert
/// on control sequences the emulator consumes and never renders (e.g. OSC 52
/// clipboard writes). Capture is bounded explicitly so a runaway child cannot
@@ -147,6 +149,24 @@ pub const Harness = struct {
/// Read pty output and feed it to our ghostty terminal for `ms` ms. After
/// this, the grid reflects everything the app rendered so far.
+ /// The app's output into the emulator. The one query answered is the
+ /// device status request (`CSI 5 n` → `CSI 0 n`), as every terminal
+ /// does: the tty asks it after each frame and draws no other until the
+ /// answer (FrameAck in src/tty/tty.zig).
+ fn feed(self: *Harness, bytes: []const u8) void {
+ self.stream.nextSlice(bytes);
+ const request = "\x1b[5n";
+ for (bytes) |b| {
+ if (b == request[self.status_request]) {
+ self.status_request += 1;
+ } else self.status_request = if (b == 0x1b) 1 else 0;
+ if (self.status_request == request.len) {
+ self.status_request = 0;
+ _ = libc.write(self.master, "\x1b[0n", 4);
+ }
+ }
+ }
+
pub fn pump(self: *Harness, ms: i64) !void {
const deadline = nowMs() + ms;
var buf: [4096]u8 = undefined;
@@ -157,7 +177,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
}
}
if (self.trace) self.traceScreen("pump");
@@ -176,7 +196,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch return false;
if (n == 0) return false;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
return true;
}
@@ -243,7 +263,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
}
const text = try self.screenText();
defer self.gpa.free(text);
@@ -270,7 +290,7 @@ pub const Harness = struct {
const n = posix.read(self.master, &buf) catch break;
if (n == 0) break;
self.recordRaw(buf[0..n]);
- self.stream.nextSlice(buf[0..n]);
+ self.feed(buf[0..n]);
if (std.mem.indexOf(u8, self.raw.items, needle) != null) return true;
}
return false;
diff --git a/test/fs.py b/test/fs.py
index 15f4eb8b..36ba180d 100644
--- a/test/fs.py
+++ b/test/fs.py
@@ -953,6 +953,20 @@ def test(binary, quic=False):
with Client(address, msize=256) as small:
assert set(small.list('/os' + str(listing))) == {
f'entry-{number:02}' for number in range(24)}
+ # 64 KiB frames: a ctl line with no newline, whole in its one
+ # Twrite, runs with it however long -- the cutoff for a write
+ # that may go on is the frame the client asked for, less 24.
+ with Client(address, msize=65536) as big:
+ assert big.msize == 65536
+ ctl = big.open('/pane/1/ctl', 1)
+ line = b'bogus' + b'x' * 20000
+ try:
+ big.rpc(118, struct.pack('<IQI', ctl, 0, len(line)) + line)
+ except OSError:
+ pass # refused with its write (a line over 1024 bytes), not held
+ else:
+ raise AssertionError('a whole 20000-byte line waited for a newline')
+ big.close(ctl)
frozen = client.open('/screen')
before = bytearray(client.read_fid(frozen, count=31))
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 <pardes-gui> [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()
diff --git a/test/monkey9p.py b/test/monkey9p.py
index c0e99fb8..ee0b0ebc 100644
--- a/test/monkey9p.py
+++ b/test/monkey9p.py
@@ -641,11 +641,11 @@ def op_write(sess, op):
ok, body = w.call(TOPEN, struct.pack('<IB', fid, mode), timeout)
res.add(open_kind(path, mode), path, ok, None if ok else body)
sizes = chunking(op.get('chunks', 'whole'), len(data), w.msize - 23)
- allowances(res, path, data, sizes, w.msize - 24)
opened = ok
sent = data
if ok:
offset = 0
+ took = []
for size in sizes:
piece = data[offset:offset + size]
ok, body = w.call(TWRITE, struct.pack('<IQI', fid, op.get('offset', offset), len(piece)) + piece, timeout)
@@ -654,8 +654,13 @@ def op_write(sess, op):
res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
offset += size
sent = data[:offset]
+ if ok:
+ took.append(size)
if not ok and op.get('stop_on_error', True):
break
+ # Misses are allowed the Twrites that succeeded: a line written a
+ # byte a Twrite is a look a byte, each its own write.
+ allowances(res, path, data[:sum(took)], took, w.msize - 24)
ok, body = w.call(TCLUNK, struct.pack('<I', fid), timeout)
res.add('clunk-write' if mode & 3 else 'clunk', path, ok, None if ok else body)
# What the open holds when it closes: the last line written, if unended.
@@ -850,11 +855,12 @@ def op_hwrite(sess, op):
fid, path, mode, off, _ = h
data = dec(op['data'])[:sess.wire.msize - 23] # one Twrite
h[4] = data
- allowances(res, path, data, [len(data)], sess.wire.msize - 24)
ok, body = sess.wire.call(TWRITE, struct.pack('<IQI', fid, off[0], len(data)) + data,
SLOW_TIMEOUT if slow(data) else TIMEOUT)
res.add('write' if mode & 3 else 'write-badfid', path, ok, None if ok else body)
if ok:
+ allowances(res, path, data, [len(data)], sess.wire.msize - 24)
+ if ok:
off[0] += len(data)
return res
@@ -1498,9 +1504,9 @@ def one_failure_rule(ctx, res):
and w[1].rstrip('/') != '/pane/new']
errs = occurrences(res.window, 'err')
msgs = occurrences(res.window, 'msg')
- # Only for a write that succeeded: a failed one logs its one err.
- failed_paths = {w[1] for w in failed}
- allowed = sum(r[3] for r in res.requests if r[0] == 'allow' and r[1] not in failed_paths)
+ # Allowed only the Twrites that succeeded (allowances): a failed one
+ # logs its one err.
+ allowed = sum(r[3] for r in res.requests if r[0] == 'allow')
# fs.md 'A write of command lines ... what is left when it closes runs at
# the close ... and its failure is in the log alone'.
closes = [r for r in res.requests if r[0] == 'close-runs']