a73x

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());
}