a73x

src/engine/grid.zig

Ref:   Size: 13.8 KiB   History

//! The client's grid: what a replica holds instead of an emulator. Cells
//! arrive already parsed (protocol.CellRow) and are copied into rows; the
//! only rules here are the two wide-glyph rules a painter needs and the
//! plain-text dump every harness compares against the daemon's.
//!
//! Deliberately platform-free: no posix, no fds, no clocks, no escape
//! bytes — this compiles for wasm32-freestanding and sits under rule 4.
const std = @import("std");
const proto = @import("protocol.zig");

pub const CursorPos = struct { x: u16, y: u16 };

pub const Cell = struct {
    style: proto.CellStyle = .{},
    wide: proto.Wide = .narrow,
    text_off: u32 = 0,
    text_len: u8 = 0,

    pub fn isBlank(self: Cell) bool {
        return self.text_len == 0 and self.wide == .narrow and self.style.isDefault();
    }
};

pub const Row = struct {
    cells: []Cell,
    /// Every cell's text, back to back; a cell slices into it.
    text: std.ArrayListUnmanaged(u8) = .empty,

    pub fn textOf(self: *const Row, c: Cell) []const u8 {
        return self.text.items[c.text_off .. c.text_off + c.text_len];
    }

    fn blank(self: *Row) void {
        @memset(self.cells, .{});
        self.text.clearRetainingCapacity();
    }

    pub fn deinit(self: *Row, alloc: std.mem.Allocator) void {
        alloc.free(self.cells);
        self.text.deinit(alloc);
    }
};

pub const RowView = struct { col_off: u16, cols: u16 };

/// Decode one CellRow into `into` (already sized to `cols`). Two passes on
/// purpose — validate, then fill — so a bad payload leaves the row as it
/// was without a scratch copy: the caller resyncs from a snapshot, and half
/// a row would paint as a lie until then.
pub fn decodeRow(alloc: std.mem.Allocator, into: *Row, bytes: []const u8, cols: u16) ![]const u8 {
    if (cols > proto.max_cols) return error.BadPayload;
    var check = try proto.CellRowReader.init(bytes);
    if (check.ncells > cols) return error.BadPayload;
    var text_total: usize = 0;
    while (try check.next()) |c| text_total += c.text.len;
    // Every byte has been read once and is well-formed; nothing below can fail
    // except the allocation, which happens before the row is touched.
    try into.text.ensureTotalCapacity(alloc, text_total);
    into.blank();
    var r = proto.CellRowReader.init(bytes) catch unreachable;
    var x: usize = 0;
    while (r.next() catch unreachable) |c| : (x += 1) {
        into.cells[x] = .{
            .style = c.style,
            .wide = c.wide,
            .text_off = @intCast(into.text.items.len),
            .text_len = @intCast(c.text.len),
        };
        into.text.appendSliceAssumeCapacity(c.text);
    }
    return r.remaining();
}

/// A column span, inclusive at both ends.
pub const ColSpan = struct { from: u16, to: u16 };

/// The last column of `r` (a row `cols` wide) that fits in `view`, or null
/// when not one character does. Inward: half a wide glyph past a pane edge
/// is a column stolen from the neighbour.
pub fn clipColOf(r: *const Row, cols: u16, view: RowView) ?u16 {
    if (view.cols == 0 or cols == 0) return null;
    var hi: u16 = @min(view.cols - 1, cols - 1);
    if (r.cells[hi].wide == .wide) {
        if (hi == 0) return null;
        hi -= 1;
    }
    return hi;
}

/// Widen [from, to] to whole glyphs. Outward: half a character under the
/// pointer means the character is under the pointer.
///
/// The two ends are accepted in either order and normalised, where the
/// engine's snapWide requires from <= to and its caller asserts it: a drag
/// runs backwards as often as forwards, and the grid is what a painter calls.
/// A column at or past `cols` reads no cell and moves nothing, which is what
/// the engine does with one (its pin off the end of the row is null).
pub fn snapWideOf(r: *const Row, cols: u16, from: u16, to: u16) ColSpan {
    var lo = @min(from, to);
    var hi = @max(from, to);
    if (lo > 0 and lo < cols and r.cells[lo].wide == .spacer_tail) lo -= 1;
    if (hi + 1 < cols and r.cells[hi].wide == .wide) hi += 1;
    return .{ .from = lo, .to = hi };
}

