mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
terminal/snapshot: single-pass style entry codec
Style entries went through roughly five writer or reader vtable calls
each: encode wrote three 4-byte colors and two u16s separately, and
decode read 16 bytes into a stack buffer only to re-parse it through a
nested fixed reader, one small read per field. Style-heavy pages carry
hundreds of entries per page, so encode now assembles each entry in a
16-byte buffer with a single write, and decode parses the fixed-size
entry directly from a byte array. Entries additionally parse straight
from the buffered payload (ID and value together) when it is contiguous.
Inserting a decoded style also hashed twice: an explicit `lookup` before
`add`, even though `add` already returns the existing entry for repeated
values. Insert with `add` alone, taking one reference per accepted table
entry, and surrender those references through the encoded-ID remap after
grid decoding, the same scheme hyperlink entries already use. Refcount
outcomes are identical: each distinct style nets its cell references.
Benchmarks ("prev" is the parent commit):
| wasm | encode prev | encode | decode prev | decode |
|-----------|------------:|---------:|------------:|---------:|
| ascii | 2.13 ms | 2.12 ms | 2.54 ms | 2.59 ms |
| styled | 2.29 ms | 2.23 ms | 6.75 ms | 6.43 ms |
| truecolor | 4.95 ms | 3.44 ms | 12.05 ms | 8.35 ms |
| cjk | 6.23 ms | 5.99 ms | 11.39 ms | 11.39 ms |
| grapheme | 8.72 ms | 8.33 ms | 11.27 ms | 10.89 ms |
| native | mode | prev | this |
|--------|--------|--------:|--------:|
| ascii | encode | 24.4 ms | 24.7 ms |
| ascii | decode | 45.1 ms | 47.5 ms |
| utf8 | encode | 41.9 ms | 41.9 ms |
| utf8 | decode | 58.6 ms | 58.8 ms |
This commit is contained in:
@@ -382,23 +382,36 @@ fn decodePayloadBody(
|
||||
.empty;
|
||||
defer hyperlink_remap.deinit(alloc);
|
||||
|
||||
// Styles
|
||||
// Styles. The complete fixed-size entry is parsed from the buffered
|
||||
// payload when possible so each entry costs no reader calls.
|
||||
const style_entry_len = @sizeOf(TerminalStyleId) + style.len;
|
||||
for (0..header.style_count) |_| {
|
||||
const native_id = try io.readInt(reader, TerminalStyleId);
|
||||
const value = try style.decodeOrNull(reader);
|
||||
const native_id: TerminalStyleId, const value = entry: {
|
||||
if (reader.bufferedLen() >= style_entry_len) {
|
||||
const bytes = reader.buffered()[0..style_entry_len];
|
||||
defer reader.toss(style_entry_len);
|
||||
break :entry .{
|
||||
std.mem.readInt(TerminalStyleId, bytes[0..2], .little),
|
||||
style.parseOrNull(bytes[2..][0..style.len]),
|
||||
};
|
||||
}
|
||||
break :entry .{
|
||||
try io.readInt(reader, TerminalStyleId),
|
||||
try style.decodeOrNull(reader),
|
||||
};
|
||||
};
|
||||
|
||||
// Zero is reserved for the implicit default. For a duplicate encoded
|
||||
// ID, the first entry wins and this complete entry is simply ignored.
|
||||
if (native_id == 0 or style_remap.contains(native_id)) continue;
|
||||
|
||||
// Invalid/default styles map to the native default. Repeated concrete
|
||||
// values share the existing native entry, while capacity failure also
|
||||
// degrades only this style.
|
||||
// Invalid/default styles map to the native default. `add` returns
|
||||
// the existing entry for a repeated concrete value, taking one
|
||||
// reference either way, while capacity failure degrades only this
|
||||
// style. The references are surrendered through the remap below
|
||||
// once every cell reference is installed.
|
||||
const decoded_id: TerminalStyleId = if (value) |valid| decoded: {
|
||||
if (valid.default()) break :decoded 0;
|
||||
if (page.styles.lookup(page.memory, valid)) |existing| {
|
||||
break :decoded existing;
|
||||
}
|
||||
break :decoded page.styles.add(
|
||||
page.memory,
|
||||
valid,
|
||||
@@ -434,16 +447,17 @@ fn decodePayloadBody(
|
||||
&hyperlink_remap,
|
||||
);
|
||||
|
||||
// A newly inserted table value starts with one reference so grid decoding
|
||||
// can safely attach it to any number of cells. Unlike organically built
|
||||
// pages, that initial reference does not itself represent a cell. Release
|
||||
// it once per distinct live style after every cell reference is installed;
|
||||
// unused entries then become dead and disappear from canonical re-encoding.
|
||||
for (1..@as(usize, page.styles.next_id)) |raw_id| {
|
||||
const id: TerminalStyleId = @intCast(raw_id);
|
||||
if (page.styles.refCount(page.memory, id) > 0) {
|
||||
page.styles.release(page.memory, id);
|
||||
}
|
||||
// Every accepted table entry took one reference through `add` so grid
|
||||
// decoding can safely attach its style to any number of cells. Unlike
|
||||
// organically built pages, those references do not themselves represent
|
||||
// cells. Release through the encoded-ID remap, so duplicate values
|
||||
// which deduplicated to the same native ID each surrender their own
|
||||
// reference; unused entries then become dead and disappear from
|
||||
// canonical re-encoding.
|
||||
var style_it = style_remap.seen.iterator(.{});
|
||||
while (style_it.next()) |encoded_id| {
|
||||
const id = style_remap.entries[encoded_id];
|
||||
if (id != 0) page.styles.release(page.memory, id);
|
||||
}
|
||||
|
||||
// Hyperlink insertion likewise creates one temporary reference for every
|
||||
|
||||
@@ -47,19 +47,14 @@
|
||||
|
||||
const std = @import("std");
|
||||
const test_fixture = @import("fixture.zig");
|
||||
const io = @import("io.zig");
|
||||
const sgr = @import("../sgr.zig");
|
||||
const terminal_style = @import("../style.zig");
|
||||
|
||||
/// Number of bytes written by `encode`, calculated using the encoder itself
|
||||
/// so this remains synchronized with the field-by-field wire format.
|
||||
pub const len = computeLen();
|
||||
|
||||
comptime {
|
||||
// This size is part of the wire format. If it changes, the snapshot
|
||||
// version and golden fixtures must also change.
|
||||
std.debug.assert(len == 16);
|
||||
}
|
||||
/// Number of bytes in one encoded style entry. This size is part of the
|
||||
/// wire format: the codec reads and writes fixed offsets within an entry
|
||||
/// of exactly this size, so if the layout changes, the snapshot version
|
||||
/// and golden fixtures must also change.
|
||||
pub const len = 16;
|
||||
|
||||
const Flags = packed struct(u16) {
|
||||
bold: bool = false,
|
||||
@@ -80,8 +75,8 @@ const ColorKind = enum(u8) {
|
||||
rgb = 2,
|
||||
};
|
||||
|
||||
/// Errors possible while decoding one style entry.
|
||||
pub const DecodeError = std.Io.Reader.Error || error{
|
||||
/// Semantic validation errors for one complete style entry buffer.
|
||||
const ParseError = error{
|
||||
/// A color kind is not defined by snapshot version 1.
|
||||
InvalidColorKind,
|
||||
|
||||
@@ -98,14 +93,21 @@ pub const DecodeError = std.Io.Reader.Error || error{
|
||||
InvalidReserved,
|
||||
};
|
||||
|
||||
/// Errors possible while decoding one style entry.
|
||||
pub const DecodeError = std.Io.Reader.Error || ParseError;
|
||||
|
||||
/// Encode one terminal style as a fixed-size snapshot style entry.
|
||||
///
|
||||
/// The entry is assembled in a fixed buffer and written once, so hot
|
||||
/// encoders perform a single writer call per style.
|
||||
pub fn encode(
|
||||
value: terminal_style.Style,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
try encodeColor(value.fg_color, writer);
|
||||
try encodeColor(value.bg_color, writer);
|
||||
try encodeColor(value.underline_color, writer);
|
||||
var encoded: [len]u8 = @splat(0);
|
||||
encodeColorBuf(encoded[0..4], value.fg_color);
|
||||
encodeColorBuf(encoded[4..8], value.bg_color);
|
||||
encodeColorBuf(encoded[8..12], value.underline_color);
|
||||
|
||||
const flags: Flags = .{
|
||||
.bold = value.flags.bold,
|
||||
@@ -118,17 +120,26 @@ pub fn encode(
|
||||
.overline = value.flags.overline,
|
||||
.underline = @intFromEnum(value.flags.underline),
|
||||
};
|
||||
try io.writeInt(writer, u16, @bitCast(flags));
|
||||
try io.writeInt(writer, u16, 0);
|
||||
std.mem.writeInt(u16, encoded[12..14], @bitCast(flags), .little);
|
||||
try writer.writeAll(&encoded);
|
||||
}
|
||||
|
||||
/// Decode and validate one fixed-size snapshot style entry.
|
||||
pub fn decode(reader: *std.Io.Reader) DecodeError!terminal_style.Style {
|
||||
const fg_color = try decodeColor(reader);
|
||||
const bg_color = try decodeColor(reader);
|
||||
const underline_color = try decodeColor(reader);
|
||||
var encoded: [len]u8 = undefined;
|
||||
try reader.readSliceAll(&encoded);
|
||||
return parse(&encoded);
|
||||
}
|
||||
|
||||
const flags: Flags = @bitCast(try io.readInt(reader, u16));
|
||||
/// Decode and validate one complete fixed-size style entry buffer.
|
||||
fn parse(encoded: *const [len]u8) ParseError!terminal_style.Style {
|
||||
const fg_color = try parseColor(encoded[0..4]);
|
||||
const bg_color = try parseColor(encoded[4..8]);
|
||||
const underline_color = try parseColor(encoded[8..12]);
|
||||
|
||||
const flags: Flags = @bitCast(
|
||||
std.mem.readInt(u16, encoded[12..14], .little),
|
||||
);
|
||||
if (flags.reserved != 0) return error.InvalidFlags;
|
||||
|
||||
const underline = std.enums.fromInt(
|
||||
@@ -136,7 +147,7 @@ pub fn decode(reader: *std.Io.Reader) DecodeError!terminal_style.Style {
|
||||
flags.underline,
|
||||
) orelse return error.InvalidUnderline;
|
||||
|
||||
const reserved = try io.readInt(reader, u16);
|
||||
const reserved = std.mem.readInt(u16, encoded[14..16], .little);
|
||||
if (reserved != 0) return error.InvalidReserved;
|
||||
|
||||
return .{
|
||||
@@ -167,9 +178,7 @@ pub fn decodeOrDiscard(
|
||||
) DecodeError!terminal_style.Style {
|
||||
var encoded: [len]u8 = undefined;
|
||||
try reader.readSliceAll(&encoded);
|
||||
|
||||
var source: std.Io.Reader = .fixed(&encoded);
|
||||
return decode(&source);
|
||||
return parse(&encoded);
|
||||
}
|
||||
|
||||
/// Decode one complete entry, returning null for invalid semantic contents.
|
||||
@@ -193,11 +202,16 @@ pub fn decodeOrNull(
|
||||
};
|
||||
}
|
||||
|
||||
fn encodeColor(
|
||||
/// `decodeOrNull` over one already-buffered entry, for enclosing codecs
|
||||
/// that parse many entries from a flat payload without reader calls.
|
||||
pub fn parseOrNull(encoded: *const [len]u8) ?terminal_style.Style {
|
||||
return parse(encoded) catch null;
|
||||
}
|
||||
|
||||
fn encodeColorBuf(
|
||||
encoded: *[4]u8,
|
||||
value: terminal_style.Style.Color,
|
||||
writer: *std.Io.Writer,
|
||||
) std.Io.Writer.Error!void {
|
||||
var encoded: [4]u8 = @splat(0);
|
||||
) void {
|
||||
switch (value) {
|
||||
.none => encoded[0] = @intFromEnum(ColorKind.none),
|
||||
.palette => |index| {
|
||||
@@ -211,23 +225,18 @@ fn encodeColor(
|
||||
encoded[3] = rgb.b;
|
||||
},
|
||||
}
|
||||
try writer.writeAll(&encoded);
|
||||
}
|
||||
|
||||
fn decodeColor(
|
||||
reader: *std.Io.Reader,
|
||||
) DecodeError!terminal_style.Style.Color {
|
||||
// Colors are always 4 bytes
|
||||
var encoded: [4]u8 = undefined;
|
||||
try reader.readSliceAll(&encoded);
|
||||
|
||||
fn parseColor(
|
||||
encoded: *const [4]u8,
|
||||
) ParseError!terminal_style.Style.Color {
|
||||
// Kind must be something we know about.
|
||||
const kind = std.enums.fromInt(ColorKind, encoded[0]) orelse {
|
||||
return error.InvalidColorKind;
|
||||
};
|
||||
|
||||
return switch (kind) {
|
||||
.none => if (std.mem.eql(u8, encoded[1..], &.{ 0, 0, 0 }))
|
||||
.none => if (encoded[1] == 0 and encoded[2] == 0 and encoded[3] == 0)
|
||||
.none
|
||||
else
|
||||
error.InvalidColor,
|
||||
@@ -243,15 +252,6 @@ fn decodeColor(
|
||||
};
|
||||
}
|
||||
|
||||
fn computeLen() usize {
|
||||
comptime {
|
||||
var buf: [128]u8 = undefined;
|
||||
var writer: std.Io.Writer = .fixed(&buf);
|
||||
encode(.{}, &writer) catch unreachable;
|
||||
return writer.end;
|
||||
}
|
||||
}
|
||||
|
||||
const test_golden_fixture = test_fixture.parse(@embedFile("testdata/style-v1.hex"));
|
||||
|
||||
test "golden encoding and decoding" {
|
||||
|
||||
Reference in New Issue
Block a user