summaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Messages.zig14
-rw-r--r--src/builtins.zig3
-rw-r--r--src/dump.zig46
-rw-r--r--src/main.zig38
-rw-r--r--src/ninep/events.zig6
-rw-r--r--src/ninep/pane.zig6
-rw-r--r--src/ninep/tree.zig50
-rw-r--r--src/tty/tty.zig280
8 files changed, 331 insertions, 112 deletions
diff --git a/src/Messages.zig b/src/Messages.zig
index 7342f726..42200863 100644
--- a/src/Messages.zig
+++ b/src/Messages.zig
@@ -503,18 +503,14 @@ pub fn clip(text: []const u8, max: usize) []const u8 {
/// reportError with the words already chosen.
pub fn reportFailure(p: *Pardes, id: usize, text: []const u8) void {
p.fs.failures +%= 1;
- // A builtin a ctl write runs: its first error is also the write's, cut
- // between words.
+ // A builtin a ctl write runs: its first error is also the write's, its
+ // path shortened in the middle if it must be, never its reason.
const failing_write = p.fs.no_prompt or p.fs.capturing or p.fs.write_waits;
if (failing_write and p.fs.failure_len == 0) {
- const kept = if (text.len > p.fs.failure.len) clip(text, p.fs.failure.len - 3) else text;
+ var room: @TypeOf(p.fs.failure) = undefined;
+ const kept = pardes.ctlfs.fitErr(text, &room);
@memcpy(p.fs.failure[0..kept.len], kept);
- var n = kept.len;
- if (kept.len < text.len) {
- @memcpy(p.fs.failure[n..][0..3], "...");
- n += 3;
- }
- p.fs.failure_len = @intCast(n);
+ p.fs.failure_len = @intCast(kept.len);
}
// The write fails with it, and its err record says it: no msg for it,
// so the same failure again is the same record again, counted
diff --git a/src/builtins.zig b/src/builtins.zig
index e47afeb4..8a01b3d6 100644
--- a/src/builtins.zig
+++ b/src/builtins.zig
@@ -574,7 +574,8 @@ fn warnModifiedIn(c: Ctx, asking: Pane.Discarding, which: Asked) bool {
std.fmt.bufPrint(&said_buf, "{s}: Modified ({s} again to discard)", .{ one, @tagName(asking) })
else
std.fmt.bufPrint(&said_buf, "{d} unsaved panes: Modified ({s} again to discard)", .{ count, @tagName(asking) })) catch "unsaved panes: Modified";
- const kept = @import("Messages.zig").clip(said, c.p.fs.failure.len);
+ var room: @TypeOf(c.p.fs.failure) = undefined;
+ const kept = pardes.ctlfs.fitErr(said, &room);
@memcpy(c.p.fs.failure[0..kept.len], kept);
c.p.fs.failure_len = @intCast(kept.len);
}
diff --git a/src/dump.zig b/src/dump.zig
index fca88a2d..a2efb95d 100644
--- a/src/dump.zig
+++ b/src/dump.zig
@@ -31,7 +31,8 @@ const Pardes = pardes.Pardes;
/// Where the next dump goes. $PARDES_DUMP wins verbatim (the snapshot harness
/// pins it for deterministic goldens); else $XDG_DATA_HOME|~/.local/share
/// /pardes/pardes-<utc>.zon — timestamped so dumps never overwrite each other.
-/// Creates the pardes dir (parents assumed; a failure surfaces at open).
+/// Creates the directory and any parent missing (a fresh HOME has no
+/// ~/.local/share); a failure surfaces at open.
pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 {
if (std.c.getenv("PARDES_DUMP")) |p|
return std.fmt.bufPrintSentinel(buf, "{s}", .{std.mem.span(p)}, 0) catch null;
@@ -40,7 +41,13 @@ pub fn outPath(buf: *[1024:0]u8, dir_setting: []const u8) ?[:0]const u8 {
var dz: [901:0]u8 = undefined;
@memcpy(dz[0..dir.len], dir);
dz[dir.len] = 0;
- _ = std.c.mkdir(dz[0..dir.len :0], 0o755); // EEXIST is fine
+ // Each missing directory on the way, as `mkdir -p`: EEXIST is fine.
+ for (dz[1..dir.len], 1..) |c, i| if (c == '/') {
+ dz[i] = 0;
+ _ = std.c.mkdir(dz[0..i :0], 0o755);
+ dz[i] = '/';
+ };
+ _ = std.c.mkdir(dz[0..dir.len :0], 0o755);
var ts: std.c.timespec = undefined;
_ = std.c.clock_gettime(.REALTIME, &ts);
const es: std.time.epoch.EpochSeconds = .{ .secs = @intCast(@max(0, ts.sec)) };
@@ -109,6 +116,21 @@ pub fn directory(buf: []u8, dir_setting: []const u8) ?[]const u8 {
return std.fmt.bufPrint(buf, "{s}", .{resolved}) catch null;
}
+test "a dump makes every directory missing on the way to its DumpDir" {
+ if (std.c.getenv("PARDES_DUMP") != null) return error.SkipZigTest;
+ var tmp = std.testing.tmpDir(.{});
+ defer tmp.cleanup();
+ var at: [4096]u8 = undefined;
+ const base = at[0..try tmp.dir.realPath(std.testing.io, &at)];
+ var setting: [4200]u8 = undefined;
+ const dir = try std.fmt.bufPrint(&setting, "{s}/fresh/.local/share/pardes", .{base});
+ var out: [1024:0]u8 = undefined;
+ const path = outPath(&out, dir).?;
+ try std.testing.expect(std.mem.startsWith(u8, path, dir));
+ var made = try tmp.dir.openDir(std.testing.io, "fresh/.local/share/pardes", .{});
+ made.close(std.testing.io);
+}
+
test "a DumpDir reads back absolute, however it was written" {
var buf: [4096]u8 = undefined;
try std.testing.expectEqualStrings("/tmp/dumps", directory(&buf, "/tmp/dumps/").?);
@@ -129,7 +151,7 @@ pub fn defaultDirectory(buf: []u8) ?[]const u8 {
pub const magic = "pardes-dump";
pub const version: u32 = 1;
pub const max_panes: usize = MAX_PANES;
-pub const max_cols: usize = 6;
+pub const max_cols: usize = MAX_COLS;
/// Output arguments are typed in the same bounded one-line tag storage. Keep
/// the schema limit named independently so a dump reader can validate it
/// without importing the output-pane implementation. The bound itself is
@@ -987,6 +1009,24 @@ test "a dump keeps the settings that differ from a fresh session's, and a restor
try std.testing.expect(!restored.settings.verbose);
}
+test "a session with every column it may hold dumps and restores them all" {
+ const gpa = std.testing.allocator;
+ const p = try Pardes.init(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 });
+ defer p.deinit();
+ while (p.ncol < MAX_COLS) {
+ layout.compute(p);
+ var widest: usize = 0;
+ for (0..p.ncol) |c| if (p.col_w[c] > p.col_w[widest]) {
+ widest = c;
+ };
+ _ = layout.insertColumn(p, widest, false) orelse return error.NoColumn;
+ }
+ try dumpState(p);
+ const restored = try initFromDump(gpa, .{ .tty_only = true, .cols = 1200, .rows = 24 }, p.dump_out.?);
+ defer restored.deinit();
+ try std.testing.expectEqual(@as(usize, MAX_COLS), restored.ncol);
+}
+
test "a restored pane keeps its dot, as acme's dump keeps a window's" {
const gpa = std.testing.allocator;
const p = try Pardes.init(gpa, .{ .tty_only = true });
diff --git a/src/main.zig b/src/main.zig
index ec2472d7..ab6f1527 100644
--- a/src/main.zig
+++ b/src/main.zig
@@ -408,10 +408,30 @@ fn nativeMain(init: std.process.Init) !void {
if (std.mem.indexOfAny(u8, word, "\r\n") != null) break :forwarding;
const target = @import("look.zig").parsePathLine(word);
var realbuf: [4096]u8 = undefined;
- const path = if (pardes.filesystem.isVirtual(target.path)) target.path else (pardes.filesystem.resolveOs(target.path, &realbuf) orelse break :forwarding).path;
- var command_buf: [8192]u8 = undefined;
- const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding;
- ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding;
+ var newbuf: [4096]u8 = undefined;
+ const found_path: ?[]const u8 = if (pardes.filesystem.isVirtual(target.path)) target.path else if (pardes.filesystem.resolveOs(target.path, &realbuf)) |r| r.path else null;
+ const path = found_path orelse named: {
+ // A name not there yet, as acme's B takes one: its directory
+ // resolved and the name kept, a pane made for it that Save
+ // creates the file from.
+ const base = std.fs.path.basename(target.path);
+ if (base.len == 0 or std.mem.eql(u8, base, ".") or std.mem.eql(u8, base, "..")) break :forwarding;
+ const dir = pardes.filesystem.resolveOs(std.fs.path.dirname(target.path) orelse ".", &realbuf) orelse break :forwarding;
+ break :named std.fmt.bufPrint(&newbuf, "{s}/{s}", .{ std.mem.trimEnd(u8, dir.path, "/"), base }) catch break :forwarding;
+ };
+ if (found_path != null) {
+ var command_buf: [8192]u8 = undefined;
+ const command = std.fmt.bufPrint(&command_buf, "{s}{s}\n", .{ path, word[target.path.len..] }) catch break :forwarding;
+ ninep_io.Client.write(arena, parent.dial, look, command) catch break :forwarding;
+ } else {
+ const made = ninep_io.Client.read(arena, parent.dial, "/pane/new", "/pane/new") catch break :forwarding;
+ const serial = std.fmt.parseInt(u32, std.mem.trim(u8, made, " \n"), 10) catch break :forwarding;
+ var name_buf: [64]u8 = undefined;
+ const name = try std.fmt.bufPrint(&name_buf, "/pane/{d}/name", .{serial});
+ var line_buf: [4200]u8 = undefined;
+ const line = std.fmt.bufPrint(&line_buf, "{s}\n", .{path}) catch break :forwarding;
+ ninep_io.Client.write(arena, parent.dial, name, line) catch break :forwarding;
+ }
if (wait) waitForDel(init.io, arena, parent.dial, path);
return;
}
@@ -468,7 +488,15 @@ fn nativeMain(init: std.process.Init) !void {
std.process.exit(1);
}
switch (pardes.platform) {
- .tty => try @import("tty/tty.zig").run(init, opts, attach),
+ .tty => @import("tty/tty.zig").run(init, opts, attach) catch |err| switch (err) {
+ // No controlling terminal (a detached pty, a daemon): said, not
+ // an error trace.
+ error.NoDevice => {
+ std.Io.File.stderr().writeStreamingAll(init.io, "pardes: no terminal to draw on: /dev/tty will not open; run it in a terminal, or --detach\n") catch {};
+ std.process.exit(1);
+ },
+ else => return err,
+ },
.gui => try @import("gui/gui.zig").run(init, opts, attach),
.web, .macos, .esp32p4 => unreachable,
}
diff --git a/src/ninep/events.zig b/src/ninep/events.zig
index c0bf4a9a..fe7af77e 100644
--- a/src/ninep/events.zig
+++ b/src/ninep/events.zig
@@ -1289,7 +1289,7 @@ test "a record is one line of UTF-8: DEL and C1 are spaces, bytes not UTF-8 are
try testing.expectEqualStrings("msg - caf\xc3\xa9\n", sanitize("msg - caf\xc3\xa9\n", &out));
}
-test "a long err record is cut between words, with an ellipsis" {
+test "a long err record keeps its end, its middle given up to an ellipsis" {
const p = try withFile(testing.allocator, "x\n");
defer p.deinit();
_ = wr(p, @intFromEnum(tree.TopFile.ctl), "Bogus " ++ "abcdefgh " ** 40 ++ "\n");
@@ -1298,7 +1298,9 @@ test "a long err record is cut between words, with an ellipsis" {
const text = call(p, .{ .tag = 2, .op = .read, .node = log, .handle = h, .size = 1 << 16 }).bytes;
const at = std.mem.lastIndexOf(u8, text, "err - ctl: ").?;
const record = text[at .. std.mem.indexOfScalarPos(u8, text, at, '\n').? + 1];
- try testing.expect(std.mem.endsWith(u8, record, "abcdefgh…\n"));
+ try testing.expect(std.mem.indexOf(u8, record, "…") != null);
+ try testing.expect(std.mem.startsWith(u8, record, "err - ctl: "));
+ try testing.expect(std.mem.endsWith(u8, record, "abcdefgh\"\n") or std.mem.endsWith(u8, record, "abcdefgh \"\n"));
_ = call(p, .{ .tag = 3, .op = .release, .node = log, .handle = h });
}
diff --git a/src/ninep/pane.zig b/src/ninep/pane.zig
index bb4ebf86..3c1e181e 100644
--- a/src/ninep/pane.zig
+++ b/src/ninep/pane.zig
@@ -571,7 +571,7 @@ fn writeTag(req: Req, pane: *Pane) Reply {
const had = tagline.curTail(pane).len;
const room = limits.max_tag_tail -| had -| @intFromBool(pf.tag_held_newline);
// Whole or not at all: a write that would pass the limit changes nothing.
- if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: over {d} bytes", .{limits.max_tag_tail}));
+ if (req.data.len > room) return tree.failText(req.tag, E.NOSPC, std.fmt.comptimePrint("tag: no space: over {d} bytes", .{limits.max_tag_tail}));
const take = wholeUtf8(req.data);
// A truncating write drops ONE trailing newline, its whole text's: a
// newline held from a write before goes in once more text follows it.
@@ -1214,7 +1214,9 @@ test "a tag write past the limit is refused whole, naming the limit" {
const big = "w" ** (limits.max_tag_tail + 10);
const r = wr(p, tag, big);
try testing.expectEqual(E.NOSPC, r.errno());
- try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: over "));
+ // `no space` is what 9ns maps to ENOSPC, so a shell through a mount
+ // sees the errno the tree gives.
+ try testing.expect(std.mem.startsWith(u8, r.reply.ename, "tag: no space: over "));
try testing.expectEqualStrings(before, rd(p, tag, 0, 1 << 16).bytes);
}
diff --git a/src/ninep/tree.zig b/src/ninep/tree.zig
index c86a689c..5f3ffc9c 100644
--- a/src/ninep/tree.zig
+++ b/src/ninep/tree.zig
@@ -456,9 +456,59 @@ pub fn stagedReply(p: *Pardes, req: Req) Reply {
pub fn handle(p: *Pardes, req: Req) Reply {
var reply = serve(p, req);
if (reply.status == .err and reply.ename.len == 0) reply.ename = errWords(reply.errno);
+ if (reply.status == .err and reply.ename.len > cloud9.fs.errmax)
+ if (p.scratch.allocator().alloc(u8, cloud9.fs.errmax)) |room| {
+ reply.ename = fitErr(reply.ename, room);
+ } else |_| {};
return reply;
}
+/// A reason longer than an Rerror carries (Plan 9's ERRMAX, 128 bytes)
+/// keeps its end, which says why: the longest path in it -- else the whole
+/// text -- gives up its middle to `…`.
+pub fn fitErr(text: []const u8, out: []u8) []const u8 {
+ const ell = "…";
+ if (text.len <= out.len) return text;
+ const over = text.len - out.len + ell.len;
+ var from: usize = 0;
+ var to: usize = text.len;
+ var words = std.mem.tokenizeAny(u8, text, " \t");
+ var longest: usize = 0;
+ while (words.next()) |w| if (std.mem.indexOfScalar(u8, w, '/') != null and w.len > longest and w.len > over + 2) {
+ longest = w.len;
+ from = @intFromPtr(w.ptr) - @intFromPtr(text.ptr);
+ to = from + w.len;
+ };
+ var a = from + (to - from - over) / 2;
+ var b = a + over;
+ while (a > from and text[a] & 0xC0 == 0x80) a -= 1;
+ while (b < to and text[b] & 0xC0 == 0x80) b += 1;
+ return std.fmt.bufPrint(out, "{s}" ++ ell ++ "{s}", .{ text[0..a], text[b..] }) catch text[0..out.len];
+}
+
+test "a reason past 128 bytes keeps its end: the path in it gives up its middle" {
+ var out: [128]u8 = undefined;
+ const long = "Save /home/someone/projects/" ++ "deep/" ** 30 ++ "file.txt: no such directory";
+ const fit = fitErr(long, &out);
+ try testing.expect(fit.len <= 128);
+ try testing.expect(std.mem.startsWith(u8, fit, "Save /home/someone/"));
+ try testing.expect(std.mem.endsWith(u8, fit, "file.txt: no such directory"));
+ try testing.expect(std.mem.indexOf(u8, fit, "…") != null);
+ try testing.expect(std.unicode.utf8ValidateSlice(fit));
+ // No path: the whole text's middle goes, the end stays.
+ const words = "why " ** 40 ++ "the reason";
+ try testing.expect(std.mem.endsWith(u8, fitErr(words, &out), "the reason"));
+ try testing.expectEqualStrings("short", fitErr("short", &out));
+ // Through the tree: a look at a long ./ name not there says why at the end.
+ const p = try th.withFile(testing.allocator, "x\n");
+ defer p.deinit();
+ var line: [512]u8 = undefined;
+ const r = th.wr(p, Node.of(serialOf(p), .look), try std.fmt.bufPrint(&line, "./{s}x.txt\n", .{"no-such-dir/" ** 20}));
+ try testing.expectEqual(Status.err, r.reply.status);
+ try testing.expect(r.reply.ename.len <= 128);
+ try testing.expect(std.mem.endsWith(u8, r.reply.ename, "no such file") or std.mem.endsWith(u8, r.reply.ename, "no such directory"));
+}
+
/// A refusal with no reason of its own said in Plan 9's words, not the C
/// library's (`Operation not permitted`); each maps back to its errno in
/// 9ns (enameToErrno), EPERM's to EACCES as Plan 9's does.
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 {};
}