/// `cols` is the width every row is sized to — a grid's own width, so a
/// decoded row drops straight into it. `null` sizes each row to its own
/// `ncells` instead, for a reader that holds no grid: `mux a` attaches at 0x0
/// and never paints, so the only width it could otherwise name is the
/// protocol maximum, and a long span at 4096 cells a row is hundreds of
/// megabytes for text that is mostly blank. The text is the same either way,
/// because the cells past `ncells` are blanks `dumpRowsPlain` trims off the
/// end of the row.
pub fn decodeRows(alloc: std.mem.Allocator, bytes: []const u8, count: u16, cols: ?u16) ![]Row {
    const rows = try alloc.alloc(Row, count);
    var made: usize = 0;
    errdefer {
        for (rows[0..made]) |*r| r.deinit(alloc);
        alloc.free(rows);
    }
    var rest = bytes;
    for (rows) |*r| {
        const w = cols orelse (try proto.CellRowReader.init(rest)).ncells;
        r.* = .{ .cells = try alloc.alloc(Cell, w) };
        made += 1;
        @memset(r.cells, .{});
        rest = try decodeRow(alloc, r, rest, w);
    }
    return rows;
}

pub fn freeRows(alloc: std.mem.Allocator, rows: []Row) void {
    for (rows) |*r| r.deinit(alloc);
    alloc.free(rows);
}

/// Rows as text: a space per blank or spacer-less empty cell, nothing for a
/// spacer, rows joined by newlines, the trailing blank ROWS dropped along with
/// the newlines that would have separated them (a screen is 24 rows tall
/// whatever is on it, so an unwritten tail is not text), and every row's
/// trailing spaces trimmed.
///
/// Interior blank rows stay: they are a gap the user typed.
///
/// The trailing-space trim is where this differs from Engine.dumpPlain, which
/// is ghostty's plainString and dumps with `trim = false`, so a space a
/// program wrote at the end of a row survives it. A client never holds such a
/// space, and never did: the VT formatter that fed the old wire trims trailing
/// whitespace before a row leaves the daemon, and the e2e convergence diff
/// strips trailing whitespace on both sides as a formatting difference between
/// two correct grids. Trimming here makes the two agree on everything a client
/// can observe. The engine.zig oracle test compares through the same trim and
/// is the authority.
pub fn dumpRowsPlain(alloc: std.mem.Allocator, rows: []const Row) ![]u8 {
    var out: std.ArrayList(u8) = .empty;
    errdefer out.deinit(alloc);
    for (rows, 0..) |*r, y| {
        const start = out.items.len;
        for (r.cells) |c| {
            switch (c.wide) {
                .spacer_tail, .spacer_head => continue,
                .narrow, .wide => {},
            }
            if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
        }
        while (out.items.len > start and out.items[out.items.len - 1] == ' ') out.items.len -= 1;
        if (y + 1 < rows.len) try out.append(alloc, '\n');
    }
    while (out.items.len > 0 and out.items[out.items.len - 1] == '\n') out.items.len -= 1;
    return out.toOwnedSlice(alloc);
}

pub const Grid = struct {
    alloc: std.mem.Allocator,
    cols: u16,
    rows: u16,
    cursor: CursorPos = .{ .x = 0, .y = 0 },
    lines: []Row,

    pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !*Grid {
        const g = try alloc.create(Grid);
        errdefer alloc.destroy(g);
        g.* = .{ .alloc = alloc, .cols = cols, .rows = rows, .lines = &.{} };
        try g.allocLines(cols, rows);
        return g;
    }

    fn allocLines(self: *Grid, cols: u16, rows: u16) !void {
        const lines = try self.alloc.alloc(Row, rows);
        var made: usize = 0;
        errdefer {
            for (lines[0..made]) |*r| r.deinit(self.alloc);
            self.alloc.free(lines);
        }
        for (lines) |*r| {
            r.* = .{ .cells = try self.alloc.alloc(Cell, cols) };
            made += 1;
            @memset(r.cells, .{});
        }
        self.freeLines();
        self.lines = lines;
        self.cols = cols;
        self.rows = rows;
    }

    fn freeLines(self: *Grid) void {
        for (self.lines) |*r| r.deinit(self.alloc);
        self.alloc.free(self.lines);
        self.lines = &.{};
    }

    pub fn deinit(self: *Grid) void {
        self.freeLines();
        self.alloc.destroy(self);
    }

    /// A resize is a blank grid: the snapshot that carries it repaints every row.
    pub fn resize(self: *Grid, cols: u16, rows: u16) !void {
        try self.allocLines(cols, rows);
        self.cursor = .{ .x = 0, .y = 0 };
    }

    pub fn clear(self: *Grid) void {
        for (self.lines) |*r| r.blank();
        self.cursor = .{ .x = 0, .y = 0 };
    }

    pub fn applyRow(self: *Grid, y: u16, bytes: []const u8) !void {
        if (y >= self.rows) return error.BadPayload;
        _ = try decodeRow(self.alloc, &self.lines[y], bytes, self.cols);
    }

    pub fn row(self: *const Grid, y: u16) *const Row {
        return &self.lines[y];
    }

    /// The screen as text. See `dumpRowsPlain`, which this is over the grid's
    /// own rows; a scrollback span decoded with `decodeRows` goes through the
    /// same function, so a client and an agent read one screen the same way.
    pub fn dumpPlain(self: *const Grid, alloc: std.mem.Allocator) ![]const u8 {
        return dumpRowsPlain(alloc, self.lines);
    }
};

