src/tui/paint.zig
Ref: Size: 54.4 KiB History
//! Painting the replica to a tty: clipped renders, delta rows, banner,
//! scrollback — pure fd-out, no transport knowledge. `paintDeltaClipped` is
//! the exception: its payload is raw wire, read here for the row indices it
//! names, so this module knows the delta FORMAT without knowing what carried
//! it. Also the single home of the synchronized-update bracket, for every
//! caller, and the only place a grid cell becomes a terminal escape.
const std = @import("std");
const grid = @import("term").grid;
const Grid = grid.Grid;
const proto = @import("term").protocol;
/// The synchronized-update bracket. Exactly once, because a dropped half is
/// invisible to both e2e suites — the bytes still paint, just tearably — so
/// only the three unit pins can see it.
pub const sync_begin = "\x1b[?2026h\x1b[?25l";
pub const sync_end = "\x1b[?25h\x1b[?2026l";
/// Inclusive grid columns of one row, painted inverted.
pub const Span = struct { from: u16, to: u16 };
/// A callback, not a shape: `client.selection` owns selection state, while
/// this file adapts its spans into painted output.
pub const Highlight = struct {
ctx: ?*anyopaque = null,
span: ?*const fn (?*anyopaque, row: u16, cols: u16) ?Span = null,
};
/// One grid row as VT for a terminal: an SGR wherever the style changes, a
/// wide glyph written once with its spacer skipped, a space for an empty
/// cell, and a stop at the last cell that is not a default blank, because
/// the caller's ECH has already cleared the rest of the row.
///
/// `span` is inclusive GRID columns, painted inverted and PLAIN and snapped
/// outward to whole glyphs, closed by a full reset. Its three pieces are
/// positioned with CHA, which is screen-absolute, so every column it emits
/// carries `view.col_off` or the row lands in the neighbour's pane.
pub fn rowToVtFrom(
alloc: std.mem.Allocator,
r: *const grid.Row,
cols: u16,
view: grid.RowView,
span: ?Span,
) ![]u8 {
var out: std.ArrayList(u8) = .empty;
errdefer out.deinit(alloc);
try out.appendSlice(alloc, "\x1b[0m");
const last = grid.clipColOf(r, cols, view) orelse return out.toOwnedSlice(alloc);
const snapped: ?grid.ColSpan = if (span) |s| blk: {
if (s.from > last) break :blk null;
break :blk grid.snapWideOf(r, cols, s.from, @min(s.to, last));
} else null;
// The last cell worth writing: trailing default blanks are the caller's
// clear, not ours — but a selected blank is a cell the user can see they
// selected, so the span holds the stop open past it. A row with nothing
// on it writes nothing at all, or an empty row would print a space and a
// screen would never look empty again.
var end: ?u16 = null;
var i: u16 = 0;
while (i <= last) : (i += 1) {
const selected = if (snapped) |s| i >= s.from and i <= s.to else false;
if (!r.cells[i].isBlank() or selected) end = i;
}
const stop = end orelse return out.toOwnedSlice(alloc);
var cur: proto.CellStyle = .{};
var x: u16 = 0;
var inverted = false;
while (x <= stop) : (x += 1) {
const c = r.cells[x];
if (c.wide == .spacer_tail or c.wide == .spacer_head) continue;
const in_span = if (snapped) |s| x >= s.from and x <= s.to else false;
if (in_span and !inverted) {
try out.writer(alloc).print("\x1b[{d}G\x1b[0m\x1b[7m", .{x + 1 + view.col_off});
inverted = true;
cur = .{};
} else if (!in_span and inverted) {
try out.writer(alloc).print("\x1b[0m\x1b[{d}G", .{x + 1 + view.col_off});
inverted = false;
cur = .{};
}
if (!in_span and !c.style.eql(cur)) {
try appendSgr(&out, alloc, c.style);
cur = c.style;
}
if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
}
try out.appendSlice(alloc, "\x1b[0m");
return out.toOwnedSlice(alloc);
}
pub fn rowToVt(alloc: std.mem.Allocator, g: *const Grid, y: u16, view: grid.RowView, span: ?Span) ![]u8 {
return rowToVtFrom(alloc, g.row(y), g.cols, view, span);
}
/// One colour parameter. `base` is 30 foreground, 40 background, 58
/// underline; the palette's first sixteen entries have their own short
/// codes, and the underline colour has none of them.
fn appendColor(out: *std.ArrayList(u8), alloc: std.mem.Allocator, base: u8, packed_col: u32) !void {
const w = out.writer(alloc);
switch (packed_col >> 24) {
0 => try w.print(";{d}", .{base + 9}), // default: 39 / 49 / 59
1 => {
const idx: u8 = @truncate(packed_col);
if (base != 58 and idx < 8) {
try w.print(";{d}", .{base + idx});
} else if (base != 58 and idx < 16) {
try w.print(";{d}", .{base + 60 + (idx - 8)});
} else try w.print(";{d};5;{d}", .{ base + 8, idx });
},
else => try w.print(";{d};2;{d};{d};{d}", .{ base + 8, (packed_col >> 16) & 0xff, (packed_col >> 8) & 0xff, packed_col & 0xff }),
}
}
/// A full SGR for `s` from a reset: every attribute the wire carries, so a
/// host terminal never inherits a neighbouring cell's style. Written as one
/// sequence starting at 0 rather than as a diff against the previous cell,
/// because a diff would have to reason about what the terminal is in and a
/// row is repainted from a reset anyway.
fn appendSgr(out: *std.ArrayList(u8), alloc: std.mem.Allocator, s: proto.CellStyle) !void {
const w = out.writer(alloc);
try w.writeAll("\x1b[0");
if (s.flags & (1 << 0) != 0) try w.writeAll(";1");
if (s.flags & (1 << 2) != 0) try w.writeAll(";2");
if (s.flags & (1 << 1) != 0) try w.writeAll(";3");
switch ((s.flags >> 8) & 0x7) {
0 => {},
1 => try w.writeAll(";4"),
2 => try w.writeAll(";4:2"),
3 => try w.writeAll(";4:3"),
4 => try w.writeAll(";4:4"),
5 => try w.writeAll(";4:5"),
else => try w.writeAll(";4"),
}
if (s.flags & (1 << 3) != 0) try w.writeAll(";5");
if (s.flags & (1 << 4) != 0) try w.writeAll(";7");
if (s.flags & (1 << 5) != 0) try w.writeAll(";8");
if (s.flags & (1 << 6) != 0) try w.writeAll(";9");
if (s.flags & (1 << 7) != 0) try w.writeAll(";53");
if (s.fg != 0) try appendColor(out, alloc, 30, s.fg);
if (s.bg != 0) try appendColor(out, alloc, 40, s.bg);
if (s.ul != 0) try appendColor(out, alloc, 58, s.ul);
try w.writeAll("m");
}
/// One grid row, inverted where the highlight says so, bounded to the pane's
/// own columns. The single place the two painters agree about what a
/// selection does to a row, and about where a row STOPS.
fn dumpRow(alloc: std.mem.Allocator, g: *const Grid, y: u16, hl: Highlight, view: grid.RowView) ![]u8 {
const ask = hl.span orelse return rowToVt(alloc, g, y, view, null);
return rowToVt(alloc, g, y, view, ask(hl.ctx, y, g.cols));
}
// Four positional scalars said this before, two of them one value at every
// production call: the row bound read one spelling of the width and the
// clear beside it read the other, with no type to say they had drifted.
// No field defaults, for the reason `RowView.col_off` lost its.
/// The rect a tile owns, 0-based. Latest-wins can outgrow rows/cols.
pub const Viewport = struct {
top: u16,
left: u16,
rows: u16,
cols: u16,
};
pub fn clampCursor(cur: grid.CursorPos, vp: Viewport) grid.CursorPos {
return .{
.x = @min(cur.x, vp.cols -| 1),
.y = @min(cur.y, vp.rows -| 1),
};
}
/// The prefix every painted row carries: CUP to that row's own left edge in
/// the tile's rect, then the clear that bounds what follows. `clear` is
/// span-bounded ECH for a tile sharing the screen, and empty for one that
/// already cleared the whole screen — a line-wide clear would reach a
/// neighbour's cells or a rail.
fn appendRowAt(
paint: *std.ArrayList(u8),
alloc: std.mem.Allocator,
row: u16,
vp: Viewport,
clear: []const u8,
) !void {
var cup: [24]u8 = undefined;
try paint.appendSlice(alloc, try std.fmt.bufPrint(&cup, "\x1b[{d};{d}H{s}", .{ @as(u32, row) + vp.top + 1, vp.left + 1, clear }));
}
/// The tail of a live paint: park the cursor where the grid puts it —
/// clamped, since latest-wins lets the grid outgrow the rect — close the
/// synchronized update, and write the buffer in ONE call, so no half-drawn
/// screen is ever on the terminal.
fn finishPaint(
paint: *std.ArrayList(u8),
alloc: std.mem.Allocator,
cur: grid.CursorPos,
vp: Viewport,
out_fd: std.posix.fd_t,
) !void {
const at = clampCursor(cur, vp);
var cbuf: [16]u8 = undefined;
try paint.appendSlice(alloc, try std.fmt.bufPrint(&cbuf, "\x1b[{d};{d}H", .{ at.y + vp.top + 1, at.x + vp.left + 1 }));
try paint.appendSlice(alloc, sync_end);
try proto.writeAllFd(out_fd, paint.items);
}
/// The replica may exceed the tty under latest-wins; rows clip at the
/// right edge. `rows` null is every row of the viewport.
pub fn renderClipped(
alloc: std.mem.Allocator,
g: *const Grid,
vp: Viewport,
hl: Highlight,
rows: ?[]const u16,
owns_screen: bool,
out_fd: std.posix.fd_t,
) !void {
var paint: std.ArrayList(u8) = .empty;
defer paint.deinit(alloc);
// `owns_screen` is the caller's contract, not inferred from the rect: a
// future top-positioned tile in a multi-tile wall would sit at row 0
// without owning the screen. A full-screen clear wipes every other tile.
try paint.appendSlice(alloc, if (owns_screen) sync_begin ++ "\x1b[H\x1b[2J" else sync_begin);
const limit = @min(g.rows, vp.rows);
const view: grid.RowView = .{ .col_off = vp.left, .cols = vp.cols };
var ech_buf: [16]u8 = undefined;
const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch "";
// Span-bounded ECH per row where the screen was not cleared, so a
// shorter row cannot leave the old one showing and a tile cannot reach
// a neighbour's cells or a rail.
const clear: []const u8 = if (owns_screen) "" else ech;
const n = if (rows) |r| r.len else limit;
for (0..n) |k| {
const y: u16 = if (rows) |r| r[k] else @intCast(k);
if (y >= limit) continue;
try appendRowAt(&paint, alloc, y, vp, clear);
const row = try dumpRow(alloc, g, y, hl, view);
defer alloc.free(row);
try paint.appendSlice(alloc, row);
}
try finishPaint(&paint, alloc, g.cursor, vp, out_fd);
}
/// What a MOVING selection needs: a drag changes one or two rows, and a
/// full repaint per cell crossed costs the whole screen.
pub fn renderRowsClipped(
alloc: std.mem.Allocator,
g: *const Grid,
vp: Viewport,
hl: Highlight,
rows: []const u16,
out_fd: std.posix.fd_t,
) !void {
if (rows.len == 0) return;
return renderClipped(alloc, g, vp, hl, rows, false, out_fd);
}
/// The rows a delta frame names, painted FROM THE GRID the replica has just
/// been fed. The frame's own bytes are cells, not paintable VT, so there is
/// no verbatim path left: every row is re-serialized against this pane's
/// width, which is also what keeps a grid-wide row off a narrower pane's
/// neighbour.
pub fn paintDeltaClipped(
alloc: std.mem.Allocator,
payload: []const u8,
g: *const Grid,
vp: Viewport,
hl: Highlight,
out_fd: std.posix.fd_t,
) !void {
const hdr = try proto.readDeltaHeader(payload);
var paint: std.ArrayList(u8) = .empty;
defer paint.deinit(alloc);
try paint.appendSlice(alloc, sync_begin);
const limit = @min(g.rows, vp.rows);
const view: grid.RowView = .{ .col_off = vp.left, .cols = vp.cols };
var ech_buf: [16]u8 = undefined;
const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch "";
// The frame is walked for the row INDICES it names and nothing else: the
// cells it carried are already in the grid, put there by the replica.
var it = proto.deltaRowIterator(payload);
while (try it.next()) |row| {
if (row.row >= limit) continue;
try appendRowAt(&paint, alloc, row.row, vp, ech);
const seg = try dumpRow(alloc, g, row.row, hl, view);
defer alloc.free(seg);
try paint.appendSlice(alloc, seg);
}
try finishPaint(&paint, alloc, .{ .x = hdr.cursor_x, .y = hdr.cursor_y }, vp, out_fd);
}
/// An inverse status marker parked in the top-right corner: `[scroll]` when
/// viewing history, `[reconnecting]` when the transport is being rebuilt.
/// Text only, so a caller mid-repaint can append it into its own paint
/// buffer and keep the whole screen one synchronized update.
fn bannerText(buf: []u8, view_cols: u16, label: []const u8, row_off: u16, col_off: u16) ![]const u8 {
// Right-aligned inside the pane: the label's left column is the pane's
// left edge plus the pane's width less the label, so at col_off 0 and
// view_cols 80 the column is 66 — matching the plain client's edge.
const col = @max(col_off + 1, col_off +| view_cols -| @as(u16, @intCast(label.len)));
return std.fmt.bufPrint(buf, "\x1b[{d};{d}H\x1b[7m{s}\x1b[0m", .{ row_off + 1, col, label });
}
// The longest label `paintBanner` renders whole. It holds the wall's add-tile
// prompt line, which interact asserts at comptime. Above this, `bufPrint`
// overflows and the caller's status marker never reaches the screen.
pub const banner_label_max: usize = 260;
/// Drop a banner onto a screen that is otherwise staying put — the cursor is
/// saved and restored around it, so the shell's cursor does not visibly jump
/// to the corner. Best-effort: a status marker is never worth failing over.
pub fn paintBanner(out_fd: std.posix.fd_t, view_cols: u16, label: []const u8, row_off: u16, col_off: u16) void {
// Both sized off the cap: 24 covers `\x1b[R;CH` at u16 width plus the
// two SGRs, and the wrap adds six more.
var buf: [banner_label_max + 24]u8 = undefined;
const mark = bannerText(&buf, view_cols, label, row_off, col_off) catch return;
var paint: [banner_label_max + 32]u8 = undefined;
const text = std.fmt.bufPrint(&paint, "\x1b[s{s}\x1b[u", .{mark}) catch return;
proto.writeAllFd(out_fd, text) catch {};
}
/// A page of history: the rows a `scrollback_chunk` carried, decoded, and
/// the inverse [scroll] marker top-right saying this is not live. `g_cols`
/// is the daemon's grid width, which the rows were encoded at; a pane
/// narrower than that clips at its own edge like any live row.
pub fn renderScrollback(
alloc: std.mem.Allocator,
rows: []const grid.Row,
g_cols: u16,
vp: Viewport,
owns_screen: bool,
out_fd: std.posix.fd_t,
) !void {
var paint: std.ArrayList(u8) = .empty;
defer paint.deinit(alloc);
// `owns_screen` is the caller's contract: a whole-screen clear wipes
// every other tile, so only a tile that has the whole screen takes one.
// A tile at an offset clears per row instead — the pattern
// `renderClipped` uses for a live tile.
var ech_buf: [16]u8 = undefined;
const ech = std.fmt.bufPrint(&ech_buf, "\x1b[{d}X", .{vp.cols}) catch "";
const clear: []const u8 = if (owns_screen) "" else ech;
if (owns_screen) {
try paint.appendSlice(alloc, sync_begin ++ "\x1b[H\x1b[2J");
} else {
try paint.appendSlice(alloc, sync_begin);
}
const view: grid.RowView = .{ .col_off = vp.left, .cols = vp.cols };
// The chunk is the answer to a request for `vp.rows` of history, so a
// longer one is a daemon disagreeing with this client about the tile's
// height and every surplus row would land on the tile below. The request
// is not the bound; the rect is.
var n: u16 = 0;
while (n < vp.rows and n < rows.len) : (n += 1) {
try appendRowAt(&paint, alloc, n, vp, clear);
const seg = try rowToVtFrom(alloc, &rows[n], g_cols, view, null);
defer alloc.free(seg);
try paint.appendSlice(alloc, seg);
}
// A chunk shorter than the tile is a page near the top of history. On a
// cleared screen those rows are already blank; on a shared one they
// still hold the live text this page replaced, so each is erased.
if (!owns_screen) {
while (n < vp.rows) : (n += 1) try appendRowAt(&paint, alloc, n, vp, clear);
}
var mark_buf: [96]u8 = undefined;
try paint.appendSlice(alloc, try bannerText(&mark_buf, vp.cols, "[scroll]", vp.top, vp.left));
// Deliberately unpaired, and not to be "simplified" into `sync_end`: it
// closes the update WITHOUT the cursor-show, because a cursor parked in a
// history page means nothing. Every exit from scroll mode repaints.
try paint.appendSlice(alloc, "\x1b[?2026l");
try proto.writeAllFd(out_fd, paint.items);
}
// ---------------------------------------------------------------------------
// Tests. A client grid can only be filled from encoded cells, so every screen
// under test is AUTHORED through an engine and mirrored in — the same path the
// daemon's encoder and the replica's decoder take on the wire.
const Engine = @import("engine").Engine;
/// A grid holding what an engine that size shows after `bytes`, cursor
/// included. The bridge between a screen a test wants to describe in VT and
/// the cells a painter reads.
fn authoredGrid(alloc: std.mem.Allocator, cols: u16, rows: u16, bytes: []const u8) !*Grid {
const g = try Grid.init(alloc, cols, rows);
errdefer g.deinit();
const e = try Engine.init(alloc, .{ .cols = cols, .rows = rows });
defer e.deinit();
e.feed(bytes);
try e.mirrorInto(g);
return g;
}
/// A highlight of two fixed rows, standing in for what `select.Drag`
/// answers: rows 1 and 2, from column 3 to the width the painter offers.
const TestHighlight = struct {
cols_seen: u16 = 0,
fn span(ctx: ?*anyopaque, row: u16, cols: u16) ?Span {
const self: *TestHighlight = @ptrCast(@alignCast(ctx.?));
self.cols_seen = cols;
if (row != 1 and row != 2) return null;
return .{ .from = 3, .to = cols -| 1 };
}
fn hl(self: *TestHighlight) Highlight {
return .{ .ctx = self, .span = &span };
}
};
test "rowToVt: an SGR per style change, a wide glyph once, a trailing-blank stop" {
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 12, 2, "\x1b[1;31mab\x1b[0m\u{6f22}c");
defer g.deinit();
const row = try rowToVt(alloc, g, 0, .{ .col_off = 0, .cols = 12 }, null);
defer alloc.free(row);
// Bold red for `ab`, a reset-shaped SGR for `漢c`, the wide glyph written
// once with its spacer skipped, and nothing for the eight blank columns
// after it: the caller's ECH cleared those.
try std.testing.expectEqualStrings(
"\x1b[0m\x1b[0;1;31mab\x1b[0m\u{6f22}c\x1b[0m",
row,
);
}
test "rowToVt: a selection paints its cells PLAIN, whatever style they carry" {
// An inversion over a bold red cell that kept its own pen is a selection
// the user cannot read: the terminal renders inverse-on-red, not the
// highlight. The span opens with a full reset and emits no SGR of its
// own, so a styled row under a selection comes out uniformly inverted —
// and the head before it keeps its style, which is what says the reset
// belongs to the span and not to the row.
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 12, 2, "\x1b[2;1H\x1b[1;31mabcdef");
defer g.deinit();
const row = try rowToVt(alloc, g, 1, .{ .col_off = 0, .cols = 80 }, .{ .from = 3, .to = 11 });
defer alloc.free(row);
try std.testing.expectEqualStrings(
"\x1b[0m\x1b[0;1;31mabc\x1b[4G\x1b[0m\x1b[7mdef \x1b[0m",
row,
);
}
test "renderClipped inverts the highlighted rows and leaves the rest alone" {
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 12, 4, "row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
defer g.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
var h: TestHighlight = .{};
try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, h.hl(), null, true, pipe[1]);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const text = out[0..n];
// The two highlighted rows carry an inversion; the rows above and
// below are ordinary dumps and must not.
try std.testing.expectEqual(@as(usize, 2), std.mem.count(u8, text, "\x1b[7m"));
const r0 = std.mem.indexOf(u8, text, "row-zero").?;
const r1 = std.mem.indexOf(u8, text, "\x1b[7m").?;
const r3 = std.mem.indexOf(u8, text, "row-three").?;
try std.testing.expect(r0 < r1 and r1 < r3);
// The span begins at column 4 (0-based 3), which only the CHA says.
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4G\x1b[0m\x1b[7m") != null);
// The width the highlight was clamped to is the GRID's, not the tty's:
// a 12-column replica painted on an 80-column terminal has 68 columns
// no cell can be selected in, and asking the daemon for one is a round
// trip spent to be told `.invalid`.
try std.testing.expectEqual(@as(u16, 12), h.cols_seen);
}
test "paintBanner parks an inverse label top-right without moving the cursor" {
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
paintBanner(pipe[1], 80, "[reconnecting]", 0, 0);
std.posix.close(pipe[1]);
var out: [256]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const text = out[0..n];
// Row 1, right-aligned: 80 columns less the label's own width.
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;66H") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[7m[reconnecting]\x1b[0m") != null);
// Saved and restored around the paint, so the shell's cursor does not
// visibly jump into the corner while we reconnect.
try std.testing.expect(std.mem.startsWith(u8, text, "\x1b[s"));
try std.testing.expect(std.mem.endsWith(u8, text, "\x1b[u"));
}
test "paintBanner on a narrow tty clamps to column 1 instead of underflowing" {
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
// Label longer than the whole terminal: the saturating subtraction must
// land on column 1, never wrap around to a huge column.
paintBanner(pipe[1], 4, "[reconnecting]", 0, 0);
std.posix.close(pipe[1]);
var out: [256]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[1;1H") != null);
}
test "paint: a banner label of banner_label_max bytes still paints" {
// The wall's add-tile prompt is a banner, and a spelling the user is
// mid-way through typing is a label. A buffer that overflows on a long
// one does not truncate — it writes NOTHING, so the prompt appears to
// freeze at the width the buffer happened to allow.
var label: [banner_label_max]u8 = undefined;
@memset(&label, 'x');
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
paintBanner(pipe[1], 400, &label, 0, 0);
std.posix.close(pipe[1]);
var out: [1024]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
try std.testing.expect(std.mem.indexOf(u8, out[0..n], &label) != null);
}
test "paint: a banner never starts left of its pane" {
// Right-alignment is a subtraction, and a label wider than the pane
// would take it left of `col_off` — into the neighbour across the rail.
// `bannerText` does not truncate: keeping a label INSIDE its pane's
// right edge is the caller's, and `wallview.tileBanner` owns it.
var buf: [banner_label_max + 24]u8 = undefined;
var label: [50]u8 = undefined;
@memset(&label, 'x');
// Exactly the pane's width: the right edge and the left edge coincide.
try std.testing.expectEqual(@as(u16, 61), try bannerCol(&buf, 40, label[0..40], 60));
// Wider than the pane: the floor holds it at the pane's own left edge.
try std.testing.expectEqual(@as(u16, 61), try bannerCol(&buf, 40, label[0..50], 60));
}
/// The column `bannerText` chose, parsed back out of its `\x1b[R;CH`.
fn bannerCol(buf: []u8, view_cols: u16, label: []const u8, col_off: u16) !u16 {
const text = try bannerText(buf, view_cols, label, 0, col_off);
const semi = std.mem.indexOfScalar(u8, text, ';').?;
const h = std.mem.indexOfScalar(u8, text, 'H').?;
return std.fmt.parseInt(u16, text[semi + 1 .. h], 10);
}
test "paint: a banner parks in its own tile's corner, not the screen's" {
// A tile at an offset parks its status marker in ITS top-right corner.
// On the grid rather than in the bytes: the corner is a cell, and a
// marker one tile up or one column across the rail still emits the
// `\x1b[7m` and the label a substring search asks for.
const alloc = std.testing.allocator;
const row_off: u16 = 2;
const label = "[reconnecting]";
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
paintBanner(pipe[1], beside_width, label, row_off, beside_off);
std.posix.close(pipe[1]);
var out: [256]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const screen = try besideScreen(alloc);
defer screen.deinit();
// A cursor with somewhere to be: the save/restore wrapper's whole point
// is that a shell's caret does not visibly jump to the corner.
screen.feed("\x1b[6;3H");
screen.feed(out[0..n]);
const plain = try screen.dumpPlain(alloc);
defer alloc.free(plain);
var it = std.mem.splitScalar(u8, plain, '\n');
var i: u16 = 0;
while (it.next()) |line| : (i += 1) {
const want = if (i == row_off)
"L" ** (beside_off - 1) ++ "|" ++ "R" ** (beside_width - label.len - 1) ++
label ++ "R"
else
beside_seed_row;
try std.testing.expectEqualStrings(want, line);
}
try std.testing.expectEqual(Engine.CursorPos{ .x = 2, .y = 5 }, screen.cursorPos());
}
test "renderScrollback paints rows with an inverse scroll marker" {
const alloc = std.testing.allocator;
const hist = try authoredGrid(alloc, 80, 2, "old-row-1\r\nold-row-2");
defer hist.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try renderScrollback(alloc, hist.lines, 80, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, true, pipe[1]);
std.posix.close(pipe[1]);
var out: [4096]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
try std.testing.expect(std.mem.indexOf(u8, out[0..n], "old-row-1") != null);
try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[7m[scroll]") != null);
}
// The plain client paints scrollback at row 0 and its capture bytes are
// pinned end-to-end; this is the unit-level pin of that exact shape, so a
// change to the offset path cannot drift the row 0 path past it.
test "renderScrollback at row 0 is byte-identical to the plain client" {
const alloc = std.testing.allocator;
const hist = try authoredGrid(alloc, 80, 2, "old-row-1\r\nold-row-2");
defer hist.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try renderScrollback(alloc, hist.lines, 80, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, true, pipe[1]);
std.posix.close(pipe[1]);
var out: [4096]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
// Home + whole-screen clear once, one addressed row per history row with
// no clear behind it (the screen is already blank), the banner at the
// screen's top-right (row 1, col 80 - len("[scroll]") = 73), then the
// unpaired close that hides the cursor for a history page.
const expected = "\x1b[?2026h\x1b[?25l\x1b[H\x1b[2J" ++
"\x1b[1;1H\x1b[0mold-row-1\x1b[0m" ++
"\x1b[2;1H\x1b[0mold-row-2\x1b[0m" ++
"\x1b[1;72H\x1b[7m[scroll]\x1b[0m\x1b[?2026l";
try std.testing.expectEqualStrings(expected, out[0..n]);
}
// A focused wall tile paints scrollback at a row_off: the page must own
// only its sub-rect, because the exit path repaints nothing but the tile's
// own rows and a whole-screen clear would leave every neighbour blank.
test "renderScrollback at a row_off owns only its sub-rect" {
const alloc = std.testing.allocator;
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
const row_off: u16 = 5;
const size: proto.Size = .{ .cols = 80, .rows = 24 };
// Three rows MORE than the tile is tall: the ASK is not what bounds the
// paint, so a daemon that disagreed about the height would land its
// surplus on the tile below, and a chunk shorter than the band could
// never say so.
const blob = comptime blk: {
var s: []const u8 = "";
var r: u16 = 1;
while (r <= 27) : (r += 1) {
s = s ++ std.fmt.comptimePrint("old-row-{d}", .{r});
if (r < 27) s = s ++ "\r\n";
}
break :blk s;
};
const hist = try authoredGrid(alloc, 80, 27, blob);
defer hist.deinit();
try renderScrollback(alloc, hist.lines, 80, .{ .top = row_off, .left = 0, .rows = size.rows, .cols = 80 }, false, pipe[1]);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const text = out[0..n];
// No whole-screen clear and no home to row 1: either would wipe the
// tile's neighbours. A per-row clear stands in instead, the same
// pattern `renderClipped` uses for a live tile.
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2J") == null);
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[H") == null);
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[80X") != null);
// Every CUP this paint emits lands inside [row_off+1, row_off+rows]:
// a row outside that band belongs to another tile.
var i: usize = 0;
while (i < text.len) {
if (text[i] == 0x1b and i + 1 < text.len and text[i + 1] == '[') {
const start = i + 2;
var j = start;
while (j < text.len and (std.ascii.isDigit(text[j]) or text[j] == ';')) j += 1;
if (j < text.len and text[j] == 'H') {
const params = text[start..j];
const semi = std.mem.indexOfScalar(u8, params, ';') orelse params.len;
const row = std.fmt.parseInt(u16, params[0..semi], 10) catch 1;
try std.testing.expect(row >= row_off + 1 and row <= row_off + size.rows);
i = j + 1;
continue;
}
}
i += 1;
}
// The banner parks at the tile's own top-right corner, not the screen's,
// and each history row is addressed at row_off + n + 1.
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;72H\x1b[7m[scroll]\x1b[0m") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;72H") == null);
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[6;1H\x1b[80X\x1b[0mold-row-1") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[7;1H\x1b[80X\x1b[0mold-row-2") != null);
// The last row the tile has room for is painted; the next is not sent
// to a row it does not own, it is not sent at all.
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[29;1H\x1b[80X\x1b[0mold-row-24") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "old-row-25") == null);
// The unpaired close is unchanged: scroll mode hides the cursor.
try std.testing.expect(std.mem.endsWith(u8, text, "\x1b[?2026l"));
}
test "renderScrollback: a page shorter than the tile erases the rows it does not fill" {
// A page near the top of history answers with fewer rows than the tile
// is tall. On a shared screen those rows still hold the live text this
// page replaced, so each one is erased rather than left showing.
const alloc = std.testing.allocator;
const hist = try authoredGrid(alloc, beside_width, 2, "hh\r\nii");
defer hist.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try renderScrollback(
alloc,
hist.lines,
beside_width,
.{ .top = 0, .left = beside_off, .rows = 4, .cols = beside_width },
false,
pipe[1],
);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const screen = try screenAfter(alloc, out[0..n]);
defer alloc.free(screen);
var it = std.mem.splitScalar(u8, screen, '\n');
var y: u16 = 0;
while (it.next()) |line| : (y += 1) {
const lhs = "L" ** (beside_off - 1) ++ "|";
const want = switch (y) {
1 => lhs ++ "ii",
// Rows 2 and 3 are the tile's and hold no history: erased, and
// the neighbour across the rail is untouched. The oracle's dump
// ends a row at its last written cell, so an erased tail reads
// as nothing rather than as spaces.
2, 3 => lhs,
4...7 => beside_seed_row,
else => continue,
};
try std.testing.expectEqualStrings(want, line);
}
}
test "renderClipped paints only rows that fit and clamps the cursor" {
const alloc = std.testing.allocator;
var body: std.ArrayList(u8) = .empty;
defer body.deinit(alloc);
try body.appendSlice(alloc, "top row\r\n");
var i: usize = 0;
while (i < 28) : (i += 1) try body.appendSlice(alloc, "mid\r\n");
try body.appendSlice(alloc, "bottom row\x1b[30;100H"); // cursor parked at grid corner
const g = try authoredGrid(alloc, 100, 30, body.items);
defer g.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
// Local tty is smaller than the 100x30 grid.
try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, null, true, pipe[1]);
std.posix.close(pipe[1]);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
var chunk: [4096]u8 = undefined;
while (true) {
const n = try std.posix.read(pipe[0], &chunk);
if (n == 0) break;
try out.appendSlice(alloc, chunk[0..n]);
}
try std.testing.expect(std.mem.indexOf(u8, out.items, "top row") != null);
// Row 29 (0-based) of the grid is beyond a 24-row tty: never painted.
try std.testing.expect(std.mem.indexOf(u8, out.items, "bottom row") == null);
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[25;") == null); // no CUP past the tty
// Cursor clamped into the tty (row 24, col 80), inside sync brackets.
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[24;80H") != null);
// Positional, and both bytes: the update must open before the first CUP,
// and the cursor must go down with it or it visibly walks the rows.
try std.testing.expect(std.mem.startsWith(u8, out.items, "\x1b[?2026h\x1b[?25l"));
// Spelled out rather than compared against `sync_end`, which would hold
// whatever the constant said. A full repaint runs on every attach, resize
// and reconnect, so losing the cursor-show strands a hidden cursor.
try std.testing.expect(std.mem.endsWith(u8, out.items, "\x1b[?25h\x1b[?2026l"));
}
test "renderClipped stops at the grid when the tty is the larger one" {
// The other direction of the clip: `rowToVt` indexes the grid's own
// lines, so the row loop must bound on the grid, not just on the tty.
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 40, 10, "small grid\x1b[10;40H");
defer g.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, null, true, pipe[1]);
std.posix.close(pipe[1]);
var out: std.ArrayList(u8) = .empty;
defer out.deinit(alloc);
var chunk: [4096]u8 = undefined;
while (true) {
const n = try std.posix.read(pipe[0], &chunk);
if (n == 0) break;
try out.appendSlice(alloc, chunk[0..n]);
}
try std.testing.expect(std.mem.indexOf(u8, out.items, "small grid") != null);
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[10;1H") != null); // last grid row
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[11;1H") == null); // none past it
try std.testing.expect(std.mem.indexOf(u8, out.items, "\x1b[10;40H") != null); // cursor unclamped
}
test "paint: a tile at a row and column offset paints only inside its rect" {
// Both offsets at once, judged on the grid: a substring search over the
// emitted bytes cannot see the byte that landed on the neighbour ABOVE,
// because every address it looks for is present either way. The rows
// outside the band and the columns left of the rail are the assertion.
const alloc = std.testing.allocator;
const row_off: u16 = 2;
const tile_rows: u16 = 3;
const g = try authoredGrid(alloc, beside_width, tile_rows, "0" ** beside_width ++ "\r\n" ++
"1" ** beside_width ++ "\r\n" ++ "2" ** beside_width ++ "\x1b[1;5H");
defer g.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try renderClipped(
alloc,
g,
.{ .top = row_off, .left = beside_off, .rows = tile_rows, .cols = beside_width },
.{},
null,
false,
pipe[1],
);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const screen = try besideScreen(alloc);
defer screen.deinit();
screen.feed(out[0..n]);
const plain = try screen.dumpPlain(alloc);
defer alloc.free(plain);
const lhs = "L" ** (beside_off - 1) ++ "|";
try std.testing.expectEqualStrings(
beside_seed_row ++ "\n" ++ beside_seed_row ++ "\n" ++
lhs ++ "0" ** beside_width ++ "\n" ++
lhs ++ "1" ** beside_width ++ "\n" ++
lhs ++ "2" ** beside_width ++ "\n" ++
beside_seed_row ++ "\n" ++ beside_seed_row ++ "\n" ++ beside_seed_row,
plain,
);
// The cursor rides BOTH offsets, or the shell's prompt and its caret sit
// in different tiles. Parked at grid row 0 column 4 (0-based).
try std.testing.expectEqual(
Engine.CursorPos{ .x = beside_off + 4, .y = row_off },
screen.cursorPos(),
);
}
test "paintDeltaClipped skips rows beyond the tty and clamps the cursor" {
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 80, 30, "\x1b[4;1Hfits\x1b[29;1Hdoes-not-fit");
defer g.deinit();
var payload: std.ArrayList(u8) = .empty;
defer payload.deinit(alloc);
try proto.appendDeltaHeader(&payload, alloc, .{
.seq = 9,
.history_rows = 0,
.cursor_x = 99,
.cursor_y = 29,
.row_count = 2,
});
// The row bytes are cells the replica has already taken; the paint reads
// the grid, so what they hold does not matter here — only which rows the
// frame names.
try proto.appendDeltaRow(&payload, alloc, 3, "");
try proto.appendDeltaRow(&payload, alloc, 28, "");
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try paintDeltaClipped(alloc, payload.items, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, pipe[1]);
std.posix.close(pipe[1]);
var out: [4096]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[4;1H\x1b[80X\x1b[0mfits") != null);
try std.testing.expect(std.mem.indexOf(u8, out[0..n], "does-not-fit") == null);
try std.testing.expect(std.mem.indexOf(u8, out[0..n], "\x1b[24;80H") != null); // clamped
}
test "paintDeltaClipped re-inverts a delta row the selection covers" {
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 12, 4, "row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
defer g.deinit();
var payload: std.ArrayList(u8) = .empty;
defer payload.deinit(alloc);
try proto.appendDeltaHeader(&payload, alloc, .{
.seq = 4,
.history_rows = 0,
.cursor_x = 0,
.cursor_y = 0,
.row_count = 2,
});
// Row 1 is under the highlight, row 0 is not — one frame carrying both
// is the case the two branches have to be told apart in.
try proto.appendDeltaRow(&payload, alloc, 0, "");
try proto.appendDeltaRow(&payload, alloc, 1, "");
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
var h: TestHighlight = .{};
try paintDeltaClipped(alloc, payload.items, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, h.hl(), pipe[1]);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const text = out[0..n];
// Exactly the covered row is inverted: a delta paint that inverted every
// row it touched would flash the whole frame under a held selection, and
// one that inverted none would leave the selection full of holes wherever
// the session was writing.
try std.testing.expectEqual(@as(usize, 1), std.mem.count(u8, text, "\x1b[7m"));
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[4G\x1b[0m\x1b[7m") != null);
// The uncovered row is an ordinary clipped dump of the grid, addressed
// and cleared like any other painted row.
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;1H\x1b[80X\x1b[0mrow-zero") != null);
}
test "paintDeltaClipped brackets the whole paint in one synchronized update" {
// The delta path's flicker defence, and the one layer that can hold it: a
// synchronized update changes WHEN the terminal shows the paint, never WHAT,
// so no rendered grid can tell a torn paint from a whole one. Asserted on
// the ENDS, since only position shows that every row lands inside.
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 80, 30, "\x1b[2;1Hrow");
defer g.deinit();
var payload: std.ArrayList(u8) = .empty;
defer payload.deinit(alloc);
try proto.appendDeltaHeader(&payload, alloc, .{
.seq = 3,
.history_rows = 0,
.cursor_x = 0,
.cursor_y = 1,
.row_count = 1,
});
try proto.appendDeltaRow(&payload, alloc, 1, "");
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try paintDeltaClipped(alloc, payload.items, g, .{ .top = 0, .left = 0, .rows = 24, .cols = 80 }, .{}, pipe[1]);
std.posix.close(pipe[1]);
var out: [4096]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
// Begin: the update opens before the first CUP, and the cursor goes with
// it — a visible cursor stepping through the rows is the same flicker.
try std.testing.expect(std.mem.startsWith(u8, out[0..n], "\x1b[?2026h\x1b[?25l"));
// End: the cursor comes back and the frame is committed, in that order.
// Committing first would show one frame with the cursor still hidden.
try std.testing.expect(std.mem.endsWith(u8, out[0..n], "\x1b[?25h\x1b[?2026l"));
}
test "paint: a delta lands in the tile's rect, every row of it" {
// Every delta row's CUP takes BOTH offsets, not just the cursor's: a frame
// that left some rows at the screen origin tears a held selection across two
// tiles. Judged on the grid — a misplaced row still emits the right address.
const alloc = std.testing.allocator;
const row_off: u16 = 2;
const g = try authoredGrid(alloc, beside_width, 4, "0" ** beside_width ++ "\r\n" ++
"1" ** beside_width);
defer g.deinit();
var payload: std.ArrayList(u8) = .empty;
defer payload.deinit(alloc);
try proto.appendDeltaHeader(&payload, alloc, .{
.seq = 1,
.history_rows = 0,
.cursor_x = 5,
.cursor_y = 0,
.row_count = 2,
});
try proto.appendDeltaRow(&payload, alloc, 0, "");
try proto.appendDeltaRow(&payload, alloc, 1, "");
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try paintDeltaClipped(
alloc,
payload.items,
g,
.{ .top = row_off, .left = beside_off, .rows = 4, .cols = beside_width },
.{},
pipe[1],
);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const screen = try besideScreen(alloc);
defer screen.deinit();
screen.feed(out[0..n]);
const plain = try screen.dumpPlain(alloc);
defer alloc.free(plain);
const lhs = "L" ** (beside_off - 1) ++ "|";
try std.testing.expectEqualStrings(
beside_seed_row ++ "\n" ++ beside_seed_row ++ "\n" ++
lhs ++ "0" ** beside_width ++ "\n" ++
lhs ++ "1" ** beside_width ++ "\n" ++
beside_seed_row ++ "\n" ++ beside_seed_row ++ "\n" ++
beside_seed_row ++ "\n" ++ beside_seed_row,
plain,
);
// The header's cursor (grid row 0, column 5) rides both offsets too.
try std.testing.expectEqual(
Engine.CursorPos{ .x = beside_off + 5, .y = row_off },
screen.cursorPos(),
);
}
test "a pane off the left edge paints inside its own span" {
// col_off 40, 39 cols: CUP lands at column 41 and the erase covers 39
// cells — the bytes a beside-neighbour's survival depends on.
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 40, 4, "row-zero\r\nrow-one\r\nrow-two\r\nrow-three");
defer g.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try renderClipped(alloc, g, .{ .top = 0, .left = 40, .rows = 24, .cols = 39 }, .{}, null, false, pipe[1]);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const text = out[0..n];
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[1;41H\x1b[39X") != null);
try std.testing.expect(std.mem.indexOf(u8, text, "\x1b[2K") == null);
}
// ------------------------------------------------- the beside-pane oracle
// A painter that emits TOO MUCH is invisible to a substring search: every byte
// the assertion wants is present, plus the ones that landed on the neighbour.
// So these replay onto a screen holding two panes and judge the GRID.
/// Screen columns of the fixture below: a 12-column left pane, a rail, then
/// the pane under test from column 13 to the screen's edge.
const beside_cols: u16 = 40;
const beside_off: u16 = 13;
const beside_width: u16 = beside_cols - beside_off;
/// More rows than any tile under test owns, so a paint that strays above or
/// below its band lands on a row still holding the seed.
const beside_rows: u16 = 8;
/// `L` left pane, `|` rail, `R` the pane under test: anything reaching an
/// `L` or the `|` crossed a boundary it does not own.
fn besideSeed(buf: []u8) []const u8 {
@memset(buf[0 .. beside_off - 1], 'L');
buf[beside_off - 1] = '|';
@memset(buf[beside_off..beside_cols], 'R');
return buf[0..beside_cols];
}
/// One untouched screen row: what a neighbour reads as before any paint.
const beside_seed_row = "L" ** (beside_off - 1) ++ "|" ++ "R" ** beside_width;
/// A screen already holding the neighbours — a left pane, a rail, and the
/// columns the tile under test paints into — on every row.
fn besideScreen(alloc: std.mem.Allocator) !*Engine {
var buf: [beside_cols]u8 = undefined;
const screen = try Engine.init(alloc, .{ .cols = beside_cols, .rows = beside_rows });
// DECAWM off is what the client sets at attach, and it is the reason a
// right-hand pane's overrun hides: it piles at the screen edge instead
// of wrapping. A left-hand pane has a neighbour there instead.
screen.feed("\x1b[?7l");
var r: u16 = 0;
while (r < beside_rows) : (r += 1) {
var nb: [16]u8 = undefined;
screen.feed(std.fmt.bufPrint(&nb, "\x1b[{d};1H", .{r + 1}) catch unreachable);
screen.feed(besideSeed(&buf));
}
return screen;
}
/// The whole screen, rows joined by `\n`: a row outside the tile's band
/// must still read as the seed.
fn screenAfter(alloc: std.mem.Allocator, painted: []const u8) ![]const u8 {
const screen = try besideScreen(alloc);
defer screen.deinit();
screen.feed(painted);
return screen.dumpPlain(alloc);
}
/// One row of the screen after the paint lands on it.
fn screenRow(alloc: std.mem.Allocator, painted: []const u8, y: u16) ![]const u8 {
const plain = try screenAfter(alloc, painted);
defer alloc.free(plain);
var it = std.mem.splitScalar(u8, plain, '\n');
var i: u16 = 0;
while (it.next()) |line| : (i += 1) if (i == y) return alloc.dupe(u8, line);
return alloc.dupe(u8, "");
}
test "paint: a pane narrower than the grid paints no cell past its own edge" {
// Latest-wins: another client made the daemon's grid 30 columns while
// this pane is 12. The surplus has nowhere to go but the rail and the
// neighbour across it.
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 30, 4, "x" ** 30);
defer g.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, null, false, pipe[1]);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const row = try screenRow(alloc, out[0..n], 0);
defer alloc.free(row);
try std.testing.expectEqualStrings("x" ** 12 ++ "|" ++ "R" ** 27, row);
}
test "paint: a wide cell astride the pane's edge is dropped, not halved" {
// Eleven narrow cells then a two-column glyph at columns 11-12: the
// pane holds twelve columns, so the glyph does not fit. Emitting it
// spends a column of the rail; emitting half of it is not a character.
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 30, 4, "a" ** 11 ++ "\u{6f22}" ++ "b" ** 17);
defer g.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try renderClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, null, false, pipe[1]);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const row = try screenRow(alloc, out[0..n], 0);
defer alloc.free(row);
try std.testing.expectEqualStrings("a" ** 11 ++ " " ++ "|" ++ "R" ** 27, row);
}
test "paint: a highlight in an offset pane inverts inside the pane" {
// The span's three pieces are positioned with CHA, which is
// screen-absolute: a pane at an offset that emitted a GRID column would
// walk the cursor into its left neighbour and paint the row there.
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, beside_width, 4, "\x1b[2;1H" ++ "y" ** beside_width);
defer g.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
var h: TestHighlight = .{};
try renderClipped(
alloc,
g,
.{ .top = 0, .left = beside_off, .rows = 4, .cols = beside_width },
h.hl(),
null,
false,
pipe[1],
);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
// Row 1 is under the highlight. Inverted or not, every one of its cells
// is a `y` and every cell left of the rail is untouched.
const row = try screenRow(alloc, out[0..n], 1);
defer alloc.free(row);
try std.testing.expectEqualStrings("L" ** (beside_off - 1) ++ "|" ++ "y" ** beside_width, row);
}
test "paint: a delta row wider than the pane stops at the pane's edge" {
// The daemon's rows are grid-wide. Every delta row is re-serialized at
// the pane's own width, so the surplus never reaches the rail.
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 30, 4, "z" ** 30);
defer g.deinit();
var payload: std.ArrayList(u8) = .empty;
defer payload.deinit(alloc);
try proto.appendDeltaHeader(&payload, alloc, .{
.seq = 1,
.history_rows = 0,
.cursor_x = 0,
.cursor_y = 0,
.row_count = 1,
});
try proto.appendDeltaRow(&payload, alloc, 0, "");
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try paintDeltaClipped(alloc, payload.items, g, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, pipe[1]);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const row = try screenRow(alloc, out[0..n], 0);
defer alloc.free(row);
try std.testing.expectEqualStrings("z" ** 12 ++ "|" ++ "R" ** 27, row);
}
test "paint: a history row wider than the pane stops at the pane's edge" {
// `renderScrollback` re-serializes each fetched row at the pane's width:
// same overrun, on the page the user reached for to copy.
const alloc = std.testing.allocator;
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
// Six rows into a four-row tile: the [scroll] marker owns the first, so
// the second is the one with nothing but history on it, and the last two
// have nowhere to go but the tile below.
const blob = "g" ** 30 ++ "\r\n" ++ "h" ** 30 ++ "\r\n" ++ "i" ** 30 ++
"\r\n" ++ "j" ** 30 ++ "\r\n" ++ "k" ** 30 ++ "\r\n" ++ "l" ** 30;
const hist = try authoredGrid(alloc, 30, 6, blob);
defer hist.deinit();
try renderScrollback(alloc, hist.lines, 30, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, false, pipe[1]);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const screen = try screenAfter(alloc, out[0..n]);
defer alloc.free(screen);
var it = std.mem.splitScalar(u8, screen, '\n');
var y: u16 = 0;
while (it.next()) |line| : (y += 1) {
const want = switch (y) {
1 => "h" ** 12 ++ "|" ++ "R" ** 27,
2 => "i" ** 12 ++ "|" ++ "R" ** 27,
3 => "j" ** 12 ++ "|" ++ "R" ** 27,
// The tile is four rows tall; `k` and `l` belong to nobody here.
4...7 => beside_seed_row,
else => continue,
};
try std.testing.expectEqualStrings(want, line);
}
}
test "paint: a repainted row wider than the pane stops at the pane's edge" {
// The drag path repaints single rows, and it is the one a selection
// runs through on every motion report.
const alloc = std.testing.allocator;
const g = try authoredGrid(alloc, 30, 4, "\r\n" ++ "w" ** 30);
defer g.deinit();
const pipe = try std.posix.pipe();
defer std.posix.close(pipe[0]);
try renderRowsClipped(alloc, g, .{ .top = 0, .left = 0, .rows = 4, .cols = 12 }, .{}, &.{1}, pipe[1]);
std.posix.close(pipe[1]);
var out: [8192]u8 = undefined;
const n = try std.posix.read(pipe[0], &out);
const row = try screenRow(alloc, out[0..n], 1);
defer alloc.free(row);
try std.testing.expectEqualStrings("w" ** 12 ++ "|" ++ "R" ** 27, row);
}
// Forces semantic analysis of every pub decl under `zig build test`, so an
// unreferenced decl must at least compile (the silent-module-loss hazard,
// decisions.md). Pub decls only: std.meta.declarations sees nothing private.
test {
std.testing.refAllDeclsRecursive(@This());
}