Files
ghostty/src/terminal/snapshot/grid.zig
Mitchell Hashimoto d7bb4b8639 libghostty-vt: add C API for snapshotting functions
Expose terminal snapshot through the libghostty-vt C API and add
a new C example that runs in CI to verify this stuff works!

## Example

```c
size_t continuation_limit = 1024;
assert(ghostty_terminal_set(
    terminal,
    GHOSTTY_TERMINAL_OPT_CONTINUATION_MAX_BYTES,
    &continuation_limit) == GHOSTTY_SUCCESS);

uint8_t *bytes = NULL;
size_t len = 0;
assert(ghostty_snapshot_encode_alloc(
    terminal, NULL, &bytes, &len) == GHOSTTY_SUCCESS);

GhosttySnapshotDecoder decoder = NULL;
assert(ghostty_snapshot_decoder_new_buf(
    NULL, &decoder, bytes, len) == GHOSTTY_SUCCESS);

GhosttyTerminal restored = NULL;
assert(ghostty_snapshot_decoder_decode(
    decoder, &restored) == GHOSTTY_SUCCESS);

ghostty_snapshot_decoder_free(decoder);
ghostty_free(NULL, bytes, len);
```

Streaming decode:

```c
GhosttyReader reader = {
    .read = read_snapshot,
    .userdata = source,
};
GhosttySnapshotDecoder decoder = NULL;
assert(ghostty_snapshot_decoder_new(
    NULL, &decoder, reader) == GHOSTTY_SUCCESS);

GhosttyTerminal terminal = NULL;
assert(ghostty_snapshot_decoder_ready(
    decoder, &terminal) == GHOSTTY_SUCCESS);

GhosttyResult result;
while ((result = ghostty_snapshot_decoder_next(decoder)) ==
       GHOSTTY_SUCCESS) {
  size_t rows = 0;
  assert(ghostty_snapshot_decoder_get(
      decoder,
      GHOSTTY_SNAPSHOT_DECODER_DATA_PROGRESS_ROWS,
      &rows) == GHOSTTY_SUCCESS);
  render(terminal);
}
assert(result == GHOSTTY_NO_VALUE);
```
2026-08-03 13:09:04 -07:00

2064 lines
75 KiB
Zig

