terminal/snapshot: variable-width grid cell encoding

Add a per-row encoded cell width to the PAGE grid format. Rows
previously always spent eight bytes per cell, but a plain text cell
carries only a codepoint: on line-shaped scrollback most encoded
bytes were predictable zeros that still had to pass through CRC32C,
BLAKE3, both codecs, and any transport compression the caller
applies.

Each row now declares one of four widths in previously reserved row
flag bits, chosen canonically as the smallest width admitted by the
bitwise OR of the row cell words: one or two bytes transport a bare
codepoint, four bytes transport the low word half (any content kind,
style IDs up to sixty-three, no wide or flag or hyperlink bits), and
eight bytes remain the full word. Every width is a truncation on
encode and a zero-extension on decode, so narrow rows encode and
decode as vectorizable integer loops, one and two byte rows need at
most surrogate replacement and skip cell normalization entirely, and
full-width rows keep the existing bulk copy. Decoders use the
declared width for framing and accept rows encoded wider than
necessary. Rows containing wide characters, hyperlinks, semantic
content, or large style IDs still use the full width, which leaves
CJK-heavy content unchanged.

Benchmark deltas at this commit (terminal-snapshot, M-series,
ReleaseFast, 1 MB corpora):

  ascii lines 1-70:  7.66 MB -> 1.03 MB (7.4x)
                     encode 5.8 -> 2.0 ms, decode 8.1 -> 2.7 ms
  ascii full-wrap:   8.04 MB -> 1.04 MB (7.7x)
                     encode 5.4 -> 1.3 ms, decode 7.2 -> 1.8 ms
  utf8:              unchanged (wide cells keep rows at full width)

For a caller compressing the stream, the lines snapshot end to end
with zstd -1: encode plus compress 18.5 -> 2.8 ms, decompress plus
decode 15.8 -> 3.6 ms, and the compressed size itself drops from
1.35 MB to 0.86 MB because the packed stream is denser for the
entropy coder.
This commit is contained in:
Mitchell Hashimoto
2026-08-02 10:12:30 -07:00
parent 3e5d128353
commit 9e3019f190
7 changed files with 821 additions and 151 deletions

View File

