a73x

6c3ecd04

feat: the CellRow codec, a grid row as runs of styled cells

a73x   2026-09-04 16:59

Commit message
feat: the CellRow codec, a grid row as runs of styled cells

One grid row on the wire as runs of styled cells, so a client can copy
cells into a grid instead of parsing VT. Nothing sends or reads these
yet; the daemon encoder and the client grid come next.

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

src/engine/protocol.zig
Old New
@@ -1263,6 +1263,174 @@ pub fn deltaRowIterator(payload: []const u8) DeltaRowIterator {
1263 return .{ .rest = payload[@min(payload.len, delta_header_len)..] }; 1263 return .{ .rest = payload[@min(payload.len, delta_header_len)..] };
1264 } 1264 }
1265 1265
1266 // ---------------------------------------------------------------------------
1267 // CellRow: one grid row as runs of styled cells. The client copies these into
1268 // a grid and never parses VT; the daemon's ghostty is the only parser left.
1269 // A row ends at the last cell that is not a default-style blank, and the
1270 // reader's caller fills the rest of the width with blanks.
1271
1272 pub const Wide = enum(u2) { narrow = 0, wide = 1, spacer_tail = 2, spacer_head = 3 };
1273
1274 pub const color_none: u32 = 0;
1275
1276 pub fn colorPalette(index: u8) u32 {
1277 return (1 << 24) | @as(u32, index);
1278 }
1279
1280 pub fn colorRgb(r: u8, g: u8, b: u8) u32 {
1281 return (2 << 24) | (@as(u32, r) << 16) | (@as(u32, g) << 8) | @as(u32, b);
1282 }
1283
1284 pub const CellStyle = struct {
1285 fg: u32 = color_none,
1286 bg: u32 = color_none,
1287 ul: u32 = color_none,
1288 /// ghostty Style.Flags bit order: bold 0, italic 1, faint 2, blink 3,
1289 /// inverse 4, invisible 5, strikethrough 6, overline 7, underline 8-10.
1290 flags: u16 = 0,
1291
1292 pub fn eql(a: CellStyle, b: CellStyle) bool {
1293 return a.fg == b.fg and a.bg == b.bg and a.ul == b.ul and a.flags == b.flags;
1294 }
1295
1296 pub fn isDefault(self: CellStyle) bool {
1297 return self.eql(.{});
1298 }
1299 };
1300
1301 /// Run flag bit: every cell in the run is one byte 0x20..0x7E, narrow, and
1302 /// carries no head byte. Dense text is what a wire pays for most.
1303 pub const run_ascii: u16 = 1 << 15;
1304 pub const run_header_len = 16;
1305 pub const cell_row_prefix_len = 2;
1306 pub const cell_text_max = 63;
1307
1308 pub const DecodedCell = struct { style: CellStyle, wide: Wide, text: []const u8 };
1309
1310 fn asciiCell(wide: Wide, text: []const u8) bool {
1311 return wide == .narrow and text.len == 1 and text[0] >= 0x20 and text[0] <= 0x7E;
1312 }
1313
1314 pub const CellRowWriter = struct {
1315 list: *std.ArrayList(u8),
1316 alloc: std.mem.Allocator,
1317 prefix_at: usize,
1318 ncells: u16 = 0,
1319 /// The open run's cells, held back until the run closes so the ascii
1320 /// decision is made over the whole run.
1321 run: std.ArrayListUnmanaged(DecodedCell) = .empty,
1322 run_style: CellStyle = .{},
1323
1324 pub fn begin(list: *std.ArrayList(u8), alloc: std.mem.Allocator) !CellRowWriter {
1325 const at = list.items.len;
1326 try list.appendSlice(alloc, &[_]u8{ 0, 0 });
1327 return .{ .list = list, .alloc = alloc, .prefix_at = at };
1328 }
1329
1330 pub fn cell(self: *CellRowWriter, style: CellStyle, wide: Wide, text: []const u8) !void {
1331 std.debug.assert(text.len <= cell_text_max);
1332 if (self.run.items.len > 0 and !style.eql(self.run_style)) try self.flush();
1333 if (self.run.items.len == 0) self.run_style = style;
1334 try self.run.append(self.alloc, .{ .style = style, .wide = wide, .text = text });
1335 self.ncells += 1;
1336 }
1337
1338 fn flush(self: *CellRowWriter) !void {
1339 const cells = self.run.items;
1340 if (cells.len == 0) return;
1341 var ascii = true;
1342 for (cells) |c| {
1343 if (!asciiCell(c.wide, c.text)) {
1344 ascii = false;
1345 break;
1346 }
1347 }
1348 var hdr: [run_header_len]u8 = undefined;
1349 std.mem.writeInt(u16, hdr[0..2], @intCast(cells.len), .little);
1350 std.mem.writeInt(u16, hdr[2..4], self.run_style.flags | (if (ascii) run_ascii else 0), .little);
1351 std.mem.writeInt(u32, hdr[4..8], self.run_style.fg, .little);
1352 std.mem.writeInt(u32, hdr[8..12], self.run_style.bg, .little);
1353 std.mem.writeInt(u32, hdr[12..16], self.run_style.ul, .little);
1354 try self.list.appendSlice(self.alloc, &hdr);
1355 for (cells) |c| {
1356 if (ascii) {
1357 try self.list.append(self.alloc, c.text[0]);
1358 } else {
1359 const head: u8 = (@as(u8, @intFromEnum(c.wide)) << 6) | @as(u8, @intCast(c.text.len));
1360 try self.list.append(self.alloc, head);
1361 try self.list.appendSlice(self.alloc, c.text);
1362 }
1363 }
1364 self.run.clearRetainingCapacity();
1365 }
1366
1367 /// Closes the open run and stamps ncells. The writer is spent after this.
1368 pub fn finish(self: *CellRowWriter) void {
1369 self.flush() catch |e| switch (e) {
1370 error.OutOfMemory => @panic("CellRowWriter.finish: out of memory"),
1371 };
1372 std.mem.writeInt(u16, self.list.items[self.prefix_at..][0..2], self.ncells, .little);
1373 self.run.deinit(self.alloc);
1374 }
1375 };
1376
1377 pub const CellRowReader = struct {
1378 rest: []const u8,
1379 ncells: u16,
1380 read: u16 = 0,
1381 run_left: u16 = 0,
1382 run_style: CellStyle = .{},
1383 run_ascii: bool = false,
1384
1385 pub fn init(bytes: []const u8) !CellRowReader {
1386 if (bytes.len < cell_row_prefix_len) return error.BadPayload;
1387 return .{
1388 .rest = bytes[cell_row_prefix_len..],
1389 .ncells = std.mem.readInt(u16, bytes[0..2], .little),
1390 };
1391 }
1392
1393 pub fn next(self: *CellRowReader) !?DecodedCell {
1394 if (self.read == self.ncells) return null;
1395 if (self.run_left == 0) {
1396 if (self.rest.len < run_header_len) return error.BadPayload;
1397 const count = std.mem.readInt(u16, self.rest[0..2], .little);
1398 const flags = std.mem.readInt(u16, self.rest[2..4], .little);
1399 // A run may not claim cells the row does not have.
1400 if (count == 0 or count > self.ncells - self.read) return error.BadPayload;
1401 self.run_left = count;
1402 self.run_ascii = flags & run_ascii != 0;
1403 self.run_style = .{
1404 .flags = flags & ~run_ascii,
1405 .fg = std.mem.readInt(u32, self.rest[4..8], .little),
1406 .bg = std.mem.readInt(u32, self.rest[8..12], .little),
1407 .ul = std.mem.readInt(u32, self.rest[12..16], .little),
1408 };
1409 self.rest = self.rest[run_header_len..];
1410 }
1411 self.run_left -= 1;
1412 self.read += 1;
1413 if (self.run_ascii) {
1414 if (self.rest.len < 1) return error.BadPayload;
1415 const text = self.rest[0..1];
1416 self.rest = self.rest[1..];
1417 return .{ .style = self.run_style, .wide = .narrow, .text = text };
1418 }
1419 if (self.rest.len < 1) return error.BadPayload;
1420 const head = self.rest[0];
1421 const len: usize = head & 0x3f;
1422 if (self.rest.len < 1 + len) return error.BadPayload;
1423 const text = self.rest[1 .. 1 + len];
1424 self.rest = self.rest[1 + len ..];
1425 return .{ .style = self.run_style, .wide = @enumFromInt(head >> 6), .text = text };
1426 }
1427
1428 /// The bytes after this row — the next row of a dense run, or nothing.
1429 pub fn remaining(self: *const CellRowReader) []const u8 {
1430 return self.rest;
1431 }
1432 };
1433
1266 pub const ComposedDelta = struct { header: DeltaHeader, bytes: []u8 }; 1434 pub const ComposedDelta = struct { header: DeltaHeader, bytes: []u8 };
1267 1435
1268 /// Turn a delta payload into the VT byte string that applies it: per row, CUP 1436 /// Turn a delta payload into the VT byte string that applies it: per row, CUP
@@ -2609,3 +2777,97 @@ test "writeFrameBounded: a peer that reads gets every byte, short writes and all
2609 try std.testing.expectEqual(MsgType.sessions_reply, got.type); 2777 try std.testing.expectEqual(MsgType.sessions_reply, got.type);
2610 try std.testing.expectEqualStrings(payload, got.payload); 2778 try std.testing.expectEqualStrings(payload, got.payload);
2611 } 2779 }
2780
2781 test "cellrow: two default ascii cells are one ascii run" {
2782 const alloc = std.testing.allocator;
2783 var list: std.ArrayList(u8) = .empty;
2784 defer list.deinit(alloc);
2785 var w = try CellRowWriter.begin(&list, alloc);
2786 try w.cell(.{}, .narrow, "a");
2787 try w.cell(.{}, .narrow, "b");
2788 w.finish();
2789 // ncells=2; run: count=2, flags=ascii, fg=bg=ul=0; then "ab".
2790 try std.testing.expectEqualSlices(u8, &[_]u8{
2791 2, 0, // ncells
2792 2, 0, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // run header
2793 'a', 'b',
2794 }, list.items);
2795 }
2796
2797 test "cellrow: a wide glyph and its spacer, a grapheme, and a styled run round-trip" {
2798 const alloc = std.testing.allocator;
2799 var list: std.ArrayList(u8) = .empty;
2800 defer list.deinit(alloc);
2801 const red: CellStyle = .{ .fg = colorPalette(1), .flags = 1 }; // bold red
2802 var w = try CellRowWriter.begin(&list, alloc);
2803 try w.cell(.{}, .wide, "漢");
2804 try w.cell(.{}, .spacer_tail, "");
2805 try w.cell(.{}, .narrow, "e\u{301}");
2806 try w.cell(red, .narrow, "x");
2807 try w.cell(red, .narrow, "");
2808 w.finish();
2809
2810 var r = try CellRowReader.init(list.items);
2811 try std.testing.expectEqual(@as(u16, 5), r.ncells);
2812 const c0 = (try r.next()).?;
2813 try std.testing.expectEqual(Wide.wide, c0.wide);
2814 try std.testing.expectEqualStrings("漢", c0.text);
2815 const c1 = (try r.next()).?;
2816 try std.testing.expectEqual(Wide.spacer_tail, c1.wide);
2817 try std.testing.expectEqualStrings("", c1.text);
2818 const c2 = (try r.next()).?;
2819 try std.testing.expectEqualStrings("e\u{301}", c2.text);
2820 const c3 = (try r.next()).?;
2821 try std.testing.expect(c3.style.eql(red));
2822 try std.testing.expectEqualStrings("x", c3.text);
2823 const c4 = (try r.next()).?;
2824 try std.testing.expect(c4.style.eql(red));
2825 try std.testing.expectEqualStrings("", c4.text);
2826 try std.testing.expectEqual(@as(?DecodedCell, null), try r.next());
2827 try std.testing.expectEqual(@as(usize, 0), r.remaining().len);
2828 }
2829
2830 test "cellrow: an ascii run is only taken when every cell qualifies" {
2831 const alloc = std.testing.allocator;
2832 var list: std.ArrayList(u8) = .empty;
2833 defer list.deinit(alloc);
2834 var w = try CellRowWriter.begin(&list, alloc);
2835 try w.cell(.{}, .narrow, "a");
2836 try w.cell(.{}, .narrow, "é"); // 2 bytes: breaks the ascii form for the whole run
2837 w.finish();
2838 // ncells=2; run: count=2, flags=0; cells: head 1 'a', head 2 0xC3 0xA9
2839 try std.testing.expectEqualSlices(u8, &[_]u8{
2840 2, 0, // ncells
2841 2, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, // run header
2842 1, 'a', 2, 0xC3, 0xA9,
2843 }, list.items);
2844 }
2845
2846 test "cellrow: the reader leaves the bytes after the row alone" {
2847 const alloc = std.testing.allocator;
2848 var list: std.ArrayList(u8) = .empty;
2849 defer list.deinit(alloc);
2850 var w = try CellRowWriter.begin(&list, alloc);
2851 try w.cell(.{}, .narrow, "a");
2852 w.finish();
2853 try list.appendSlice(alloc, "tail");
2854 var r = try CellRowReader.init(list.items);
2855 _ = try r.next();
2856 try std.testing.expectEqual(@as(?DecodedCell, null), try r.next());
2857 try std.testing.expectEqualStrings("tail", r.remaining());
2858 }
2859
2860 test "cellrow: malformed rows are BadPayload, never a read past the end" {
2861 // A run that claims more cells than the row's ncells: refused at the
2862 // run header, before a cell is read.
2863 var over = try CellRowReader.init(&[_]u8{ 1, 0, 2, 0, 0x00, 0x80, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 'a', 'b' });
2864 try std.testing.expectError(error.BadPayload, over.next());
2865 // A cell whose text_len runs past the payload.
2866 var short_text = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 5, 'a' });
2867 try std.testing.expectError(error.BadPayload, short_text.next());
2868 // A row shorter than its own prefix.
2869 try std.testing.expectError(error.BadPayload, CellRowReader.init(&[_]u8{1}));
2870 // A row that ends mid-run header.
2871 var mid_header = try CellRowReader.init(&[_]u8{ 1, 0, 1, 0, 0 });
2872 try std.testing.expectError(error.BadPayload, mid_header.next());
2873 }