//! Grid (rows, cells, and grapheme suffixes) encoding.
//!
//! A grid contains the rows and cells of a terminal page. Its dimensions are
//! supplied by the containing record rather than repeated here. The encoder
//! writes exactly `rows` row records followed by one grapheme suffix section.
//!
//! All records are tightly packed with no padding between them. All integers
//! are unsigned and little-endian.
//!
//! The layout is designed so the common case decodes with bulk copies: cells
//! are fixed-size words, trailing default cells are elided per row, and the
//! variable-length grapheme suffixes live outside the row data.
//!
//! ```text
//! +---------------------------+
//! | Row 0 |
//! +---------------------------+
//! | ... |
//! +---------------------------+
//! | Row (rows - 1) |
//! +---------------------------+
//! | Grapheme suffix section |
//! +---------------------------+
//! ```
//!
//! ## Row
//!
//! Each row has the following format:
//!
//! | 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:
//!
//! | Bits | Field |
//! | ---: | :------------------------ |
//! | 0 | Wrap |
//! | 1 | Wrap continuation |
//! | 2-3 | Semantic prompt |
//! | 4-5 | Encoded cell width |
//! | 6-7 | Reserved, zero |
//!
//! Semantic prompt values are:
//!
//! | Value | Meaning |
//! | ----: | :------------------ |
//! | 0 | None |
//! | 1 | Prompt |
//! | 2 | Prompt continuation |
//!
//! Value 3 is not emitted in snapshot version 1. Decoders treat it as none.
//!
//! The encoded cell count must not exceed the grid's column count; it is a
//! structural field and decoders reject larger values. Cells at and beyond
//! the count are the default cell: all sixty-four bits zero, meaning an
//! empty narrow codepoint cell with the default style and no hyperlink.
//! 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, transported at the row's
//! encoded cell width as described above:
//!
//! ```text
//! bit 0 +-------------------------------+
//! | Content kind |
//! | 2 bits |
//! bit 2 +-------------------------------+
//! | Content |
//! | 24 bits |
//! bit 26 +-------------------------------+
//! | Style ID |
//! | 16 bits |
//! bit 42 +-------------------------------+
//! | Width kind |
//! | 2 bits |
//! bit 44 +-------------------------------+
//! | Protected |
//! bit 45 +-------------------------------+
//! | Hyperlink flag |
//! bit 46 +-------------------------------+
//! | Semantic content |
//! | 2 bits |
//! bit 48 +-------------------------------+
//! | Hyperlink ID |
//! | 16 bits |
//! bit 64 +-------------------------------+
//! ```
//!
//! Content kinds are:
//!
//! | Value | Meaning |
//! | ----: | :------------------------------- |
//! | 0 | Codepoint |
//! | 1 | Codepoint with grapheme suffix |
//! | 2 | Palette background |
//! | 3 | RGB background |
//!
//! The content kind selects the layout of the 24-bit content field. All
//! content bit positions below are relative to the start of the content
//! field, so content bit 0 is cell word bit 2.
//!
//! For codepoint content (kinds 0 and 1), the content field is a Unicode
//! scalar value. Values above U+10FFFF and surrogates decode as U+FFFD.
//! Kind 1 additionally declares that the grapheme suffix section contains
//! one entry for this cell; the cell is otherwise identical to kind 0.
//!
//! ```text
//! bit 0 +-------------------------------+
//! | Unicode scalar value |
//! | 24 bits |
//! bit 24 +-------------------------------+
//! ```
//!
//! For a palette background (kind 2), the low byte is the palette index.
//! The remaining content bits are reserved, canonically zero, and ignored
//! by decoders.
//!
//! ```text
//! bit 0 +-------------------------------+
//! | Palette index |
//! | 8 bits |
//! bit 8 +-------------------------------+
//! | Reserved, zero |
//! | 16 bits |
//! bit 24 +-------------------------------+
//! ```
//!
//! For an RGB background (kind 3), the content field is one byte per
//! channel:
//!
//! ```text
//! bit 0 +-------------------------------+
//! | Red |
//! | 8 bits |
//! bit 8 +-------------------------------+
//! | Green |
//! | 8 bits |
//! bit 16 +-------------------------------+
//! | Blue |
//! | 8 bits |
//! bit 24 +-------------------------------+
//! ```
//!
//! Width kinds are:
//!
//! | Value | Meaning |
//! | ----: | :---------- |
//! | 0 | Narrow |
//! | 1 | Wide |
//! | 2 | Spacer tail |
//! | 3 | Spacer head |
//!
//! Semantic content values are:
//!
//! | Value | Meaning |
//! | ----: | :------ |
//! | 0 | Output |
//! | 1 | Input |
//! | 2 | Prompt |
//!
//! Value 3 is not emitted in snapshot version 1. Decoders treat it as output.
//!
//! Style and hyperlink ID zero mean no style and no hyperlink. Other IDs
//! refer to entries in the containing record's separate style and hyperlink
//! tables. The hyperlink flag is set exactly when the hyperlink ID is
//! nonzero; decoders derive cell linkage from the remapped ID and ignore the
//! flag itself.
//!
//! ## Grapheme suffix section
//!
//! The section begins with an entry count followed by that many entries:
//!
//! | Offset | Size | Field |
//! | -----: | ------: | :------------------- |
//! | 0 | 4 | Entry count (`u32`) |
//! | 4 | varies | Entries |
//!
//! Each entry:
//!
//! | Offset | Size | Field |
//! | -----: | ----------: | :-------------------------- |
//! | 0 | 2 | Row (`u16`) |
//! | 2 | 2 | Column (`u16`) |
//! | 4 | 2 | Codepoint count (`u16`) |
//! | 6 | 4 * `count` | Codepoints (`u32` each) |
//!
//! Each codepoint is a Unicode scalar; invalid scalars are individually
//! ignored by decoders. Canonical encoders emit exactly one entry, with at
//! least one codepoint, for every kind 1 cell, in ascending row-then-column
//! order. Decoders consume every declared entry and ignore entries whose
//! target is out of range, is not a codepoint cell, has a zero codepoint, or
//! already received an entry. A kind 1 cell that receives no suffix
//! codepoints decodes as a plain codepoint cell.
const std = @import("std");
const assert = std.debug.assert;
const builtin = @import("builtin");
const Allocator = std.mem.Allocator;
const test_fixture = @import("fixture.zig");
const io = @import("io.zig");
const terminal_hyperlink = @import("../hyperlink.zig");
const terminal_page = @import("../page.zig");
const terminal_style = @import("../style.zig");
/// The Kitty virtual-placement placeholder remains wire-relevant even when
/// runtime Kitty graphics support is compiled out.
const kitty_virtual_placeholder: u21 = 0x10EEEE;
const TerminalCell = terminal_page.Cell;
const TerminalHyperlinkId = terminal_hyperlink.Id;
const TerminalPage = terminal_page.Page;
const TerminalRow = terminal_page.Row;
const TerminalStyleId = terminal_style.Id;
/// The header before every row's encoded cells.
///
/// 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. 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,
cell_width: Cell.EncodedWidth = .one,
_padding: u2 = 0,
};
/// The wire layout of one encoded cell. This is its own registry: the bit
/// positions and field meanings are part of the snapshot format and are
/// documented above independently of the native cell.
///
/// Enum-like fields are raw integers because decoders must accept reserved
/// values (for example semantic content 3) without instantiating an invalid
/// native enum.
pub const Cell = packed struct(u64) {
kind: u2 = @intFromEnum(Kind.codepoint),
content: u24 = 0,
style_id: u16 = 0,
width: u2 = 0,
protected: bool = false,
hyperlink: bool = false,
semantic_content: u2 = 0,
hyperlink_id: u16 = 0,
/// Determines how `content` is interpreted.
pub const Kind = enum(u2) {
codepoint = 0,
codepoint_grapheme = 1,
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
/// bit for bit, with the wire hyperlink ID occupying the native padding.
///
/// The wire format does not require this: it is an optimization. When it
/// holds, whole rows encode and decode as bulk copies. If the native layout
/// ever diverges, the portable field-by-field codec below remains correct
/// and this constant simply becomes false.
const native_matches_wire = native: {
if (@bitSizeOf(TerminalCell) != 64) break :native false;
// The bulk copies above are only sound if we can prove the two layouts
// agree, and we want native cell changes to demote us to the portable
// codec automatically rather than corrupt snapshots.
//
// Instead of pinning every native field offset and enum value w/ assertions
// that must be maintained by hand, each probe below pairs a native
// cell with the wire cell that must share its exact bit pattern; both
// sides bit cast to a word and any difference means some field moved
// or some enum member was renumbered.
//
// Three probes, one per content payload, cover the whole cell.
const probes = [_]struct {
native: TerminalCell,
wire: Cell,
}{
.{
.native = .{
.content_tag = .codepoint_grapheme,
.content = .{ .codepoint = .{ .data = 0x10FFFF } },
.style_id = 0xBEEF,
.wide = .spacer_tail,
.protected = true,
._padding = 0x1D2C,
},
.wire = .{
.kind = 1,
.content = 0x10FFFF,
.style_id = 0xBEEF,
.width = 2,
.protected = true,
// The native padding position, canonical wire or not.
.hyperlink_id = 0x1D2C,
},
},
.{
.native = .{
.content_tag = .bg_color_palette,
.content = .{ .color_palette = .{ .data = 0xAB } },
.wide = .spacer_head,
.hyperlink = true,
.semantic_content = .input,
},
.wire = .{
.kind = 2,
.content = 0xAB,
.width = 3,
.hyperlink = true,
.semantic_content = 1,
},
},
.{
.native = .{
.content_tag = .bg_color_rgb,
.content = .{ .color_rgb = .{
.r = 0x12,
.g = 0x34,
.b = 0x56,
} },
.wide = .wide,
.semantic_content = .prompt,
},
.wire = .{
.kind = 3,
.content = 0x563412,
.width = 1,
.semantic_content = 2,
},
},
};
for (probes) |probe| {
const native_bits: u64 = @bitCast(probe.native);
const wire_bits: u64 = @bitCast(probe.wire);
if (native_bits != wire_bits) break :native false;
}
break :native true;
};
/// Whether rows of cells can be copied between native and wire storage
/// without per-cell transformation.
const bulk_codec = native_matches_wire and
builtin.cpu.arch.endian() == .little;
pub const EncodeError = std.Io.Writer.Error || error{
/// Wide and spacer cells do not form a valid row.
InvalidWideCell,
/// One cell's grapheme suffix exceeds the entry's u16 codepoint count.
TooManyGraphemes,
};
/// Encode every row, cell, and grapheme suffix directly from a page.
///
/// If an error is returned, partial data may have been written. If you
/// want transactional writing, the caller is responsible for using something
/// like a seekable stream and rolling back.
pub fn encode(
page: *const TerminalPage,
writer: *std.Io.Writer,
) EncodeError!void {
defer page.assertIntegrity();
for (0..page.size.rows) |y| {
const row = page.getRow(y);
const cells = page.getCells(row);
// Trailing default cells decode implicitly. Wide/spacer pairs and
// hyperlinked or styled cells are always nonzero, so eliding the
// zero suffix never drops encoded state.
const count: usize = count: {
var i: usize = cells.len;
while (i > 0) : (i -= 1) {
if (!cells[i - 1].isZero()) break :count i;
}
break :count 0;
};
// Validate the wide state of every encoded cell so we don't encode
// 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 => {},
.wide => if (x + 1 == cells.len or
cells[x + 1].wide != .spacer_tail)
{
return error.InvalidWideCell;
},
.spacer_tail => if (x == 0 or
cells[x - 1].wide != .wide)
{
return error.InvalidWideCell;
},
.spacer_head => if (x + 1 != cells.len or !row.wrap) {
return error.InvalidWideCell;
},
}
word_or |= classifyWord(cell);
}
// 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);
}
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,
writer: *std.Io.Writer,
) EncodeError!void {
// Count and validate entries before the section header so the count is
// always exact. Rows without the native grapheme hint contain no
// grapheme cells in any intact page.
var entries: u32 = 0;
for (0..page.size.rows) |y| {
const row = page.getRow(y);
if (!row.grapheme) continue;
for (page.getCells(row)) |*cell| {
if (!cell.hasGrapheme()) continue;
const cps = page.lookupGrapheme(cell) orelse unreachable;
if (cps.len > std.math.maxInt(u16)) return error.TooManyGraphemes;
entries += 1;
}
}
try io.writeInt(writer, u32, entries);
if (entries == 0) return;
for (0..page.size.rows) |y| {
const row = page.getRow(y);
if (!row.grapheme) continue;
for (page.getCells(row), 0..) |*cell, x| {
if (!cell.hasGrapheme()) continue;
const cps = page.lookupGrapheme(cell) orelse unreachable;
try io.writeInt(writer, u16, @intCast(y));
try io.writeInt(writer, u16, @intCast(x));
try io.writeInt(writer, u16, @intCast(cps.len));
for (cps) |cp| try io.writeInt(writer, u32, cp);
}
}
}
pub const DecodeError = std.Io.Reader.Error || error{
/// A row declares more encoded cells than the grid has columns.
InvalidRowCellCount,
};
/// Maps encoded style table IDs to page-assigned style IDs.
pub const StyleRemap = Remap(TerminalStyleId);
/// Maps encoded hyperlink table IDs to page-assigned hyperlink IDs.
pub const HyperlinkRemap = Remap(TerminalHyperlinkId);
/// Decode every row, cell, and grapheme suffix directly into an
/// initialized, empty page.
///
/// The grid does not encode dimensions, so `page` must already have the
/// exact row and column count expected by the containing record. Capacity
/// hints are advisory. Graphemes and cell hyperlink references that do not
/// fit are discarded without affecting the rest of the grid.
///
/// Style and hyperlink table entries must be inserted into `page` before
/// calling this function, with their encoded and page-assigned IDs recorded
/// in `style_remap` and `hyperlink_remap`. ID zero always means the default
/// style or no hyperlink. A nonzero ID missing from its remap is also
/// treated as zero so unknown table references do not prevent the rest of
/// the grid from decoding.
///
/// Invalid semantic data is normalized into a degraded form while
/// preserving the declared byte boundaries. Unknown semantic values use
/// their neutral variants, invalid Unicode becomes U+FFFD, invalid optional
/// data is ignored, and malformed wide-cell relationships become narrow
/// cells. The encoded cell count is the only structural field.
pub fn decode(
page: *TerminalPage,
reader: *std.Io.Reader,
style_remap: *const StyleRemap,
hyperlink_remap: *const HyperlinkRemap,
) DecodeError!void {
for (0..page.size.rows) |y| {
// Read the row header and cell count.
const row_header: Row, const count: u16 = header: {
// The staged payload path has every header buffered.
var row_header_bytes: [3]u8 = undefined;
if (reader.bufferedLen() >= 3) {
row_header_bytes = reader.buffered()[0..3].*;
reader.toss(3);
} else {
try reader.readSliceAll(&row_header_bytes);
}
// 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 };
};
const row = page.getRow(y);
row.wrap = row_header.wrap;
row.wrap_continuation = row_header.wrap_continuation;
row.semantic_prompt = std.enums.fromInt(
TerminalRow.SemanticPrompt,
row_header.semantic_prompt,
) orelse .none;
const cells = page.getCells(row);
if (count > cells.len) return error.InvalidRowCellCount;
if (count == 0) continue;
// 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. 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
/// validation, reserved-value degradation, style and hyperlink remapping
/// with reference counting, and wide-pair normalization against already
/// decoded neighbors.
fn applyCell(
page: *TerminalPage,
row: *TerminalRow,
cells: []TerminalCell,
x: usize,
bits_wire: u64,
style_remap: *const StyleRemap,
hyperlink_remap: *const HyperlinkRemap,
) void {
const cell = &cells[x];
// The default cell is the common case and needs no normalization,
// reference counting, or table lookups.
if (bits_wire == 0) {
storeCell(cell, 0);
normalizeWide(row, cells, x);
return;
}
// Any word is a valid wire cell because its fields are raw integers,
// so all normalization below is plain field access.
var wire: Cell = @bitCast(bits_wire);
// Hyperlink linkage is derived from the remapped ID below. The stored
// native cell starts unlinked either way.
const link_encoded = wire.hyperlink_id;
wire.hyperlink_id = 0;
wire.hyperlink = false;
switch (@as(Cell.Kind, @enumFromInt(wire.kind))) {
// Kind 1 differs from 0 only by declaring a grapheme suffix section
// entry, which reattaches through the native grapheme APIs later.
.codepoint, .codepoint_grapheme => {
wire.kind = @intFromEnum(Cell.Kind.codepoint);
if (!validScalar(wire.content)) wire.content = 0xFFFD;
// Kitty image and placement state is not part of this snapshot
// version, but the placeholder is still a valid Unicode scalar.
// Preserve it and derive the native row hint so later row
// operations remain correct.
if (wire.content == kitty_virtual_placeholder) {
row.kitty_virtual_placeholder = true;
}
},
// Only the palette index is meaningful; the remaining content bits
// are reserved and must not obscure it.
.bg_color_palette => wire.content = @as(u8, @truncate(wire.content)),
.bg_color_rgb => {},
}
// Reserved semantic content degrades to plain output.
wire.semantic_content = @intFromEnum(std.enums.fromInt(
TerminalCell.SemanticContent,
wire.semantic_content,
) orelse .output);
// IDs belong to the encoded page. Translate them to IDs assigned by the
// destination page before storing them on cells. The table owns one
// reference and each decoded cell owns one additional reference.
if (wire.style_id != 0) {
wire.style_id = style_remap.get(wire.style_id);
if (wire.style_id != 0) {
page.styles.use(page.memory, wire.style_id);
row.styled = true;
}
}
storeCell(cell, @bitCast(wire));
if (link_encoded != 0) link: {
const link_native = hyperlink_remap.get(link_encoded);
if (link_native == 0) break :link;
// setHyperlink records the cell mapping but intentionally does not
// increment the set's reference count. If its map is full, undo our
// reference and leave this cell unlinked.
page.hyperlink_set.use(page.memory, link_native);
page.setHyperlink(row, cell, link_native) catch {
page.hyperlink_set.release(page.memory, link_native);
};
}
normalizeWide(row, cells, x);
}
/// Resolve wide-pair relationships for the cell at `x` against its already
/// normalized predecessors.
fn normalizeWide(row: *const TerminalRow, cells: []TerminalCell, x: usize) void {
// A following cell resolves whether the previous wide marker owns a
// tail. Normalize the current marker immediately when possible,
// including a wide marker at the row end.
if (x > 0 and
cells[x - 1].wide == .wide and
cells[x].wide != .spacer_tail)
{
cells[x - 1].wide = .narrow;
}
switch (cells[x].wide) {
.narrow => {},
// A non-final wide marker remains pending until the next cell.
.wide => if (x + 1 == cells.len) {
cells[x].wide = .narrow;
},
.spacer_tail => if (x == 0 or
cells[x - 1].wide != .wide)
{
cells[x].wide = .narrow;
},
.spacer_head => if (x + 1 != cells.len or !row.wrap) {
cells[x].wide = .narrow;
},
}
}
/// Decode the grapheme suffix section into already decoded cells.
fn decodeGraphemes(
page: *TerminalPage,
reader: *std.Io.Reader,
) DecodeError!void {
const entries = try io.readInt(reader, u32);
for (0..entries) |_| {
const y = try io.readInt(reader, u16);
const x = try io.readInt(reader, u16);
const cp_count = try io.readInt(reader, u16);
// Resolve the target cell. Entries whose target cannot carry a
// suffix are optional detail: their codepoints are consumed to
// preserve framing and then dropped.
const target: ?struct {
row: *TerminalRow,
cell: *TerminalCell,
} = target: {
if (y >= page.size.rows or x >= page.size.cols) {
break :target null;
}
const row = page.getRow(y);
const cell = &page.getCells(row)[x];
// Cell decoding stores every kind 1 cell as a plain codepoint,
// so this also gives duplicate entries first-wins semantics.
if (cell.content_tag != .codepoint) break :target null;
if (cell.content.codepoint.data == 0) break :target null;
break :target .{ .row = row, .cell = cell };
};
// Always consume every declared codepoint. Invalid scalars and NUL are
// not meaningful grapheme suffix components and are ignored. If native
// capacity is exhausted, remove any prefix already attached so the
// cell never exposes a truncated cluster.
var accept = target != null;
for (0..cp_count) |_| {
const cp = try io.readInt(reader, u32);
if (!accept) continue;
if (cp == 0 or !validScalar(cp)) continue;
page.appendGrapheme(
target.?.row,
target.?.cell,
@intCast(cp),
) catch {
if (target.?.cell.hasGrapheme()) {
page.clearGrapheme(target.?.cell);
page.updateRowGraphemeFlag(target.?.row);
}
accept = false;
};
}
}
}
/// The encoded word for one native cell and its hyperlink ID.
fn cellBits(cell: TerminalCell, link_id: TerminalHyperlinkId) u64 {
if (comptime native_matches_wire) {
var wire: Cell = @bitCast(cell);
wire.hyperlink = link_id != 0;
wire.hyperlink_id = link_id;
return @bitCast(wire);
}
const wire: Cell = .{
.kind = @intFromEnum(cell.content_tag),
.content = switch (cell.content_tag) {
.codepoint,
.codepoint_grapheme,
=> cell.content.codepoint.data,
.bg_color_palette => cell.content.color_palette.data,
.bg_color_rgb => @as(u24, cell.content.color_rgb.r) |
(@as(u24, cell.content.color_rgb.g) << 8) |
(@as(u24, cell.content.color_rgb.b) << 16),
},
.style_id = cell.style_id,
.width = switch (cell.wide) {
.narrow => 0,
.wide => 1,
.spacer_tail => 2,
.spacer_head => 3,
},
.protected = cell.protected,
.hyperlink = link_id != 0,
.semantic_content = switch (cell.semantic_content) {
.output => 0,
.input => 1,
.prompt => 2,
},
.hyperlink_id = link_id,
};
return @bitCast(wire);
}
/// Store one normalized, hyperlink-free wire word as a native cell.
fn storeCell(cell: *TerminalCell, bits: u64) void {
if (comptime native_matches_wire) {
const words: [*]u64 = @ptrCast(cell);
words[0] = bits;
return;
}
const wire: Cell = @bitCast(bits);
var native: TerminalCell = .init(0);
switch (@as(Cell.Kind, @enumFromInt(wire.kind))) {
.codepoint, .codepoint_grapheme => native.content = .{
.codepoint = .{ .data = @intCast(wire.content) },
},
.bg_color_palette => {
native.content_tag = .bg_color_palette;
native.content = .{
.color_palette = .{ .data = @truncate(wire.content) },
};
},
.bg_color_rgb => {
native.content_tag = .bg_color_rgb;
native.content = .{ .color_rgb = .{
.r = @truncate(wire.content),
.g = @truncate(wire.content >> 8),
.b = @truncate(wire.content >> 16),
} };
},
}
native.style_id = wire.style_id;
native.wide = @enumFromInt(wire.width);
native.protected = wire.protected;
native.semantic_content = @enumFromInt(wire.semantic_content);
cell.* = native;
}
/// Maps encoded table IDs to IDs assigned by the destination page.
///
/// Build this by inserting each decoded table entry into the page, then
/// recording the encoded ID and the ID returned by the page's set. ID zero
/// is implicit and does not need an entry. Lookup must be cheap because the
/// grid decoder consults it for every styled or linked cell, so this is a
/// direct-indexed table rather than a hash map.
fn Remap(comptime Id: type) type {
return struct {
const Self = @This();
/// One slot for every possible encoded ID.
pub const capacity = std.math.maxInt(Id) + 1;
/// Indexed by encoded ID; zero means unmapped or default.
entries: []Id,
/// Tracks IDs that received an entry, including ones mapped to the
/// default, so callers can give duplicate table entries first-wins
/// semantics.
seen: std.DynamicBitSetUnmanaged,
pub fn init(alloc: Allocator) Allocator.Error!Self {
const entries = try alloc.alloc(Id, capacity);
errdefer alloc.free(entries);
@memset(entries, 0);
const seen = try std.DynamicBitSetUnmanaged.initEmpty(
alloc,
capacity,
);
return .{ .entries = entries, .seen = seen };
}
pub fn deinit(self: *Self, alloc: Allocator) void {
alloc.free(self.entries);
self.seen.deinit(alloc);
self.* = undefined;
}
/// Record one encoded-to-native mapping.
pub fn put(self: *Self, encoded: Id, native: Id) void {
assert(!self.seen.isSet(encoded));
self.entries[encoded] = native;
self.seen.set(encoded);
}
/// Whether the encoded ID already has an entry, even a default one.
pub fn contains(self: *const Self, encoded: Id) bool {
return self.seen.isSet(encoded);
}
/// The native ID for an encoded ID, or zero when unmapped.
pub inline fn get(self: *const Self, encoded: Id) Id {
return self.entries[encoded];
}
};
}
const test_golden_fixture = test_fixture.parse(
@embedFile("testdata/grid-v1.hex"),
);
test "cell wire layout registry" {
const testing = std.testing;
// The wire bit positions are format constants. The shifts and masks
// derive from the packed struct, so pin the struct itself to the
// documented registry so an edit cannot silently move wire bits.
try testing.expectEqual(0, @bitOffsetOf(Cell, "kind"));
try testing.expectEqual(2, @bitOffsetOf(Cell, "content"));
try testing.expectEqual(26, @bitOffsetOf(Cell, "style_id"));
try testing.expectEqual(42, @bitOffsetOf(Cell, "width"));
try testing.expectEqual(44, @bitOffsetOf(Cell, "protected"));
try testing.expectEqual(45, @bitOffsetOf(Cell, "hyperlink"));
try testing.expectEqual(46, @bitOffsetOf(Cell, "semantic_content"));
try testing.expectEqual(48, @bitOffsetOf(Cell, "hyperlink_id"));
// The native cell currently matches the wire registry, which enables
// the bulk row codec. If this fails, the native layout diverged: either
// restore it or accept the portable codec and update this expectation.
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 = 4,
.styles = 0,
.hyperlink_bytes = 0,
.grapheme_bytes = 64,
.string_bytes = 0,
};
var source = try TerminalPage.init(capacity);
defer source.deinit();
// The first row covers semantic metadata, a wide-cell pair, and a
// multi-codepoint grapheme.
const wide = source.getRowAndCell(0, 0);
wide.row.semantic_prompt = .prompt;
wide.cell.* = .init('A');
wide.cell.wide = .wide;
wide.cell.protected = true;
wide.cell.semantic_content = .prompt;
const tail = source.getRowAndCell(1, 0);
tail.cell.wide = .spacer_tail;
tail.cell.semantic_content = .input;
const grapheme = source.getRowAndCell(2, 0);
grapheme.cell.* = .init('x');
try source.setGraphemes(
grapheme.row,
grapheme.cell,
&.{ 0x0301, 0x0302 },
);
// The second row covers both background-color kinds and a wrapped spacer
// head with the remaining row semantic value.
const palette = source.getRowAndCell(0, 1);
palette.cell.content_tag = .bg_color_palette;
palette.cell.content = .{ .color_palette = .{ .data = 7 } };
const rgb = source.getRowAndCell(1, 1);
rgb.cell.content_tag = .bg_color_rgb;
rgb.cell.content = .{ .color_rgb = .{
.r = 0xaa,
.g = 0xbb,
.b = 0xcc,
} };
rgb.cell.protected = true;
const head = source.getRowAndCell(2, 1);
head.cell.wide = .spacer_head;
head.row.wrap = true;
head.row.wrap_continuation = true;
head.row.semantic_prompt = .prompt_continuation;
// 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(
.bytes,
"src/terminal/snapshot/testdata/grid-v1.hex",
"snapshot_fixture-grid-v1.hex",
&test_golden_fixture,
writer.buffered(),
);
var destination = try TerminalPage.init(capacity);
defer destination.deinit();
var style_remap = try StyleRemap.init(std.testing.allocator);
defer style_remap.deinit(std.testing.allocator);
var hyperlink_remap = try HyperlinkRemap.init(std.testing.allocator);
defer hyperlink_remap.deinit(std.testing.allocator);
// Decode the checked-in reference through a one-byte reader buffer.
var fixture_reader: std.Io.Reader = .fixed(&test_golden_fixture);
var read_buffer: [1]u8 = undefined;
var limited = fixture_reader.limited(.unlimited, &read_buffer);
try decode(
&destination,
&limited.interface,
&style_remap,
&hyperlink_remap,
);
try destination.verifyIntegrity(std.testing.allocator);
const decoded_wide = destination.getRowAndCell(0, 0);
try std.testing.expectEqual(@as(u21, 'A'), decoded_wide.cell.codepoint());
try std.testing.expectEqual(TerminalCell.Wide.wide, decoded_wide.cell.wide);
try std.testing.expect(decoded_wide.cell.protected);
try std.testing.expectEqual(
TerminalCell.SemanticContent.prompt,
decoded_wide.cell.semantic_content,
);
try std.testing.expectEqual(
TerminalRow.SemanticPrompt.prompt,
decoded_wide.row.semantic_prompt,
);
const decoded_tail = destination.getRowAndCell(1, 0);
try std.testing.expectEqual(
TerminalCell.Wide.spacer_tail,
decoded_tail.cell.wide,
);
try std.testing.expectEqual(
TerminalCell.SemanticContent.input,
decoded_tail.cell.semantic_content,
);
const decoded_grapheme = destination.getRowAndCell(2, 0);
try std.testing.expectEqualSlices(
u21,
&.{ 0x0301, 0x0302 },
destination.lookupGrapheme(decoded_grapheme.cell).?,
);
const decoded_palette = destination.getRowAndCell(0, 1);
try std.testing.expectEqual(
TerminalCell.ContentTag.bg_color_palette,
decoded_palette.cell.content_tag,
);
try std.testing.expectEqual(
@as(u8, 7),
decoded_palette.cell.content.color_palette.data,
);
const decoded_rgb = destination.getRowAndCell(1, 1);
try std.testing.expectEqual(
TerminalCell.ContentTag.bg_color_rgb,
decoded_rgb.cell.content_tag,
);
try std.testing.expectEqual(
TerminalCell.RGB{ .r = 0xaa, .g = 0xbb, .b = 0xcc },
decoded_rgb.cell.content.color_rgb,
);
try std.testing.expect(decoded_rgb.cell.protected);
const decoded_head = destination.getRowAndCell(2, 1);
try std.testing.expectEqual(
TerminalCell.Wide.spacer_head,
decoded_head.cell.wide,
);
try std.testing.expect(decoded_head.row.wrap);
try std.testing.expect(decoded_head.row.wrap_continuation);
try std.testing.expectEqual(
TerminalRow.SemanticPrompt.prompt_continuation,
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: [160]u8 = undefined;
var rewriter: std.Io.Writer = .fixed(&reencoded);
try encode(&destination, &rewriter);
try std.testing.expectEqualStrings(
&test_golden_fixture,
rewriter.buffered(),
);
}
test "grid elides trailing default cells" {
const testing = std.testing;
var page = try TerminalPage.init(.{ .cols = 80, .rows = 3 });
defer page.deinit();
// Row 0 is fully default. Row 1 has content in columns zero and two.
// Row 2 has one protected-only cell at column four.
const middle = page.getRowAndCell(2, 1);
middle.cell.* = .init('b');
page.getRowAndCell(0, 1).cell.* = .init('a');
page.getRowAndCell(4, 2).cell.protected = true;
var encoded: [256]u8 = undefined;
var writer: std.Io.Writer = .fixed(&encoded);
try encode(&page, &writer);
// 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 * 1) + (3 + 5 * 8) + 4),
writer.buffered().len,
);
var destination = try TerminalPage.init(.{ .cols = 80, .rows = 3 });
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);
var reader: std.Io.Reader = .fixed(writer.buffered());
try decode(&destination, &reader, &style_remap, &hyperlink_remap);
try destination.verifyIntegrity(testing.allocator);
try testing.expectEqual(
@as(u21, 'a'),
destination.getRowAndCell(0, 1).cell.codepoint(),
);
try testing.expectEqual(
@as(u21, 'b'),
destination.getRowAndCell(2, 1).cell.codepoint(),
);
try testing.expect(destination.getRowAndCell(4, 2).cell.protected);
try testing.expect(destination.getRowAndCell(79, 1).cell.isZero());
}
test "grid rejects a row cell count above the column count" {
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: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
try writer.writeByte(@bitCast(Row{}));
try io.writeInt(&writer, u16, 3);
for (0..3) |_| try io.writeInt(&writer, u64, 0);
try io.writeInt(&writer, u32, 0);
var reader: std.Io.Reader = .fixed(writer.buffered());
try testing.expectError(
error.InvalidRowCellCount,
decode(&page, &reader, &style_remap, &hyperlink_remap),
);
}
test "grid normalizes incomplete wide cells" {
const testing = std.testing;
// One column puts the wide cell at the row end. Two columns put an
// ordinary narrow cell after it. Neither case supplies a spacer tail.
// Both rely on the implicit narrow cell rule when the tail is elided.
for ([_]u16{ 1, 2 }) |columns| {
const capacity: terminal_page.Capacity = .{
.cols = columns,
.rows = 1,
};
// Craft the malformed row directly on the wire. Decoding keeps its
// 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{ .cell_width = .eight }));
try io.writeInt(&payload_writer, u16, columns);
try io.writeInt(&payload_writer, u64, @bitCast(Cell{
.width = 1, // wide
.content = 'A',
}));
if (columns > 1) try io.writeInt(&payload_writer, u64, @bitCast(Cell{
.content = 'B',
}));
try io.writeInt(&payload_writer, u32, 0);
var destination = try TerminalPage.init(capacity);
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);
var payload_reader: std.Io.Reader = .fixed(
payload_writer.buffered(),
);
try decode(
&destination,
&payload_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(
TerminalCell.Wide.narrow,
destination.getRowAndCell(0, 0).cell.wide,
);
if (columns > 1) {
const second = destination.getRowAndCell(1, 0).cell;
try testing.expectEqual(@as(u21, 'B'), second.codepoint());
try testing.expectEqual(TerminalCell.Wide.narrow, second.wide);
}
}
// A wide marker whose spacer tail was elided by a short cell count is
// also normalized, and the trailing cells stay default.
var page = try TerminalPage.init(.{ .cols = 4, .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: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
try writer.writeByte(@bitCast(Row{ .cell_width = .eight }));
try io.writeInt(&writer, u16, 1);
try io.writeInt(&writer, u64, @bitCast(Cell{
.width = 1, // wide
.content = 'W',
}));
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(
TerminalCell.Wide.narrow,
page.getRowAndCell(0, 0).cell.wide,
);
try testing.expect(page.getRowAndCell(1, 0).cell.isZero());
}
test "grid normalizes reserved cell values" {
const testing = std.testing;
var page = try TerminalPage.init(.{ .cols = 3, .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: [64]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
// 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);
// A surrogate codepoint with reserved semantic content 3.
try io.writeInt(&writer, u64, @bitCast(Cell{
.content = 0xD800,
.semantic_content = 3,
}));
// Reserved palette content bits do not obscure the palette index.
try io.writeInt(&writer, u64, @bitCast(Cell{
.kind = 2,
.content = 0xFFFF07,
}));
// An unknown style reference degrades to the default style, and an
// unknown hyperlink reference leaves the cell unlinked.
try io.writeInt(&writer, u64, @bitCast(Cell{
.content = 'x',
.style_id = 5,
.hyperlink = true,
.hyperlink_id = 9,
}));
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.expect(first.row.wrap);
try testing.expectEqual(
TerminalRow.SemanticPrompt.none,
first.row.semantic_prompt,
);
try testing.expectEqual(@as(u21, 0xFFFD), first.cell.codepoint());
try testing.expectEqual(
TerminalCell.SemanticContent.output,
first.cell.semantic_content,
);
const second = page.getRowAndCell(1, 0).cell;
try testing.expectEqual(
TerminalCell.ContentTag.bg_color_palette,
second.content_tag,
);
try testing.expectEqual(@as(u8, 7), second.content.color_palette.data);
const third = page.getRowAndCell(2, 0).cell;
try testing.expectEqual(@as(u21, 'x'), third.codepoint());
try testing.expectEqual(@as(TerminalStyleId, 0), third.style_id);
try testing.expect(!third.hyperlink);
try testing.expectEqual(null, page.lookupHyperlink(third));
}
test "grid drops undeliverable grapheme entries" {
const testing = std.testing;
var page = try TerminalPage.init(.{
.cols = 4,
.rows = 1,
.grapheme_bytes = 64,
});
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: [128]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
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' }));
// A kind 1 cell without an entry decodes as a plain codepoint.
try io.writeInt(&writer, u64, @bitCast(Cell{ .kind = 1, .content = 'y' }));
// A background cell cannot carry a suffix.
try io.writeInt(&writer, u64, @bitCast(Cell{ .kind = 2, .content = 7 }));
// An empty codepoint cannot carry a suffix.
try io.writeInt(&writer, u64, @bitCast(Cell{}));
try io.writeInt(&writer, u32, 5);
// Valid entry with one invalid scalar dropped from within it.
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 4);
try io.writeInt(&writer, u32, 0x0301);
try io.writeInt(&writer, u32, 0);
try io.writeInt(&writer, u32, 0xD800);
try io.writeInt(&writer, u32, 0x0302);
// Duplicate entry for the same cell is consumed and dropped.
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 1);
try io.writeInt(&writer, u32, 0x0303);
// Entry for the background cell is consumed and dropped.
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 2);
try io.writeInt(&writer, u16, 1);
try io.writeInt(&writer, u32, 0x0301);
// Entry for the empty cell is consumed and dropped.
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 3);
try io.writeInt(&writer, u16, 1);
try io.writeInt(&writer, u32, 0x0301);
// Entry outside the grid is consumed and dropped.
try io.writeInt(&writer, u16, 7);
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 1);
try io.writeInt(&writer, u32, 0x0301);
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.expectEqualSlices(
u21,
&.{ 0x0301, 0x0302 },
page.lookupGrapheme(first.cell).?,
);
const second = page.getRowAndCell(1, 0).cell;
try testing.expectEqual(@as(u21, 'y'), second.codepoint());
try testing.expect(!second.hasGrapheme());
try testing.expect(!page.getRowAndCell(2, 0).cell.hasGrapheme());
try testing.expect(!page.getRowAndCell(3, 0).cell.hasGrapheme());
}
test "grid drops a complete grapheme when capacity fails mid-cluster" {
const testing = std.testing;
var page = try TerminalPage.init(.{
.cols = 1,
.rows = 1,
.grapheme_bytes = 16,
});
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: [1024]u8 = undefined;
var writer: std.Io.Writer = .fixed(&payload);
try writer.writeByte(@bitCast(Row{ .cell_width = .eight }));
try io.writeInt(&writer, u16, 1);
try io.writeInt(&writer, u64, @bitCast(Cell{ .kind = 1, .content = 'x' }));
try io.writeInt(&writer, u32, 1);
try io.writeInt(&writer, u16, 0);
try io.writeInt(&writer, u16, 0);
// BitmapAllocator rounds this capacity to 64 four-codepoint chunks. The
// 129th suffix needs 33 new chunks while the old 32-chunk slice is still
// live, forcing the append's atomic replacement allocation to fail.
try io.writeInt(&writer, u16, 129);
for (0..129) |i| try io.writeInt(
&writer,
u32,
@intCast(0x0300 + i),
);
var reader: std.Io.Reader = .fixed(writer.buffered());
try decode(&page, &reader, &style_remap, &hyperlink_remap);
try page.verifyIntegrity(testing.allocator);
const cell = page.getRowAndCell(0, 0);
try testing.expectEqual(@as(u21, 'x'), cell.cell.codepoint());
try testing.expect(!cell.cell.hasGrapheme());
try testing.expect(!cell.row.grapheme);
try testing.expectEqual(@as(usize, 0), page.graphemeCount());
}
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_virtual_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(
kitty_virtual_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);
}