test "grid: applyRow fills the row and blanks the rest of the width" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 6, 2);
    defer g.deinit();
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try proto.CellRowWriter.begin(&list, alloc);
    try w.cell(.{ .fg = proto.colorPalette(2) }, .narrow, "h");
    try w.cell(.{}, .wide, "漢");
    try w.cell(.{}, .spacer_tail, "");
    w.finish();
    try g.applyRow(1, list.items);
    const r = g.row(1);
    try std.testing.expectEqualStrings("h", r.textOf(r.cells[0]));
    try std.testing.expectEqual(proto.colorPalette(2), r.cells[0].style.fg);
    try std.testing.expectEqual(proto.Wide.wide, r.cells[1].wide);
    try std.testing.expectEqual(proto.Wide.spacer_tail, r.cells[2].wide);
    try std.testing.expectEqual(@as(u8, 0), r.cells[3].text_len);
    try std.testing.expect(r.cells[5].style.isDefault());
    // Row 0 was never written and is blank.
    try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
}

test "grid: a row wider than the grid is BadPayload and leaves the row untouched" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 2, 1);
    defer g.deinit();
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try proto.CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    try w.cell(.{}, .narrow, "b");
    try w.cell(.{}, .narrow, "c");
    w.finish();
    try std.testing.expectError(error.BadPayload, g.applyRow(0, list.items));
    try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
}

test "grid: dumpPlain trims trailing blanks per row, joins rows with newlines, and drops the blank tail" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 6, 3);
    defer g.deinit();
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try proto.CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    try w.cell(.{}, .narrow, "");
    try w.cell(.{}, .narrow, "b");
    w.finish();
    try g.applyRow(0, list.items);
    const s = try g.dumpPlain(alloc);
    defer alloc.free(s);
    // Rows 1 and 2 were never written, so the engine's dump ends at row 0.
    try std.testing.expectEqualStrings("a b", s);
}

test "grid: clipColOf steps inward off a wide glyph at the pane edge; snapWideOf steps outward" {
    const alloc = std.testing.allocator;
    const g = try Grid.init(alloc, 6, 2);
    defer g.deinit();
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var w = try proto.CellRowWriter.begin(&list, alloc);
    try w.cell(.{}, .narrow, "a");
    try w.cell(.{}, .wide, "漢");
    try w.cell(.{}, .spacer_tail, "");
    try w.cell(.{}, .narrow, "b");
    w.finish();
    // Row 1, not row 0: both answers are for the row asked about.
    try g.applyRow(1, list.items);
    // The pane starts at screen column 4; every answer below is a GRID column,
    // so the offset must not reach it.
    // A pane 2 wide would cut 漢 in half: the last column that fits is 0.
    try std.testing.expectEqual(@as(?u16, 0), clipColOf(&g.lines[1], g.cols, .{ .col_off = 4, .cols = 2 }));
    // A pane as wide as the grid clips at the last COLUMN, not at the last
    // written one: trailing blanks are cells a pane paints like any other.
    try std.testing.expectEqual(@as(?u16, 5), clipColOf(&g.lines[1], g.cols, .{ .col_off = 4, .cols = 6 }));
    try std.testing.expectEqual(@as(?u16, null), clipColOf(&g.lines[1], g.cols, .{ .col_off = 4, .cols = 0 }));
    // The unwritten row 0 has no wide glyph, so nothing steps inward there.
    try std.testing.expectEqual(@as(?u16, 1), clipColOf(&g.lines[0], g.cols, .{ .col_off = 4, .cols = 2 }));
    // A drag from the spacer to the wide cell covers the whole glyph.
    const s = snapWideOf(&g.lines[1], g.cols, 2, 1);
    try std.testing.expectEqual(@as(u16, 1), s.from);
    try std.testing.expectEqual(@as(u16, 2), s.to);
}

test "grid: decodeRows reads a dense run and refuses a short one" {
    const alloc = std.testing.allocator;
    var list: std.ArrayList(u8) = .empty;
    defer list.deinit(alloc);
    var i: usize = 0;
    while (i < 2) : (i += 1) {
        var w = try proto.CellRowWriter.begin(&list, alloc);
        try w.cell(.{}, .narrow, "x");
        w.finish();
    }
    const rows = try decodeRows(alloc, list.items, 2, 4);
    defer freeRows(alloc, rows);
    try std.testing.expectEqual(@as(usize, 2), rows.len);
    try std.testing.expectEqualStrings("x", rows[1].textOf(rows[1].cells[0]));
    try std.testing.expectError(error.BadPayload, decodeRows(alloc, list.items, 3, 4));
}