a73x

63427849

feat: the client grid, pinned against the engine as its oracle

a73x   2026-09-04 18:04

Commit message
feat: the client grid, pinned against the engine as its oracle

grid.zig is the flat cell store a replica will hold instead of a second
emulator: rows of Cell, each slicing its text out of the row's own byte
list, filled from protocol.CellRow and nothing else. It imports only std
and protocol.zig, so it still compiles for wasm32-freestanding, and the
bridge that turns an authored screen into one -- Engine.mirrorInto -- and
the tests that grade it live in engine.zig, where ghostty already is.

Two rules moved off the brief, both because the engine is the authority
and the brief's expected values were not what the engine produces.

dumpPlain drops the trailing blank ROWS. ghostty's plainString ends the
dump at the last row with anything on it, so a 24-row screen holding two
lines dumps two lines and no newline after them; the first draft joined
all 24 and the oracle read back twenty-two extra newlines. Interior blank
rows still stand -- they are a gap the user typed, not an unwritten tail.
The unit test's expected string moved with the rule, "a b\n\n" to "a b".

clipCol on a full-width pane returns the last COLUMN, not the last written
one. On a 6-wide grid holding four cells, the brief expected 3 and both
the engine and the code it shipped return 5: trailing blanks are cells a
pane paints like any other, and only a wide glyph cut by the pane edge
steps the answer inward.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CsWfuJFQbTfGtKZLS5qw4q