@@ -27,11 +27,11 @@
//!
//! Each row has the following format:
//!
//! | Offset | Size | Field |
//! | -----: | ----------: | :------------------------ |
//! | 0 | 1 | Row flags |
//! | 1 | 2 | Encoded cell count (`u16`)|
//! | 3 | 8 * `count` | Encoded cells |
//! | Offset | Size | Field |
//! | -----: | ----------------: | :------------------------- |
//! | 0 | 1 | Row flags |
//! | 1 | 2 | Encoded cell count (`u16`) |
//! | 3 | `width` * `count` | Encoded cells |
//!
//! The row flag byte has the following format:
//!
@@ -40,7 +40,8 @@
//! | 0 | Wrap |
//! | 1 | Wrap continuation |
//! | 2-3 | Semantic prompt |
//! | 4-7 | Reserved, zero |
//! | 4-5 | Encoded cell width |
//! | 6-7 | Reserved, zero |
//!
//! Semantic prompt values are:
//!
@@ -59,12 +60,40 @@
//! Canonical encoders emit exactly through the row's last non-default cell,
//! so a fully default row has a zero count and no cell words.
//!
//! ## Encoded cell width
//!
//! The two width bits select how many bytes encode each of the row's
//! cells: `1 << width` is the size, so zero through three select one, two,
//! four, or eight bytes. Narrower widths are truncated transports of the
//! same cell word: a cell qualifies for a width when all of its higher
//! word bits are zero.
//!
//! | Width | Bytes | Encoded value and admitted cells |
//! | ----: | ----: | :--------------------------------------------------- |
//! | 0 | 1 | Codepoint at or below U+00FF; all other bits zero |
//! | 1 | 2 | Codepoint at or below U+FFFF; all other bits zero |
//! | 2 | 4 | Word bits 0-31: any content kind and codepoint, style |
//! | | | IDs 1-63, narrow, no flags, no hyperlink |
//! | 3 | 8 | The complete word |
//!
//! Widths zero and one store the codepoint value itself, which is word
//! bits 2-25 shifted down; the reconstructed word is the codepoint shifted
//! left by two. Width two stores the word's low half unshifted. This makes
//! every width a zero-extension on decode and a truncation on encode.
//!
//! Canonical encoders choose each row's smallest admissible width, which
//! follows directly from the bitwise OR of the row's cell words. Decoders
//! use the declared width for framing and accept rows encoded wider than
//! necessary. The width of a row with a zero cell count is canonically
//! zero and carries no meaning.
//!
//! Native row cache flags are not encoded. In particular, the Kitty virtual
//! placeholder hint is derived while decoding cells containing U+10EEEE.
//!
//! ## Cell
//!
//! Each cell is one 64-bit little-endian word:
//! Each cell is one 64-bit little-endian word, transported at the row's
//! encoded cell width as described above:
//!
//! ```text
//! bit 0 +-------------------------------+
@@ -219,13 +248,14 @@ const TerminalStyleId = terminal_style.Id;
///
/// The semantic prompt is a raw integer for the same reason as the wire
/// cell fields: decoders must accept its reserved value without
/// instantiating an invalid native enum, so every header byte bit-casts to
/// a valid value.
/// instantiating an invalid native enum. The width enum is exhaustive over
/// its two bits, so every header byte bit-casts to a valid value.
pub const Row = packed struct(u8) {
wrap: bool = false,
wrap_continuation: bool = false,
semantic_prompt: u2 = 0,
_padding: u4 = 0,
cell_width: Cell.EncodedWidth = .one,
_padding: u2 = 0,
};
/// The wire layout of one encoded cell. This is its own registry: the bit
@@ -252,6 +282,72 @@ pub const Cell = packed struct(u64) {
bg_color_palette = 2,
bg_color_rgb = 3,
};
/// The encoded cell width declared by a row header: how many bytes
/// transport each of the row's cell words. See the format
/// documentation above for the value each width transports and the
/// cells it admits.
///
/// `truncate` and `extend` are the transport transform itself, so the
/// admission masks derive from them rather than being maintained by
/// hand: a cell word is admitted exactly when it round-trips.
pub const EncodedWidth = enum(u2) {
one = 0,
two = 1,
four = 2,
eight = 3,
/// The number of bytes transporting one cell word.
pub fn size(self: EncodedWidth) usize {
return @as(usize, 1) << @intFromEnum(self);
}
/// The integer type transporting one cell word.
pub fn Int(comptime self: EncodedWidth) type {
return switch (self) {
.one => u8,
.two => u16,
.four => u32,
.eight => u64,
};
}
/// Truncate one cell word to its transported value. Only words
/// admitted by this width round-trip; `select` proves that for
/// every cell in a row before an encoder may use it.
pub fn truncate(comptime self: EncodedWidth, word: u64) self.Int() {
return @truncate(switch (self) {
// The bare codepoint, shifted down from the content field.
.one, .two => @as(Cell, @bitCast(word)).content,
// The word itself.
.four, .eight => word,
});
}
/// Widen one transported value back to its cell word.
pub fn extend(comptime self: EncodedWidth, value: self.Int()) u64 {
return switch (self) {
.one, .two => @bitCast(Cell{ .content = value }),
.four, .eight => value,
};
}
/// The word bits a cell may use and still round-trip through this
/// width.
pub fn mask(comptime self: EncodedWidth) u64 {
return comptime self.extend(std.math.maxInt(self.Int()));
}
/// The smallest width admitting the word, typically the bitwise
/// OR of every cell word in a row.
pub fn select(word: u64) EncodedWidth {
inline for ([_]EncodedWidth{ .one, .two, .four }) |width| {
if (word & ~width.mask() == 0) return width;
}
return .eight;
}
};
};
/// Whether the native cell's in-memory layout matches the wire cell layout
@@ -380,24 +476,12 @@ pub fn encode(
break :count 0;
};
// Row header: flags then the encoded cell count.
{
const row_header: Row = .{
.wrap = row.wrap,
.wrap_continuation = row.wrap_continuation,
.semantic_prompt = @intFromEnum(row.semantic_prompt),
};
var header_bytes: [3]u8 = undefined;
header_bytes[0] = @bitCast(row_header);
std.mem.writeInt(u16, header_bytes[1..3], @intCast(count), .little);
try writer.writeAll(&header_bytes);
}
// Validate the wide state of every encoded cell so we don't encode
// corrupt data, and detect the cells that keep this row off the
// direct-copy path. Trailing default cells are narrow, so checking
// the encoded prefix against the full row width covers every pair.
var direct = true;
// corrupt data, and accumulate the OR of the row's cell words to
// select its encoded cell width. Trailing default cells are narrow,
// so checking the encoded prefix against the full row width covers
// every pair.
var word_or: u64 = 0;
for (cells[0..count], 0..) |*cell, x| {
switch (cell.wide) {
.narrow => {},
@@ -415,31 +499,117 @@ pub fn encode(
return error.InvalidWideCell;
},
}
// Hyperlink IDs live in a native side table, and nonzero native
// padding would leak into the wire hyperlink ID field.
if (cell.hyperlink or cell._padding != 0) direct = false;
word_or |= classifyWord(cell);
}
if (comptime bulk_codec) {
if (direct) {
try writer.writeAll(std.mem.sliceAsBytes(cells[0..count]));
continue;
}
// Canonical rows use the smallest admissible width.
const cell_width: Cell.EncodedWidth = .select(word_or);
// Row header: flags then the encoded cell count.
{
const row_header: Row = .{
.wrap = row.wrap,
.wrap_continuation = row.wrap_continuation,
.semantic_prompt = @intFromEnum(row.semantic_prompt),
.cell_width = cell_width,
};
var header_bytes: [3]u8 = undefined;
header_bytes[0] = @bitCast(row_header);
std.mem.writeInt(u16, header_bytes[1..3], @intCast(count), .little);
try writer.writeAll(&header_bytes);
}
for (cells[0..count]) |*cell| {
const link_id: TerminalHyperlinkId = if (cell.hyperlink)
page.lookupHyperlink(cell) orelse unreachable
else
0;
try io.writeInt(writer, u64, cellBits(cell.*, link_id));
switch (cell_width) {
inline .one, .two, .four => |width| try encodeNarrowCells(
width,
cells[0..count],
writer,
),
.eight => {
// Hyperlink IDs live in a native side table, but we embed
// them in ours, so if we have any hyperlinks we need
// to fallback to the loop below.
if (comptime bulk_codec) {
const witness: Cell = @bitCast(word_or);
if (!witness.hyperlink and witness.hyperlink_id == 0) {
try writer.writeAll(std.mem.sliceAsBytes(cells[0..count]));
continue;
}
}
for (cells[0..count]) |*cell| {
const link_id: TerminalHyperlinkId = if (cell.hyperlink)
page.lookupHyperlink(cell) orelse unreachable
else
0;
try io.writeInt(
writer,
u64,
cellBits(cell.*, link_id),
);
}
},
}
}
try encodeGraphemes(page, writer);
}
/// The word used to select a row's encoded cell width. This is the cell's
/// wire word with the hyperlink flag reflecting the native cell, so linked
/// cells and nonzero native padding disqualify every narrow width.
inline fn classifyWord(cell: *const TerminalCell) u64 {
if (comptime native_matches_wire) return @bitCast(cell.*);
var wire: Cell = @bitCast(cellBits(cell.*, 0));
wire.hyperlink = cell.hyperlink;
return @bitCast(wire);
}
/// Write one row's cells truncated to the given encoded width.
///
/// Every admitted cell round-trips exactly because the row's width
/// selection proved every cell word survives `truncate` then `extend`.
fn encodeNarrowCells(
comptime width: Cell.EncodedWidth,
cells: []const TerminalCell,
writer: *std.Io.Writer,
) std.Io.Writer.Error!void {
const size = comptime width.size();
var chunk: [1024]u8 = undefined;
var i: usize = 0;
while (i < cells.len) {
const n = @min(cells.len - i, chunk.len / size);
if (comptime native_matches_wire) {
// A pure truncating loop over integers that the compiler can
// vectorize.
const words: [*]const u64 = @ptrCast(cells.ptr);
for (0..n) |j| {
std.mem.writeInt(
width.Int(),
chunk[j * size ..][0..size],
width.truncate(words[i + j]),
.little,
);
}
} else {
for (cells[i..][0..n], 0..) |*cell, j| {
std.mem.writeInt(
width.Int(),
chunk[j * size ..][0..size],
width.truncate(classifyWord(cell)),
.little,
);
}
}
try writer.writeAll(chunk[0 .. n * size]);
i += n;
}
}
/// Encode the grapheme suffix section for every kind 1 cell in the grid.
fn encodeGraphemes(
page: *const TerminalPage,
@@ -526,9 +696,9 @@ pub fn decode(
try reader.readSliceAll(&row_header_bytes);
}
// Every bit pattern is a valid header: booleans decode directly
// and the raw semantic value gets a default below. Reserved
// bits do not change the known fields.
// Every bit pattern is a valid header: booleans and the exhaustive
// width enum decode directly, and the raw semantic value gets a
// default below. Reserved bits do not change the known fields.
const row_header: Row = @bitCast(row_header_bytes[0]);
const count = std.mem.readInt(u16, row_header_bytes[1..3], .little);
break :header .{ row_header, count };
@@ -546,50 +716,201 @@ pub fn decode(
if (count > cells.len) return error.InvalidRowCellCount;
if (count == 0) continue;
if (comptime bulk_codec) {
// Read the encoded words directly into page storage, then
// normalize them in place. The raw words are only ever touched
// as integers until normalization makes them valid cells.
const words: [*]u64 = @ptrCast(cells.ptr);
try reader.readSliceAll(
std.mem.sliceAsBytes(cells[0..count]),
);
for (0..count) |x| {
applyCell(
page,
row,
cells,
x,
words[x],
style_remap,
hyperlink_remap,
);
}
} else {
for (0..count) |x| {
const bits = try io.readInt(reader, u64);
applyCell(
page,
row,
cells,
x,
bits,
style_remap,
hyperlink_remap,
);
}
}
// The encoded cell width is framing: it determines exactly how many
// bytes this row occupies.
switch (row_header.cell_width) {
inline .one, .two => |width| try decodeNarrowCells(
width,
reader,
cells[0..count],
),
.four => try decodeWordCells(
.four,
page,
row,
cells,
count,
reader,
style_remap,
hyperlink_remap,
),
.eight => {
if (comptime bulk_codec) {
// Read the encoded words directly into page storage,
// then normalize them in place. The raw words are only
// ever touched as integers until normalization makes
// them valid cells.
const words: [*]u64 = @ptrCast(cells.ptr);
try reader.readSliceAll(
std.mem.sliceAsBytes(cells[0..count]),
);
for (0..count) |x| {
applyCell(
page,
row,
cells,
x,
words[x],
style_remap,
hyperlink_remap,
);
}
} else {
try decodeWordCells(
.eight,
page,
row,
cells,
count,
reader,
style_remap,
hyperlink_remap,
);
}
// The implicit cell after a short row is narrow, which resolves a
// trailing wide marker exactly like an explicit narrow neighbor.
if (count < cells.len and cells[count - 1].wide == .wide) {
cells[count - 1].wide = .narrow;
// The implicit cell after a short row is narrow, which
// resolves a trailing wide marker exactly like an explicit
// narrow neighbor. Only full-width rows can encode wide
// markers.
if (count < cells.len and cells[count - 1].wide == .wide) {
cells[count - 1].wide = .narrow;
}
},
}
}
try decodeGraphemes(page, reader);
}
/// Decode one row of width-one or width-two cells.
///
/// These widths admit only bare codepoints, so cells store directly with at
/// most Unicode scalar validation: no styles, hyperlinks, wide pairs, or
/// row hints are reachable and no normalization state is needed.
fn decodeNarrowCells(
comptime width: Cell.EncodedWidth,
reader: *std.Io.Reader,
cells: []TerminalCell,
) DecodeError!void {
const size = comptime width.size();
// The staged payload path has the complete row buffered, making this
// one bounds check followed by a vectorizable widening loop.
const total = cells.len * size;
if (reader.bufferedLen() >= total) {
widenCells(width, reader.buffered()[0..total], cells);
reader.toss(total);
return;
}
// Streaming sources fall back to bounded chunks.
var chunk: [1024]u8 = undefined;
var i: usize = 0;
while (i < cells.len) {
const n = @min(cells.len - i, chunk.len / size);
try reader.readSliceAll(chunk[0 .. n * size]);
widenCells(width, chunk[0 .. n * size], cells[i..][0..n]);
i += n;
}
}
/// Store codepoint-valued encoded cells of the given width.
fn widenCells(
comptime width: Cell.EncodedWidth,
bytes: []const u8,
cells: []TerminalCell,
) void {
const size = comptime width.size();
// When the native cell matches the wire word, this is a pure widening
// loop over integers that the compiler can vectorize.
if (comptime native_matches_wire) {
const words: [*]u64 = @ptrCast(cells.ptr);
for (0..cells.len) |i| {
words[i] = width.extend(widenValue(width, bytes[i * size ..]));
}
return;
}
for (cells, 0..) |*cell, i| {
storeCell(cell, width.extend(widenValue(width, bytes[i * size ..])));
}
}
/// Read and validate one narrow transported value.
inline fn widenValue(
comptime width: Cell.EncodedWidth,
bytes: []const u8,
) width.Int() {
const size = comptime width.size();
const value = std.mem.readInt(width.Int(), bytes[0..size], .little);
// Width one cannot encode an invalid scalar. Width two admits
// surrogates, which degrade exactly like their full-width form.
if (comptime width != .one) {
if (!validScalar(value)) return 0xFFFD;
}
return value;
}
/// Decode one row of width-four or fallback full-width cells through the
/// complete per-cell normalization path.
fn decodeWordCells(
comptime width: Cell.EncodedWidth,
page: *TerminalPage,
row: *TerminalRow,
cells: []TerminalCell,
count: usize,
reader: *std.Io.Reader,
style_remap: *const StyleRemap,
hyperlink_remap: *const HyperlinkRemap,
) DecodeError!void {
const size = comptime width.size();
// The staged payload path has the complete row buffered.
const total = count * size;
if (reader.bufferedLen() >= total) {
const bytes = reader.buffered()[0..total];
for (0..count) |x| {
const bits = width.extend(std.mem.readInt(
width.Int(),
bytes[x * size ..][0..size],
.little,
));
applyCell(
page,
row,
cells,
x,
bits,
style_remap,
hyperlink_remap,
);
}
reader.toss(total);
return;
}
// Streaming sources fall back to per-cell reads.
for (0..count) |x| {
const bits = width.extend(try io.readInt(reader, width.Int()));
applyCell(
page,
row,
cells,
x,
bits,
style_remap,
hyperlink_remap,
);
}
}
/// Whether the value is a valid Unicode scalar value.
inline fn validScalar(cp: u32) bool {
return cp <= 0x10FFFF and (cp < 0xD800 or cp > 0xDFFF);
}
/// Normalize one encoded cell word and store it at `cells[x]`.
///
/// This owns every per-cell decode rule except grapheme suffixes: content
@@ -715,11 +1036,6 @@ fn normalizeWide(row: *const TerminalRow, cells: []TerminalCell, x: usize) void
}
}
/// Whether the value is a valid Unicode scalar value.
inline fn validScalar(cp: u32) bool {
return cp <= 0x10FFFF and (cp < 0xD800 or cp > 0xDFFF);
}
/// Decode the grapheme suffix section into already decoded cells.
fn decodeGraphemes(
page: *TerminalPage,
@@ -903,7 +1219,6 @@ fn Remap(comptime Id: type) type {
}
};
}
const test_golden_fixture = test_fixture.parse(
@embedFile("testdata/grid-v1.hex"),
);
@@ -929,10 +1244,76 @@ test "cell wire layout registry" {
try testing.expect(native_matches_wire);
}
test "encoded cell width transport registry" {
const testing = std.testing;
// Pin the transported value and admission mask of every width against
// the documented format: widths one and two carry the bare codepoint,
// width four the low word half, width eight the complete word.
const word: u64 = @bitCast(Cell{
.kind = 1,
.content = 0xABCDEF,
.style_id = 0x1234,
.hyperlink_id = 0x5678,
});
try testing.expectEqual(@as(u8, 0xEF), Cell.EncodedWidth.one.truncate(word));
try testing.expectEqual(@as(u16, 0xCDEF), Cell.EncodedWidth.two.truncate(word));
try testing.expectEqual(
@as(u32, @truncate(word)),
Cell.EncodedWidth.four.truncate(word),
);
try testing.expectEqual(word, Cell.EncodedWidth.eight.truncate(word));
try testing.expectEqual(
@as(u64, 0x0000_0000_0000_03FC),
Cell.EncodedWidth.one.mask(),
);
try testing.expectEqual(
@as(u64, 0x0000_0000_0003_FFFC),
Cell.EncodedWidth.two.mask(),
);
try testing.expectEqual(
@as(u64, 0x0000_0000_FFFF_FFFF),
Cell.EncodedWidth.four.mask(),
);
try testing.expectEqual(
@as(u64, 0xFFFF_FFFF_FFFF_FFFF),
Cell.EncodedWidth.eight.mask(),
);
// Selection returns the smallest admissible width, and every admitted
// word round-trips through its transport.
const cases = [_]struct { cell: Cell, width: Cell.EncodedWidth }{
.{ .cell = .{}, .width = .one },
.{ .cell = .{ .content = 0xFF }, .width = .one },
.{ .cell = .{ .content = 0x100 }, .width = .two },
.{ .cell = .{ .content = 0xFFFF }, .width = .two },
.{ .cell = .{ .content = 0x10000 }, .width = .four },
.{ .cell = .{ .kind = 2, .content = 7 }, .width = .four },
.{ .cell = .{ .content = 'a', .style_id = 63 }, .width = .four },
.{ .cell = .{ .content = 'a', .style_id = 64 }, .width = .eight },
.{ .cell = .{ .width = 1, .content = 'a' }, .width = .eight },
.{ .cell = .{ .protected = true }, .width = .eight },
.{ .cell = .{ .semantic_content = 1 }, .width = .eight },
.{ .cell = .{ .hyperlink = true, .hyperlink_id = 1 }, .width = .eight },
};
inline for (cases) |case| {
const case_word: u64 = @bitCast(case.cell);
try testing.expectEqual(
case.width,
Cell.EncodedWidth.select(case_word),
);
try testing.expectEqual(
case_word,
case.width.extend(case.width.truncate(case_word)),
);
}
}
test "grid golden encoding and decoding" {
const capacity: terminal_page.Capacity = .{
.cols = 3,
.rows = 2,
.rows = 4,
.styles = 0,
.hyperlink_bytes = 0,
.grapheme_bytes = 64,
@@ -983,7 +1364,16 @@ test "grid golden encoding and decoding" {
head.row.wrap_continuation = true;
head.row.semantic_prompt = .prompt_continuation;
var encoded: [128]u8 = undefined;
// The third row is bare ASCII text with an interior blank, which uses
// the one-byte encoded cell width.
source.getRowAndCell(0, 2).cell.* = .init('h');
source.getRowAndCell(2, 2).cell.* = .init('i');
// The fourth row needs the two-byte width for a codepoint above U+00FF.
source.getRowAndCell(0, 3).cell.* = .init(0x0416); // Ж
source.getRowAndCell(1, 3).cell.* = .init('!');
var encoded: [160]u8 = undefined;
var writer: std.Io.Writer = .fixed(&encoded);
try encode(&source, &writer);
try test_fixture.expectEqual(
@@ -1076,8 +1466,26 @@ test "grid golden encoding and decoding" {
decoded_head.row.semantic_prompt,
);
try std.testing.expectEqual(
@as(u21, 'h'),
destination.getRowAndCell(0, 2).cell.codepoint(),
);
try std.testing.expect(destination.getRowAndCell(1, 2).cell.isZero());
try std.testing.expectEqual(
@as(u21, 'i'),
destination.getRowAndCell(2, 2).cell.codepoint(),
);
try std.testing.expectEqual(
@as(u21, 0x0416),
destination.getRowAndCell(0, 3).cell.codepoint(),
);
try std.testing.expectEqual(
@as(u21, '!'),
destination.getRowAndCell(1, 3).cell.codepoint(),
);
// A re-encode proves the decoded native page retains every wire field.
var reencoded: [128]u8 = undefined;
var reencoded: [160]u8 = undefined;
var rewriter: std.Io.Writer = .fixed(&reencoded);
try encode(&destination, &rewriter);
try std.testing.expectEqualStrings(
@@ -1102,10 +1510,11 @@ test "grid elides trailing default cells" {
var writer: std.Io.Writer = .fixed(&encoded);
try encode(&page, &writer);
// 3 bytes per row header, cells only through the last non-default
// cell, and an empty grapheme section.
// 3 bytes per row header and cells only through the last non-default
// cell, followed by an empty grapheme section. The text row uses the
// one-byte width while the protected flag forces the full width.
try testing.expectEqual(
@as(usize, 3 + (3 + 3 * 8) + (3 + 5 * 8) + 4),
@as(usize, 3 + (3 + 3 * 1) + (3 + 5 * 8) + 4),
writer.buffered().len,
);
@@ -1170,7 +1579,7 @@ test "grid normalizes incomplete wide cells" {
// content but makes the incomplete wide cell narrow.
var payload: [64]u8 = undefined;
var payload_writer: std.Io.Writer = .fixed(&payload);
try payload_writer.writeByte(@bitCast(Row{}));
try payload_writer.writeByte(@bitCast(Row{ .cell_width = .eight }));
try io.writeInt(&payload_writer, u16, columns);
try io.writeInt(&payload_writer, u64, @bitCast(Cell{
.width = 1, // wide
@@ -1225,7 +1634,7 @@ test "grid normalizes incomplete wide cells" {
var payload: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
try writer.writeByte(@bitCast(Row{}));
try writer.writeByte(@bitCast(Row{ .cell_width = .eight }));
try io.writeInt(&writer, u16, 1);
try io.writeInt(&writer, u64, @bitCast(Cell{
.width = 1, // wide
@@ -1255,8 +1664,9 @@ test "grid normalizes reserved cell values" {
var payload: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
// Reserved row flag bits are ignored while the unknown semantic-prompt
// value degrades to none and wrap survives.
// Reserved row flag bits six and seven are ignored while the unknown
// semantic-prompt value degrades to none and wrap survives. Bits four
// and five select the full encoded cell width.
try writer.writeByte(0xFD);
try io.writeInt(&writer, u16, 3);
@@ -1327,7 +1737,7 @@ test "grid drops undeliverable grapheme entries" {
var payload: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
try writer.writeByte(@bitCast(Row{}));
try writer.writeByte(@bitCast(Row{ .cell_width = .eight }));
try io.writeInt(&writer, u16, 4);
// A kind 1 cell that receives a valid entry.
try io.writeInt(&writer, u64, @bitCast(Cell{ .kind = 1, .content = 'x' }));
@@ -1383,3 +1793,220 @@ test "grid drops undeliverable grapheme entries" {
try testing.expect(!page.getRowAndCell(2, 0).cell.hasGrapheme());
try testing.expect(!page.getRowAndCell(3, 0).cell.hasGrapheme());
}
test "grid encodes rows at their narrowest width" {
const testing = std.testing;
var page = try TerminalPage.init(.{
.cols = 2,
.rows = 4,
.styles = 8,
});
defer page.deinit();
// Width zero: bare Latin-1 text.
page.getRowAndCell(0, 0).cell.* = .init('A');
page.getRowAndCell(1, 0).cell.* = .init(0xFF);
// Width one: any BMP codepoint.
page.getRowAndCell(0, 1).cell.* = .init(0x0100);
// Width two: a small style ID and a background color.
const style_id = try page.styles.add(page.memory, .{
.flags = .{ .bold = true },
});
try testing.expect(style_id <= 63);
const styled = page.getRowAndCell(0, 2);
styled.cell.* = .init('s');
styled.cell.style_id = style_id;
styled.row.styled = true;
const bg = page.getRowAndCell(1, 2);
bg.cell.content_tag = .bg_color_palette;
bg.cell.content = .{ .color_palette = .{ .data = 7 } };
// Width three: a protected cell.
page.getRowAndCell(0, 3).cell.protected = true;
var encoded: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&encoded);
try encode(&page, &writer);
const bytes = writer.buffered();
try testing.expectEqual(
@as(usize, (3 + 2 * 1) + (3 + 1 * 2) + (3 + 2 * 4) + (3 + 1 * 8) + 4),
bytes.len,
);
// Each row header carries the expected width bits.
try testing.expectEqual(@as(u8, 0 << 4), bytes[0] & 0x30);
try testing.expectEqual(@as(u8, 1 << 4), bytes[5] & 0x30);
try testing.expectEqual(@as(u8, 2 << 4), bytes[10] & 0x30);
try testing.expectEqual(@as(u8, 3 << 4), bytes[21] & 0x30);
var destination = try TerminalPage.init(.{
.cols = 2,
.rows = 4,
.styles = 8,
});
defer destination.deinit();
var style_remap = try StyleRemap.init(testing.allocator);
defer style_remap.deinit(testing.allocator);
var hyperlink_remap = try HyperlinkRemap.init(testing.allocator);
defer hyperlink_remap.deinit(testing.allocator);
const native_style = try destination.styles.add(destination.memory, .{
.flags = .{ .bold = true },
});
style_remap.put(style_id, native_style);
var reader: std.Io.Reader = .fixed(bytes);
try decode(&destination, &reader, &style_remap, &hyperlink_remap);
try destination.verifyIntegrity(testing.allocator);
try testing.expectEqual(
@as(u21, 'A'),
destination.getRowAndCell(0, 0).cell.codepoint(),
);
try testing.expectEqual(
@as(u21, 0xFF),
destination.getRowAndCell(1, 0).cell.codepoint(),
);
try testing.expectEqual(
@as(u21, 0x0100),
destination.getRowAndCell(0, 1).cell.codepoint(),
);
const decoded_styled = destination.getRowAndCell(0, 2);
try testing.expectEqual(@as(u21, 's'), decoded_styled.cell.codepoint());
try testing.expectEqual(native_style, decoded_styled.cell.style_id);
try testing.expect(decoded_styled.row.styled);
try testing.expectEqual(
@as(u8, 7),
destination.getRowAndCell(1, 2).cell.content.color_palette.data,
);
try testing.expect(destination.getRowAndCell(0, 3).cell.protected);
}
test "grid decodes non-canonical cell widths" {
const testing = std.testing;
var page = try TerminalPage.init(.{ .cols = 2, .rows = 1 });
defer page.deinit();
var style_remap = try StyleRemap.init(testing.allocator);
defer style_remap.deinit(testing.allocator);
var hyperlink_remap = try HyperlinkRemap.init(testing.allocator);
defer hyperlink_remap.deinit(testing.allocator);
// A bare ASCII row encoded at the full width is wasteful but valid.
var payload: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
try writer.writeByte(@bitCast(Row{ .cell_width = .eight }));
try io.writeInt(&writer, u16, 2);
try io.writeInt(&writer, u64, @bitCast(Cell{ .content = 'o' }));
try io.writeInt(&writer, u64, @bitCast(Cell{ .content = 'k' }));
try io.writeInt(&writer, u32, 0);
var reader: std.Io.Reader = .fixed(writer.buffered());
try decode(&page, &reader, &style_remap, &hyperlink_remap);
try page.verifyIntegrity(testing.allocator);
try testing.expectEqual(
@as(u21, 'o'),
page.getRowAndCell(0, 0).cell.codepoint(),
);
try testing.expectEqual(
@as(u21, 'k'),
page.getRowAndCell(1, 0).cell.codepoint(),
);
// Re-encoding canonicalizes the row back to the one-byte width.
var reencoded: [16]u8 = undefined;
var rewriter: std.Io.Writer = .fixed(&reencoded);
try encode(&page, &rewriter);
try testing.expectEqual(@as(usize, 3 + 2 + 4), rewriter.buffered().len);
}
test "grid normalizes surrogates in two-byte cells" {
const testing = std.testing;
var page = try TerminalPage.init(.{ .cols = 2, .rows = 1 });
defer page.deinit();
var style_remap = try StyleRemap.init(testing.allocator);
defer style_remap.deinit(testing.allocator);
var hyperlink_remap = try HyperlinkRemap.init(testing.allocator);
defer hyperlink_remap.deinit(testing.allocator);
var payload: [16]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
try writer.writeByte(@bitCast(Row{ .cell_width = .two }));
try io.writeInt(&writer, u16, 2);
try io.writeInt(&writer, u16, 0xD800);
try io.writeInt(&writer, u16, 0x0416);
try io.writeInt(&writer, u32, 0);
var reader: std.Io.Reader = .fixed(writer.buffered());
try decode(&page, &reader, &style_remap, &hyperlink_remap);
try page.verifyIntegrity(testing.allocator);
try testing.expectEqual(
@as(u21, 0xFFFD),
page.getRowAndCell(0, 0).cell.codepoint(),
);
try testing.expectEqual(
@as(u21, 0x0416),
page.getRowAndCell(1, 0).cell.codepoint(),
);
}
test "grid four-byte cells run full normalization" {
const testing = std.testing;
var page = try TerminalPage.init(.{
.cols = 3,
.rows = 1,
.styles = 8,
});
defer page.deinit();
var style_remap = try StyleRemap.init(testing.allocator);
defer style_remap.deinit(testing.allocator);
var hyperlink_remap = try HyperlinkRemap.init(testing.allocator);
defer hyperlink_remap.deinit(testing.allocator);
var payload: [32]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
try writer.writeByte(@bitCast(Row{ .cell_width = .four }));
try io.writeInt(&writer, u16, 3);
// The Kitty placeholder does not fit two-byte cells but fits here
// and must still derive the native row hint.
try io.writeInt(&writer, u32, @truncate(@as(u64, @bitCast(Cell{
.content = kitty.graphics.unicode.placeholder,
}))));
// An unknown small style reference degrades to the default style.
try io.writeInt(&writer, u32, @truncate(@as(u64, @bitCast(Cell{
.content = 'x',
.style_id = 63,
}))));
// Reserved palette content bits are cleared at this width too.
try io.writeInt(&writer, u32, @truncate(@as(u64, @bitCast(Cell{
.kind = 2,
.content = 0xFFFF07,
}))));
try io.writeInt(&writer, u32, 0);
var reader: std.Io.Reader = .fixed(writer.buffered());
try decode(&page, &reader, &style_remap, &hyperlink_remap);
try page.verifyIntegrity(testing.allocator);
const first = page.getRowAndCell(0, 0);
try testing.expectEqual(
@as(u21, kitty.graphics.unicode.placeholder),
first.cell.codepoint(),
);
try testing.expect(first.row.kitty_virtual_placeholder);
const second = page.getRowAndCell(1, 0).cell;
try testing.expectEqual(@as(u21, 'x'), second.codepoint());
try testing.expectEqual(@as(TerminalStyleId, 0), second.style_id);
const third = page.getRowAndCell(2, 0).cell;
try testing.expectEqual(
TerminalCell.ContentTag.bg_color_palette,
third.content_tag,
);
try testing.expectEqual(@as(u8, 7), third.content.color_palette.data);
}

View File

@@ -1015,7 +1015,7 @@ test "decode defaults missing sparse cell references" {
var style_encoded: [Header.len + 3 + 8 + 4]u8 = undefined;
var style_writer: std.Io.Writer = .fixed(&style_encoded);
try style_header.encode(&style_writer);
try style_writer.writeByte(0); // row flags
try style_writer.writeByte(0x30); // row flags, full cell width
try io.writeInt(&style_writer, u16, 1); // cell count
try io.writeInt(&style_writer, u64, @bitCast(grid.Cell{
.style_id = 1,
@@ -1048,7 +1048,7 @@ test "decode defaults missing sparse cell references" {
var hyperlink_encoded: [Header.len + 3 + 8 + 4]u8 = undefined;
var hyperlink_writer: std.Io.Writer = .fixed(&hyperlink_encoded);
try hyperlink_header.encode(&hyperlink_writer);
try hyperlink_writer.writeByte(0); // row flags
try hyperlink_writer.writeByte(0x30); // row flags, full cell width
try io.writeInt(&hyperlink_writer, u16, 1); // cell count
try io.writeInt(&hyperlink_writer, u64, @bitCast(grid.Cell{
.hyperlink = true,

View File

@@ -2262,7 +2262,7 @@ test "SCREEN decode ignores a PAGE with an empty hyperlink URI" {
// One narrow codepoint cell refers to the hyperlink table entry above.
// Since that entry is ignored, the cell must restore without a hyperlink.
try page_payload.writeByte(0); // row flags
try page_payload.writeByte(0x30); // row flags, full cell width
try io.writeInt(page_payload, u16, 1); // cell count
try io.writeInt(page_payload, u64, @bitCast(grid.Cell{
.content = 'A',

View File

@@ -933,7 +933,13 @@ types:
valid:
expr: _ <= columns
- id: cells
type: grid_cell(_index, columns, flags.wrap)
type:
switch-on: flags.width_log2
cases:
0: grid_cell_1
1: grid_cell_2
2: grid_cell_4
3: grid_cell(_index, columns, flags.wrap)
repeat: expr
repeat-expr: cell_count
@@ -942,7 +948,7 @@ types:
- id: raw
type: u1
valid:
expr: (_ & 0xf0) == 0 and ((_ >> 2) & 0x3) <= 2
expr: (_ & 0xc0) == 0 and ((_ >> 2) & 0x3) <= 2
instances:
wrap:
value: (raw & 1) != 0
@@ -950,6 +956,46 @@ types:
value: (raw & 2) != 0
semantic_prompt:
value: (raw >> 2) & 0x3
width_log2:
value: (raw >> 4) & 0x3
grid_cell_1:
doc: One-byte encoded cell; the value is a codepoint at or below U+00FF.
seq:
- id: codepoint
type: u1
grid_cell_2:
doc: |
Two-byte encoded cell; the value is a codepoint at or below U+FFFF.
Canonical encoders never emit surrogates.
seq:
- id: codepoint
type: u2
valid:
expr: not (_ >= 0xd800 and _ <= 0xdfff)
grid_cell_4:
doc: |
Four-byte encoded cell holding the low half of the cell word: any
content kind and codepoint, style IDs one through sixty-three, and no
width, flag, or hyperlink bits.
seq:
- id: raw
type: u4
valid:
expr: |
(content_kind >= 2 or
(content <= 0x10ffff and
not (content >= 0xd800 and content <= 0xdfff))) and
(content_kind != 2 or content <= 0xff)
instances:
content_kind:
value: raw % 4
content:
value: (raw / 4) % 16777216
style_id:
value: raw / 67108864
grid_cell:
doc: |
@@ -984,7 +1030,8 @@ types:
not (content >= 0xd800 and content <= 0xdfff))) and
(content_kind != 2 or content <= 0xff) and
(width != 2 or
(index > 0 and _parent.cells[index - 1].width == 1)) and
(index > 0 and
_parent.cells[index - 1].as<grid_cell>.width == 1)) and
(width != 3 or (index + 1 == columns and row_wrap))
instances:
content_kind:

View File

@@ -78,52 +78,47 @@ ee ee 80 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000037a
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000003ec
00 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x000003fc
# offset 0x0000040c: page record, payload 57 bytes
03 00 39 00 00 00 26 5a 94 0b 02 00 03 00 00 00 # 0x0000040c
# offset 0x0000040c: page record, payload 36 bytes
03 00 24 00 00 00 a2 4f 26 d1 02 00 03 00 00 00 # 0x0000040c
00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 01 # 0x0000041c
00 0c 01 00 00 00 00 00 00 00 01 00 10 01 00 00 # 0x0000042c
00 00 00 00 00 01 00 14 01 00 00 00 00 00 00 00 # 0x0000043c
00 00 00 # 0x0000044c
00 43 00 01 00 44 00 01 00 45 00 00 00 00 # 0x0000042c
# offset 0x0000044f: screen record, payload 54 bytes
02 00 36 00 00 00 ce 1f 9f 08 01 00 01 00 00 00 # 0x0000044f
00 00 00 00 00 00 01 00 02 00 01 00 00 00 00 00 # 0x0000045f
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000046f
00 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000047f
# offset 0x0000043a: screen record, payload 54 bytes
02 00 36 00 00 00 ce 1f 9f 08 01 00 01 00 00 00 # 0x0000043a
00 00 00 00 00 00 01 00 02 00 01 00 00 00 00 00 # 0x0000044a
00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000045a
00 08 00 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x0000046a
# offset 0x0000048f: page record, payload 73 bytes
03 00 49 00 00 00 37 af 30 0e 02 00 03 00 00 00 # 0x0000048f
00 00 80 00 c0 00 00 02 00 00 00 08 00 00 03 02 # 0x0000049f
00 c8 01 00 00 00 00 00 00 b8 01 00 00 00 00 00 # 0x000004af
00 03 02 00 84 01 00 00 00 00 00 00 d0 01 00 00 # 0x000004bf
00 00 00 00 03 01 00 94 01 00 00 00 00 00 00 00 # 0x000004cf
00 00 00 # 0x000004df
# offset 0x0000047a: page record, payload 38 bytes
03 00 26 00 00 00 45 61 97 32 02 00 03 00 00 00 # 0x0000047a
00 00 80 00 c0 00 00 02 00 00 00 08 00 00 03 02 # 0x0000048a
00 72 6e 03 02 00 61 74 03 01 00 65 00 00 00 00 # 0x0000049a
# offset 0x000004e2: continuation record, payload 0 bytes
07 00 00 00 00 00 27 80 63 d1 # 0x000004e2
# offset 0x000004aa: continuation record, payload 0 bytes
07 00 00 00 00 00 27 80 63 d1 # 0x000004aa
# offset 0x000004ec: ready record, payload 32 bytes
05 00 20 00 00 00 5d f3 97 80 41 a2 18 18 cb 82 # 0x000004ec
8c 97 b6 58 14 60 26 9a 88 2e be d4 c3 4b 83 c9 # 0x000004fc
9d 32 9d de e6 70 17 4a 5d bc # 0x0000050c
# offset 0x000004b4: ready record, payload 32 bytes
05 00 20 00 00 00 4d 17 72 ed dd 87 26 75 cf 8e # 0x000004b4
e5 1f 37 e3 05 92 0a e2 f8 ef c1 16 54 be 49 e7 # 0x000004c4
b2 6c df 40 d2 77 fe 05 82 ea # 0x000004d4
# offset 0x00000516: history record, payload 6 bytes
04 00 06 00 00 00 20 32 ed e1 00 00 02 00 00 00 # 0x00000516
# offset 0x000004de: history record, payload 6 bytes
04 00 06 00 00 00 20 32 ed e1 00 00 02 00 00 00 # 0x000004de
# offset 0x00000526: page record, payload 38 bytes
03 00 26 00 00 00 9e 10 a2 93 02 00 02 00 00 00 # 0x00000526
00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 01 # 0x00000536
00 08 01 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000546
# offset 0x000004ee: page record, payload 31 bytes
03 00 1f 00 00 00 4a ed 2f c2 02 00 02 00 00 00 # 0x000004ee
00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 01 # 0x000004fe
00 42 00 00 00 00 00 00 00 # 0x0000050e
# offset 0x00000556: page record, payload 38 bytes
03 00 26 00 00 00 70 e1 36 3a 02 00 02 00 00 00 # 0x00000556
00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 01 # 0x00000566
00 04 01 00 00 00 00 00 00 00 00 00 00 00 00 00 # 0x00000576
# offset 0x00000517: page record, payload 31 bytes
03 00 1f 00 00 00 23 6a 6b 19 02 00 02 00 00 00 # 0x00000517
00 00 10 00 c0 00 00 04 00 00 00 08 00 00 00 01 # 0x00000527
00 41 00 00 00 00 00 00 00 # 0x00000537
# offset 0x00000586: history record, payload 6 bytes
04 00 06 00 00 00 b8 7a ba b1 01 00 00 00 00 00 # 0x00000586
# offset 0x00000540: history record, payload 6 bytes
04 00 06 00 00 00 b8 7a ba b1 01 00 00 00 00 00 # 0x00000540
# offset 0x00000596: finish record, payload 32 bytes
06 00 20 00 00 00 20 0f e0 7e 10 4f 2c da 90 fa # 0x00000596
65 19 a9 73 26 75 2a 5b 33 cd 9d fa 73 81 86 07 # 0x000005a6
9e 57 d4 83 41 9f a9 81 64 e7 # 0x000005b6
# offset 0x00000550: finish record, payload 32 bytes
06 00 20 00 00 00 5a 2d a8 f4 25 b2 49 b4 3e 64 # 0x00000550
5a 2e d6 7d 7f 3d 60 5b db 9b 4c 3a 29 00 97 ec # 0x00000560
d6 c7 1d ea da d3 a9 fd 3d 68 # 0x00000570

View File

@@ -1,14 +1,15 @@
# Ghostty snapshot fixture
# Kaitai type: grid
# Kaitai params: 2 3
# Kaitai params: 4 3
# Kaitai offset: 0
# Wire version: 1
# Generated by its snapshot test; review before replacing.
# On mismatch, the candidate is copied to the repository root.
# offset 0x00000000: encoded bytes
04 03 00 04 01 00 00 00 94 00 00 00 00 00 00 00 # 0x00000000
48 00 00 e1 01 00 00 00 00 00 00 0b 03 00 1e 00 # 0x00000010
34 03 00 04 01 00 00 00 94 00 00 00 00 00 00 00 # 0x00000000
48 00 00 e1 01 00 00 00 00 00 00 3b 03 00 1e 00 # 0x00000010
00 00 00 00 00 00 ab ee 32 03 00 10 00 00 00 00 # 0x00000020
00 00 00 0c 00 00 01 00 00 00 00 00 02 00 02 00 # 0x00000030
01 03 00 00 02 03 00 00 # 0x00000040
00 00 00 0c 00 00 00 03 00 68 00 69 10 02 00 16 # 0x00000030
04 21 00 01 00 00 00 00 00 02 00 02 00 01 03 00 # 0x00000040
00 02 03 00 00 # 0x00000050

View File

@@ -15,8 +15,8 @@
00 00 03 00 00 00 00 00 01 2a 00 00 00 00 00 00 # 0x00000024
00 00 00 00 01 00 02 01 00 00 00 61 05 00 00 00 # 0x00000034
61 6c 70 68 61 03 00 01 04 03 02 01 04 00 00 00 # 0x00000044
62 65 74 61 04 03 00 04 01 00 04 00 b4 01 00 00 # 0x00000054
00 00 0c 00 68 03 00 1e 00 00 04 00 20 01 00 0b # 0x00000064
62 65 74 61 34 03 00 04 01 00 04 00 b4 01 00 00 # 0x00000054
00 00 0c 00 68 03 00 1e 00 00 04 00 20 01 00 3b # 0x00000064
03 00 e1 01 00 00 00 00 00 00 ab ee 32 03 00 10 # 0x00000074
00 00 00 00 00 00 00 0c 00 00 01 00 00 00 01 00 # 0x00000084
00 00 02 00 01 03 00 00 02 03 00 00 # 0x00000094