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