6c3ecd04
feat: the CellRow codec, a grid row as runs of styled cells
a73x 2026-09-04 16:59
Commit message
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 | } | ||