src/engine/engine.zig
Old New
@@ -4,6 +4,10 @@
4 const std = @import("std"); 4 const std = @import("std");
5 const vt = @import("ghostty-vt"); 5 const vt = @import("ghostty-vt");
6 const proto = @import("protocol.zig"); 6 const proto = @import("protocol.zig");
7 /// The client-side grid this engine is the oracle for. A direct file import
8 /// rather than the `term` root, so `grid.zig` never pulls ghostty into the
9 /// wasm build by importing back.
10 const Grid = @import("grid.zig").Grid;
7 11
8 /// MuxHandler's own `vt` method shadows the `vt` import inside its body, 12 /// MuxHandler's own `vt` method shadows the `vt` import inside its body,
9 /// so the dep types it names are spelled through these aliases. 13 /// so the dep types it names are spelled through these aliases.
@@ -350,7 +354,7 @@ pub const Engine = struct {
350 /// The last grid column of row `y` that fits in `view`, or null when not one 354 /// The last grid column of row `y` that fits in `view`, or null when not one
351 /// character does. Inwards, unlike `snapWide`: a pane edge is a wall, and 355 /// character does. Inwards, unlike `snapWide`: a pane edge is a wall, and
352 /// half a wide glyph past it is a column stolen from the neighbour. 356 /// half a wide glyph past it is a column stolen from the neighbour.
353 fn clipCol(self: *Engine, y: u16, view: RowView) ?u16 { 357 pub fn clipCol(self: *Engine, y: u16, view: RowView) ?u16 {
354 if (view.cols == 0 or self.term.cols == 0) return null; 358 if (view.cols == 0 or self.term.cols == 0) return null;
355 var hi: u16 = @min(view.cols - 1, @as(u16, @intCast(self.term.cols - 1))); 359 var hi: u16 = @min(view.cols - 1, @as(u16, @intCast(self.term.cols - 1)));
356 const screen = self.term.screens.active; 360 const screen = self.term.screens.active;
@@ -375,7 +379,7 @@ pub const Engine = struct {
375 /// ghostty then emits the WHOLE character, which in `dumpVtRowSpan`'s three 379 /// ghostty then emits the WHOLE character, which in `dumpVtRowSpan`'s three
376 /// pieces emits a straddling one TWICE. Outwards: half a character under the 380 /// pieces emits a straddling one TWICE. Outwards: half a character under the
377 /// pointer means the character is under the pointer. 381 /// pointer means the character is under the pointer.
378 fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } { 382 pub fn snapWide(self: *Engine, y: u16, from: u16, to: u16) struct { from: u16, to: u16 } {
379 const screen = self.term.screens.active; 383 const screen = self.term.screens.active;
380 const last: u16 = @intCast(self.term.cols - 1); 384 const last: u16 = @intCast(self.term.cols - 1);
381 var lo = from; 385 var lo = from;
@@ -695,6 +699,22 @@ pub const Engine = struct {
695 return .{ .first = first, .count = n, .bytes = try list.toOwnedSlice(alloc) }; 699 return .{ .first = first, .count = n, .bytes = try list.toOwnedSlice(alloc) };
696 } 700 }
697 701
702 /// Encode every viewport row into `g` — the test bridge between an
703 /// authored screen and the grid a client would hold, and the only way a
704 /// harness gets a Grid from bytes without a daemon.
705 pub fn mirrorInto(self: *Engine, g: *Grid) !void {
706 if (g.cols != self.term.cols or g.rows != self.term.rows)
707 try g.resize(@intCast(self.term.cols), @intCast(self.term.rows));
708 var y: u16 = 0;
709 while (y < self.term.rows) : (y += 1) {
710 const row = try self.encodeViewportRow(self.alloc, y);
711 defer self.alloc.free(row);
712 try g.applyRow(y, row);
713 }
714 const cur = self.cursorPos();
715 g.cursor = .{ .x = cur.x, .y = cur.y };
716 }
717
698 /// Screen-space rows, zero being the oldest retained. Formatting writes 718 /// Screen-space rows, zero being the oldest retained. Formatting writes
699 /// into a fixed-size allocation: hostile coordinates cannot blow it up. 719 /// into a fixed-size allocation: hostile coordinates cannot blow it up.
700 pub fn extractSelection( 720 pub fn extractSelection(
@@ -1976,3 +1996,91 @@ test "cells: wire size vs VT rows (measurement; the spec's gate reads this)" {
1976 std.debug.print("\ncells-measure {s}: vt={d} cells={d} ratio={d:.2}\n", .{ s.name, v, c, @as(f64, @floatFromInt(c)) / @as(f64, @floatFromInt(v)) }); 1996 std.debug.print("\ncells-measure {s}: vt={d} cells={d} ratio={d:.2}\n", .{ s.name, v, c, @as(f64, @floatFromInt(c)) / @as(f64, @floatFromInt(v)) });
1977 } 1997 }
1978 } 1998 }
1999
2000 /// The engine's plain dump with each row's trailing spaces removed, which is
2001 /// the form a client can hold. ghostty dumps with `trim = false`, so a space
2002 /// a program actually wrote at the end of a row survives into
2003 /// `Engine.dumpPlain`; no replica ever held one, because the VT formatter
2004 /// that fed the old wire trims trailing whitespace (see `dumpVtRowSpan`) and
2005 /// the e2e convergence diff strips it as a formatting difference between two
2006 /// correct grids. The grid trims per row for the same reason, so the oracle
2007 /// compares through the same trim rather than pretending the two dumps agree
2008 /// on bytes nothing downstream distinguishes.
2009 fn trimRowTails(alloc: std.mem.Allocator, s: []const u8) ![]const u8 {
2010 var out: std.ArrayList(u8) = .empty;
2011 errdefer out.deinit(alloc);
2012 var it = std.mem.splitScalar(u8, s, '\n');
2013 var first = true;
2014 while (it.next()) |line| {
2015 if (!first) try out.append(alloc, '\n');
2016 first = false;
2017 try out.appendSlice(alloc, std.mem.trimRight(u8, line, " "));
2018 }
2019 return out.toOwnedSlice(alloc);
2020 }
2021
2022 test "grid oracle: the grid's dumpPlain and cursor agree with the engine's for every screen shape" {
2023 const alloc = std.testing.allocator;
2024 const screens = [_][]const u8{
2025 "plain text\r\nsecond line",
2026 "w\u{6f22}\u{5b57}x e\u{301} \u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467} end",
2027 "\x1b[1;31mred bold\x1b[0m \x1b[44mblue bg\x1b[0m\x1b[K",
2028 "short\r\n\r\n\r\nafter blanks",
2029 "\x1b[?1049h\x1b[HTUI on alt\x1b[5;10Hcursor here",
2030 "line one\r\nline two\r\n" ** 30, // scrolled: history exists, viewport is the tail
2031 "\x1b[3;1Hcol\x1b[3;40Hfar\x1b[8;1H",
2032 "hello \r\nworld ", // typed default spaces at both row ends
2033 "\x1b[1mbold \x1b[0m", // a styled trailing space: text on the wire, not a blank
2034 "\x1b[44mpad \x1b[0m", // a bg-coloured pad, the shape every status bar ends with
2035 };
2036 for (screens) |s| {
2037 var e = try Engine.init(alloc, .{ .cols = 80, .rows = 24 });
2038 defer e.deinit();
2039 e.feed(s);
2040 const g = try Grid.init(alloc, 1, 1);
2041 defer g.deinit();
2042 try e.mirrorInto(g);
2043 const raw = try e.dumpPlain(alloc);
2044 defer alloc.free(raw);
2045 const want = try trimRowTails(alloc, raw);
2046 defer alloc.free(want);
2047 const got = try g.dumpPlain(alloc);
2048 defer alloc.free(got);
2049 try std.testing.expectEqualStrings(want, got);
2050 try std.testing.expectEqual(e.cursorPos().x, g.cursor.x);
2051 try std.testing.expectEqual(e.cursorPos().y, g.cursor.y);
2052 }
2053 }
2054
2055 test "grid oracle: clipCol and snapWide agree with the engine's on rows with wide glyphs" {
2056 const alloc = std.testing.allocator;
2057 var e = try Engine.init(alloc, .{ .cols = 10, .rows = 2 });
2058 defer e.deinit();
2059 // Two rows whose wide glyphs sit in different columns, so an answer read
2060 // off row 0 is wrong for row 1 and the sweep says which row it asked.
2061 e.feed("\u{6f22}a\u{5b57}b\r\na\u{6f22}b\u{5b57}");
2062 const g = try Grid.init(alloc, 1, 1);
2063 defer g.deinit();
2064 try e.mirrorInto(g);
2065 // A pane that starts at screen column 7: both sides answer in GRID
2066 // columns, so the offset must not reach the answer.
2067 const col_off: u16 = 7;
2068 var y: u16 = 0;
2069 while (y < 2) : (y += 1) {
2070 var cols: u16 = 0;
2071 while (cols <= 10) : (cols += 1) {
2072 const view = Engine.RowView{ .col_off = col_off, .cols = cols };
2073 try std.testing.expectEqual(e.clipCol(y, view), g.clipCol(y, .{ .col_off = col_off, .cols = cols }));
2074 }
2075 var from: u16 = 0;
2076 while (from < 6) : (from += 1) {
2077 var to: u16 = from;
2078 while (to < 6) : (to += 1) {
2079 const a = e.snapWide(y, from, to);
2080 const b = g.snapWide(y, from, to);
2081 try std.testing.expectEqual(a.from, b.from);
2082 try std.testing.expectEqual(a.to, b.to);
2083 }
2084 }
2085 }
2086 }
src/engine/grid.zig
Old New
@@ -0,0 +1,338 @@
1 //! The client's grid: what a replica holds instead of an emulator. Cells
2 //! arrive already parsed (protocol.CellRow) and are copied into rows; the
3 //! only rules here are the two wide-glyph rules a painter needs and the
4 //! plain-text dump every harness compares against the daemon's.
5 //!
6 //! Deliberately platform-free: no posix, no fds, no clocks, no escape
7 //! bytes — this compiles for wasm32-freestanding and sits under rule 4.
8 const std = @import("std");
9 const proto = @import("protocol.zig");
10
11 pub const CursorPos = struct { x: u16, y: u16 };
12
13 pub const Cell = struct {
14 style: proto.CellStyle = .{},
15 wide: proto.Wide = .narrow,
16 text_off: u32 = 0,
17 text_len: u8 = 0,
18
19 pub fn isBlank(self: Cell) bool {
20 return self.text_len == 0 and self.wide == .narrow and self.style.isDefault();
21 }
22 };
23
24 pub const Row = struct {
25 cells: []Cell,
26 /// Every cell's text, back to back; a cell slices into it.
27 text: std.ArrayListUnmanaged(u8) = .empty,
28
29 pub fn textOf(self: *const Row, c: Cell) []const u8 {
30 return self.text.items[c.text_off .. c.text_off + c.text_len];
31 }
32
33 fn blank(self: *Row) void {
34 @memset(self.cells, .{});
35 self.text.clearRetainingCapacity();
36 }
37
38 pub fn deinit(self: *Row, alloc: std.mem.Allocator) void {
39 alloc.free(self.cells);
40 self.text.deinit(alloc);
41 }
42 };
43
44 pub const RowView = struct { col_off: u16, cols: u16 };
45
46 /// Decode one CellRow into `into` (already sized to `cols`). Two passes on
47 /// purpose — validate, then fill — so a bad payload leaves the row as it
48 /// was without a scratch copy: the caller resyncs from a snapshot, and half
49 /// a row would paint as a lie until then.
50 pub fn decodeRow(alloc: std.mem.Allocator, into: *Row, bytes: []const u8, cols: u16) ![]const u8 {
51 if (cols > proto.max_cols) return error.BadPayload;
52 var check = try proto.CellRowReader.init(bytes);
53 if (check.ncells > cols) return error.BadPayload;
54 var text_total: usize = 0;
55 while (try check.next()) |c| text_total += c.text.len;
56 // Every byte has been read once and is well-formed; nothing below can fail
57 // except the allocation, which happens before the row is touched.
58 try into.text.ensureTotalCapacity(alloc, text_total);
59 into.blank();
60 var r = proto.CellRowReader.init(bytes) catch unreachable;
61 var x: usize = 0;
62 while (r.next() catch unreachable) |c| : (x += 1) {
63 into.cells[x] = .{
64 .style = c.style,
65 .wide = c.wide,
66 .text_off = @intCast(into.text.items.len),
67 .text_len = @intCast(c.text.len),
68 };
69 into.text.appendSliceAssumeCapacity(c.text);
70 }
71 return r.remaining();
72 }
73
74 /// A column span, inclusive at both ends.
75 pub const ColSpan = struct { from: u16, to: u16 };
76
77 /// The last column of `r` (a row `cols` wide) that fits in `view`, or null
78 /// when not one character does. Inward: half a wide glyph past a pane edge
79 /// is a column stolen from the neighbour.
80 pub fn clipColOf(r: *const Row, cols: u16, view: RowView) ?u16 {
81 if (view.cols == 0 or cols == 0) return null;
82 var hi: u16 = @min(view.cols - 1, cols - 1);
83 if (r.cells[hi].wide == .wide) {
84 if (hi == 0) return null;
85 hi -= 1;
86 }
87 return hi;
88 }
89
90 /// Widen [from, to] to whole glyphs. Outward: half a character under the
91 /// pointer means the character is under the pointer.
92 ///
93 /// The two ends are accepted in either order and normalised, where the
94 /// engine's snapWide requires from <= to and its caller asserts it: a drag
95 /// runs backwards as often as forwards, and the grid is what a painter calls.
96 /// A column at or past `cols` reads no cell and moves nothing, which is what
97 /// the engine does with one (its pin off the end of the row is null).
98 pub fn snapWideOf(r: *const Row, cols: u16, from: u16, to: u16) ColSpan {
99 var lo = @min(from, to);
100 var hi = @max(from, to);
101 if (lo > 0 and lo < cols and r.cells[lo].wide == .spacer_tail) lo -= 1;
102 if (hi + 1 < cols and r.cells[hi].wide == .wide) hi += 1;
103 return .{ .from = lo, .to = hi };
104 }
105
106 pub fn decodeRows(alloc: std.mem.Allocator, bytes: []const u8, count: u16, cols: u16) ![]Row {
107 const rows = try alloc.alloc(Row, count);
108 var made: usize = 0;
109 errdefer {
110 for (rows[0..made]) |*r| r.deinit(alloc);
111 alloc.free(rows);
112 }
113 var rest = bytes;
114 for (rows) |*r| {
115 r.* = .{ .cells = try alloc.alloc(Cell, cols) };
116 made += 1;
117 @memset(r.cells, .{});
118 rest = try decodeRow(alloc, r, rest, cols);
119 }
120 return rows;
121 }
122
123 pub fn freeRows(alloc: std.mem.Allocator, rows: []Row) void {
124 for (rows) |*r| r.deinit(alloc);
125 alloc.free(rows);
126 }
127
128 pub const Grid = struct {
129 alloc: std.mem.Allocator,
130 cols: u16,
131 rows: u16,
132 cursor: CursorPos = .{ .x = 0, .y = 0 },
133 lines: []Row,
134
135 pub fn init(alloc: std.mem.Allocator, cols: u16, rows: u16) !*Grid {
136 const g = try alloc.create(Grid);
137 errdefer alloc.destroy(g);
138 g.* = .{ .alloc = alloc, .cols = cols, .rows = rows, .lines = &.{} };
139 try g.allocLines(cols, rows);
140 return g;
141 }
142
143 fn allocLines(self: *Grid, cols: u16, rows: u16) !void {
144 const lines = try self.alloc.alloc(Row, rows);
145 var made: usize = 0;
146 errdefer {
147 for (lines[0..made]) |*r| r.deinit(self.alloc);
148 self.alloc.free(lines);
149 }
150 for (lines) |*r| {
151 r.* = .{ .cells = try self.alloc.alloc(Cell, cols) };
152 made += 1;
153 @memset(r.cells, .{});
154 }
155 self.freeLines();
156 self.lines = lines;
157 self.cols = cols;
158 self.rows = rows;
159 }
160
161 fn freeLines(self: *Grid) void {
162 for (self.lines) |*r| r.deinit(self.alloc);
163 self.alloc.free(self.lines);
164 self.lines = &.{};
165 }
166
167 pub fn deinit(self: *Grid) void {
168 self.freeLines();
169 self.alloc.destroy(self);
170 }
171
172 /// A resize is a blank grid: the snapshot that carries it repaints every row.
173 pub fn resize(self: *Grid, cols: u16, rows: u16) !void {
174 try self.allocLines(cols, rows);
175 self.cursor = .{ .x = 0, .y = 0 };
176 }
177
178 pub fn clear(self: *Grid) void {
179 for (self.lines) |*r| r.blank();
180 self.cursor = .{ .x = 0, .y = 0 };
181 }
182
183 pub fn applyRow(self: *Grid, y: u16, bytes: []const u8) !void {
184 if (y >= self.rows) return error.BadPayload;
185 _ = try decodeRow(self.alloc, &self.lines[y], bytes, self.cols);
186 }
187
188 pub fn row(self: *const Grid, y: u16) *const Row {
189 return &self.lines[y];
190 }
191
192 /// The screen as text: a space per blank or spacer-less empty cell,
193 /// nothing for a spacer, rows joined by newlines, the trailing blank ROWS
194 /// dropped along with the newlines that would have separated them (a
195 /// screen is 24 rows tall whatever is on it, so an unwritten tail is not
196 /// text), and every row's trailing spaces trimmed.
197 ///
198 /// Interior blank rows stay: they are a gap the user typed.
199 ///
200 /// The trailing-space trim is where this differs from Engine.dumpPlain,
201 /// which is ghostty's plainString and dumps with `trim = false`, so a
202 /// space a program wrote at the end of a row survives it. A client never
203 /// holds such a space, and never did: the VT formatter that fed the old
204 /// wire trims trailing whitespace before a row leaves the daemon, and the
205 /// e2e convergence diff strips trailing whitespace on both sides as a
206 /// formatting difference between two correct grids. Trimming here makes
207 /// the two agree on everything a client can observe. The engine.zig
208 /// oracle test compares through the same trim and is the authority.
209 pub fn dumpPlain(self: *const Grid, alloc: std.mem.Allocator) ![]const u8 {
210 var out: std.ArrayList(u8) = .empty;
211 errdefer out.deinit(alloc);
212 for (self.lines, 0..) |*r, y| {
213 const start = out.items.len;
214 for (r.cells) |c| {
215 switch (c.wide) {
216 .spacer_tail, .spacer_head => continue,
217 .narrow, .wide => {},
218 }
219 if (c.text_len == 0) try out.append(alloc, ' ') else try out.appendSlice(alloc, r.textOf(c));
220 }
221 while (out.items.len > start and out.items[out.items.len - 1] == ' ') out.items.len -= 1;
222 if (y + 1 < self.lines.len) try out.append(alloc, '\n');
223 }
224 while (out.items.len > 0 and out.items[out.items.len - 1] == '\n') out.items.len -= 1;
225 return out.toOwnedSlice(alloc);
226 }
227
228 pub fn clipCol(self: *const Grid, y: u16, view: RowView) ?u16 {
229 return clipColOf(&self.lines[y], self.cols, view);
230 }
231
232 pub fn snapWide(self: *const Grid, y: u16, from: u16, to: u16) ColSpan {
233 return snapWideOf(&self.lines[y], self.cols, from, to);
234 }
235 };
236
237 test "grid: applyRow fills the row and blanks the rest of the width" {
238 const alloc = std.testing.allocator;
239 const g = try Grid.init(alloc, 6, 2);
240 defer g.deinit();
241 var list: std.ArrayList(u8) = .empty;
242 defer list.deinit(alloc);
243 var w = try proto.CellRowWriter.begin(&list, alloc);
244 try w.cell(.{ .fg = proto.colorPalette(2) }, .narrow, "h");
245 try w.cell(.{}, .wide, "漢");
246 try w.cell(.{}, .spacer_tail, "");
247 w.finish();
248 try g.applyRow(1, list.items);
249 const r = g.row(1);
250 try std.testing.expectEqualStrings("h", r.textOf(r.cells[0]));
251 try std.testing.expectEqual(proto.colorPalette(2), r.cells[0].style.fg);
252 try std.testing.expectEqual(proto.Wide.wide, r.cells[1].wide);
253 try std.testing.expectEqual(proto.Wide.spacer_tail, r.cells[2].wide);
254 try std.testing.expectEqual(@as(u8, 0), r.cells[3].text_len);
255 try std.testing.expect(r.cells[5].style.isDefault());
256 // Row 0 was never written and is blank.
257 try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
258 }
259
260 test "grid: a row wider than the grid is BadPayload and leaves the row untouched" {
261 const alloc = std.testing.allocator;
262 const g = try Grid.init(alloc, 2, 1);
263 defer g.deinit();
264 var list: std.ArrayList(u8) = .empty;
265 defer list.deinit(alloc);
266 var w = try proto.CellRowWriter.begin(&list, alloc);
267 try w.cell(.{}, .narrow, "a");
268 try w.cell(.{}, .narrow, "b");
269 try w.cell(.{}, .narrow, "c");
270 w.finish();
271 try std.testing.expectError(error.BadPayload, g.applyRow(0, list.items));
272 try std.testing.expectEqual(@as(u8, 0), g.row(0).cells[0].text_len);
273 }
274
275 test "grid: dumpPlain trims trailing blanks per row, joins rows with newlines, and drops the blank tail" {
276 const alloc = std.testing.allocator;
277 const g = try Grid.init(alloc, 6, 3);
278 defer g.deinit();
279 var list: std.ArrayList(u8) = .empty;
280 defer list.deinit(alloc);
281 var w = try proto.CellRowWriter.begin(&list, alloc);
282 try w.cell(.{}, .narrow, "a");
283 try w.cell(.{}, .narrow, "");
284 try w.cell(.{}, .narrow, "b");
285 w.finish();
286 try g.applyRow(0, list.items);
287 const s = try g.dumpPlain(alloc);
288 defer alloc.free(s);
289 // Rows 1 and 2 were never written, so the engine's dump ends at row 0.
290 try std.testing.expectEqualStrings("a b", s);
291 }
292
293 test "grid: clipCol steps inward off a wide glyph at the pane edge; snapWide steps outward" {
294 const alloc = std.testing.allocator;
295 const g = try Grid.init(alloc, 6, 2);
296 defer g.deinit();
297 var list: std.ArrayList(u8) = .empty;
298 defer list.deinit(alloc);
299 var w = try proto.CellRowWriter.begin(&list, alloc);
300 try w.cell(.{}, .narrow, "a");
301 try w.cell(.{}, .wide, "漢");
302 try w.cell(.{}, .spacer_tail, "");
303 try w.cell(.{}, .narrow, "b");
304 w.finish();
305 // Row 1, not row 0: both answers are for the row asked about.
306 try g.applyRow(1, list.items);
307 // The pane starts at screen column 4; every answer below is a GRID column,
308 // so the offset must not reach it.
309 // A pane 2 wide would cut 漢 in half: the last column that fits is 0.
310 try std.testing.expectEqual(@as(?u16, 0), g.clipCol(1, .{ .col_off = 4, .cols = 2 }));
311 // A pane as wide as the grid clips at the last COLUMN, not at the last
312 // written one: trailing blanks are cells a pane paints like any other.
313 try std.testing.expectEqual(@as(?u16, 5), g.clipCol(1, .{ .col_off = 4, .cols = 6 }));
314 try std.testing.expectEqual(@as(?u16, null), g.clipCol(1, .{ .col_off = 4, .cols = 0 }));
315 // The unwritten row 0 has no wide glyph, so nothing steps inward there.
316 try std.testing.expectEqual(@as(?u16, 1), g.clipCol(0, .{ .col_off = 4, .cols = 2 }));
317 // A drag from the spacer to the wide cell covers the whole glyph.
318 const s = g.snapWide(1, 2, 1);
319 try std.testing.expectEqual(@as(u16, 1), s.from);
320 try std.testing.expectEqual(@as(u16, 2), s.to);
321 }
322
323 test "grid: decodeRows reads a dense run and refuses a short one" {
324 const alloc = std.testing.allocator;
325 var list: std.ArrayList(u8) = .empty;
326 defer list.deinit(alloc);
327 var i: usize = 0;
328 while (i < 2) : (i += 1) {
329 var w = try proto.CellRowWriter.begin(&list, alloc);
330 try w.cell(.{}, .narrow, "x");
331 w.finish();
332 }
333 const rows = try decodeRows(alloc, list.items, 2, 4);
334 defer freeRows(alloc, rows);
335 try std.testing.expectEqual(@as(usize, 2), rows.len);
336 try std.testing.expectEqualStrings("x", rows[1].textOf(rows[1].cells[0]));
337 try std.testing.expectError(error.BadPayload, decodeRows(alloc, list.items, 3, 4));
338 }
src/engine/protocol.zig
Old New
@@ -54,6 +54,9 @@ pub const MsgType = enum(u8) {
54 54
55 pub const max_payload = 16 * 1024 * 1024; 55 pub const max_payload = 16 * 1024 * 1024;
56 56
57 /// The widest row a client will decode; a wider claim is a bad payload, not an allocation.
58 pub const max_cols: u16 = 4096;
59
57 /// One type byte + u32 LE payload length. 60 /// One type byte + u32 LE payload length.
58 pub const frame_header_len = 5; 61 pub const frame_header_len = 5;
59 62
src/engine/term.zig
Old New
@@ -1,16 +1,18 @@
1 //! The terminal component: the wire contract, the authoritative engine, 1 //! The terminal component: the wire contract, the authoritative engine,
2 //! the delta minting that feeds replicas, and the one replay core. One 2 //! the delta minting that feeds replicas, and the one replay core. One
3 //! table row — the four files are one owner, and only this root is a seam, 3 //! table row — every file below is one owner, and only this root is a seam,
4 //! so a second module claiming any of them is a file-in-multiple-modules 4 //! so a second module claiming any of them is a file-in-multiple-modules
5 //! compile error. 5 //! compile error.
6 pub const protocol = @import("protocol.zig"); 6 pub const protocol = @import("protocol.zig");
7 pub const engine = @import("engine.zig"); 7 pub const engine = @import("engine.zig");
8 pub const delta = @import("delta.zig"); 8 pub const delta = @import("delta.zig");
9 pub const replica = @import("replica.zig"); 9 pub const replica = @import("replica.zig");
10 pub const grid = @import("grid.zig");
10 11
11 test { 12 test {
12 _ = protocol; 13 _ = protocol;
13 _ = engine; 14 _ = engine;
14 _ = delta; 15 _ = delta;
15 _ = replica; 16 _ = replica;
17 _ = grid;
16 } 18 }