libghostty: faster render state updates and C API reads (#13818)

This improves the performance of render state plus C API reads. I
specifically benchmarked the C API call and found a lot of overhead in
the C API layer which this cleans up. The impact of these changes will
be less visible to Zig consumers but moderately improve there.

All benchmark numbers below are via the C API.

Highlights: 

- full rebuilds are **1.71x faster (11.4µs to 6.6µs per 120x80 frame)**
- single-dirty-row updates (e.g. the TUI/prompt steady state) are
**1.44x faster**
- full-frame reads through the C API are **1.2x to 1.8x faster** 

## Changes

* endUpdate skips unchanged style runs. 
* `GRAPHEMES_UTF8` getter gets a fast path for single ASCII codepoints
(the overwhelming majority of cells).
* The bg/fg color getters no longer copy the full 28-byte style.
Instead, they switch directly on the one color field they need.
* The `get_multi` variants validate the handle and position once per
batch instead of per key.
* Iterator positions are sentinel values instead of Zig optionals. The
optional tagging overhead was showing up in benchmarks.
* `colors_get` reads through a pointer instead of copying the ~1KB
colors struct to the stack per call.
* The palette conversion is vectorized. The 4-byte padded RGB to 3-byte
was not being auto-vectorized. Explicitly vectorize it. Something like a
4x speedup on NEON.

## Benchmarks

| Benchmark | Before | After | Speedup |
|---|---|---|---|
| update (forced full rebuild) | 11.4 µs/frame | 6.6 µs/frame | 1.71x | 
| update (single dirty row) | 143 ns | 99 ns | 1.44x | 
| read cell style/bg/fg/selected | 10.3 ns/cell | 8.8 ns/cell | 1.17x | 
| read cell via get_multi | 9.6 ns/cell | 6.9 ns/cell | 1.40x | 
|read cell UTF-8 text | 4.9 ns/cell | 2.7 ns/cell | 1.78x | 
| colors_get + palette | 213 ns/call | 45 ns/call | 4.58x |

Clean updates (no terminal changes) and the raw cell read paths are
unchanged.

**AI usage:** Driven by Fable primarily, reviewed everything and rewrote
all human-language (comments) since Fable in particular does really bad
at that. This commit message too.
This commit is contained in:
Mitchell Hashimoto
2026-08-14 09:10:52 -07:00
committed by GitHub
3 changed files with 439 additions and 96 deletions

View File

@@ -22,11 +22,18 @@ const RenderStateWrapper = struct {
state: renderpkg.RenderState = .empty,
};
/// The "before the first element" position for the iterator wrappers
/// below. Represented as a sentinel index rather than an optional because
/// optional codegens into something that is less efficient than this.
const position_none = std.math.maxInt(usize);
const RowIteratorWrapper = struct {
alloc: std.mem.Allocator,
/// The current index (also y value) into the row list.
y: ?size.CellCountInt,
/// The current index (also y value) into the row list, or
/// `position_none` if iteration hasn't started. Always validate
/// against `raws.len` before use.
y: usize,
/// These are the raw pointers into the render state data.
raws: []const page.Row,
@@ -41,7 +48,12 @@ const RowIteratorWrapper = struct {
const RowCellsWrapper = struct {
alloc: std.mem.Allocator,
x: ?size.CellCountInt,
/// The current index (also x value) into the cell list, or
/// `position_none` if iteration hasn't started. Always validate
/// against `raws.len` before use.
x: usize,
raws: []const page.Cell,
graphemes: []const []const u21,
styles: []const Style,
@@ -212,21 +224,8 @@ pub fn get(
data: Data,
out: ?*anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(Data, @intFromEnum(data)) orelse {
log.warn("render_state_get invalid data value={d}", .{@intFromEnum(data)});
return .invalid_value;
};
}
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| getTyped(
state_,
comptime_data,
@ptrCast(@alignCast(out)),
),
};
const state = state_ orelse return .invalid_value;
return getDispatch(state, data, out);
}
pub fn get_multi(
@@ -239,8 +238,16 @@ pub fn get_multi(
const k = keys orelse return .invalid_value;
const v = values orelse return .invalid_value;
// Unwrap the handle once for the whole batch rather than per key.
// A null handle fails on the first key, matching the per-call
// behavior.
const state: ?*RenderStateWrapper = state_;
for (0..count) |i| {
const result = get(state_, k[i], v[i]);
const result = if (state) |s|
getDispatch(s, k[i], v[i])
else
Result.invalid_value;
if (result != .success) {
if (out_written) |w| w.* = i;
return result;
@@ -250,12 +257,33 @@ pub fn get_multi(
return .success;
}
inline fn getDispatch(
state: *RenderStateWrapper,
data: Data,
out: ?*anyopaque,
) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(Data, @intFromEnum(data)) orelse {
log.warn("render_state_get invalid data value={d}", .{@intFromEnum(data)});
return .invalid_value;
};
}
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| getTyped(
state,
comptime_data,
@ptrCast(@alignCast(out)),
),
};
}
fn getTyped(
state_: RenderState,
state: *RenderStateWrapper,
comptime data: Data,
out: *data.OutType(),
) Result {
const state = state_ orelse return .invalid_value;
switch (data) {
.invalid => return .invalid_value,
.cols => out.* = state.state.cols,
@@ -266,7 +294,7 @@ fn getTyped(
const row_data = state.state.row_data.slice();
it.* = .{
.alloc = it.alloc,
.y = null,
.y = position_none,
.raws = row_data.items(.raw),
.cells = row_data.items(.cells),
.selection = row_data.items(.selection),
@@ -347,7 +375,7 @@ pub fn colors_get(
const out_size = out_colors.size;
if (out_size < @sizeOf(usize)) return .invalid_value;
const colors = state.state.colors;
const colors = &state.state.colors;
if (lib.structSizedFieldFits(
Colors,
out_size,
@@ -387,9 +415,10 @@ pub fn colors_get(
if (out_size > palette_offset) {
const available = out_size - palette_offset;
const max_entries = @min(colors.palette.len, available / @sizeOf(colorpkg.RGB.C));
for (0..max_entries) |i| {
out_colors.palette[i] = colors.palette[i].cval();
}
colorpkg.paletteCvalSlice(
colors.palette[0..max_entries],
out_colors.palette[0..max_entries],
);
}
}
@@ -426,7 +455,8 @@ pub fn row_iterator_free(iterator_: RowIterator) callconv(lib.calling_conv) void
pub fn row_iterator_next(iterator_: RowIterator) callconv(lib.calling_conv) bool {
const it = iterator_ orelse return false;
const next_y: size.CellCountInt = if (it.y) |y| y + 1 else 0;
// The none sentinel wraps to zero.
const next_y = it.y +% 1;
if (next_y >= it.raws.len) return false;
it.y = next_y;
return true;
@@ -456,7 +486,8 @@ pub fn row_cells_new(
pub fn row_cells_next(cells_: RowCells) callconv(lib.calling_conv) bool {
const cells = cells_ orelse return false;
const next_x: size.CellCountInt = if (cells.x) |x| x + 1 else 0;
// The none sentinel wraps to zero.
const next_x = cells.x +% 1;
if (next_x >= cells.raws.len) return false;
cells.x = next_x;
return true;
@@ -508,22 +539,10 @@ pub fn row_cells_get(
data: RowCellsData,
out: ?*anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(RowCellsData, @intFromEnum(data)) orelse {
log.warn("render_state_row_cells_get invalid data value={d}", .{@intFromEnum(data)});
return .invalid_value;
};
}
if (out == null) return .invalid_value;
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| rowCellsGetTyped(
cells_,
comptime_data,
@ptrCast(@alignCast(out)),
),
};
const cells = cells_ orelse return .invalid_value;
const x = cells.x;
if (x >= cells.raws.len) return .invalid_value;
return rowCellsGetDispatch(cells, x, data, out);
}
pub fn row_cells_get_multi(
@@ -536,8 +555,21 @@ pub fn row_cells_get_multi(
const k = keys orelse return .invalid_value;
const v = values orelse return .invalid_value;
// Unwrap the handle and position once for the whole batch rather
// than per key. An invalid handle/position fails on the first
// key, matching the per-call behavior.
const unwrapped: ?struct { *RowCellsWrapper, usize } = valid: {
const cells = cells_ orelse break :valid null;
const x = cells.x;
if (x >= cells.raws.len) break :valid null;
break :valid .{ cells, x };
};
for (0..count) |i| {
const result = row_cells_get(cells_, k[i], v[i]);
const result = if (unwrapped) |u|
rowCellsGetDispatch(u[0], u[1], k[i], v[i])
else
Result.invalid_value;
if (result != .success) {
if (out_written) |w| w.* = i;
return result;
@@ -547,13 +579,37 @@ pub fn row_cells_get_multi(
return .success;
}
fn rowCellsGetTyped(
cells_: RowCells,
inline fn rowCellsGetDispatch(
cells: *const RowCellsWrapper,
x: usize,
data: RowCellsData,
out: ?*anyopaque,
) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(RowCellsData, @intFromEnum(data)) orelse {
log.warn("render_state_row_cells_get invalid data value={d}", .{@intFromEnum(data)});
return .invalid_value;
};
}
if (out == null) return .invalid_value;
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| rowCellsGetTypedInner(
cells,
x,
comptime_data,
@ptrCast(@alignCast(out)),
),
};
}
fn rowCellsGetTypedInner(
cells: *const RowCellsWrapper,
x: usize,
comptime data: RowCellsData,
out: *data.OutType(),
) Result {
const cells = cells_ orelse return .invalid_value;
const x = cells.x orelse return .invalid_value;
const cell = cells.raws[x];
switch (data) {
.invalid => return .invalid_value,
@@ -561,7 +617,7 @@ fn rowCellsGetTyped(
.style => out.* = if (cell.hasStyling())
style_c.Style.fromStyle(cells.styles[x])
else
style_c.Style.fromStyle(.{}),
comptime style_c.Style.fromStyle(.{}),
.graphemes_len => {
if (!cell.hasText()) {
out.* = 0;
@@ -580,15 +636,37 @@ fn rowCellsGetTyped(
}
},
.bg_color => {
const s: Style = if (cell.hasStyling()) cells.styles[x] else .{};
const bg = s.bg(&cell, cells.palette) orelse return .invalid_value;
out.* = bg.cval();
// Avoid copying the full struct when only partial changes happen.
switch (cell.content_tag) {
.bg_color_palette => {
out.* = cells.palette[cell.content.color_palette.data].cval();
},
.bg_color_rgb => {
const rgb = cell.content.color_rgb;
out.* = .{ .r = rgb.r, .g = rgb.g, .b = rgb.b };
},
.codepoint,
.codepoint_grapheme,
=> {
// The default style has no background.
if (!cell.hasStyling()) return .invalid_value;
switch (cells.styles[x].bg_color) {
.none => return .invalid_value,
.palette => |idx| out.* = cells.palette[idx].cval(),
.rgb => |rgb| out.* = rgb.cval(),
}
},
}
},
.fg_color => {
const s: Style = if (cell.hasStyling()) cells.styles[x] else .{};
if (s.fg_color == .none) return .invalid_value;
const fg = s.fg(.{ .default = .{}, .palette = cells.palette });
out.* = fg.cval();
if (!cell.hasStyling()) return .invalid_value;
switch (cells.styles[x].fg_color) {
.none => return .invalid_value,
.palette => |idx| out.* = cells.palette[idx].cval(),
.rgb => |rgb| out.* = rgb.cval(),
}
},
.selected => out.* = if (cells.selection) |sel|
x >= sel[0] and x <= sel[1]
@@ -606,11 +684,32 @@ fn rowCellsGetGraphemesUtf8(
extra: []const u21,
out: *lib.Buffer,
) Result {
if (!cell.hasText()) {
out.len = 0;
return .success;
}
const first = cell.codepoint();
// Fast path: a single ASCII codepoint written to an adequately
// sized buffer. This is the overwhelmingly common case for
// terminal content.
if (first < 0x80 and extra.len == 0) {
@branchHint(.likely);
if (out.ptr) |ptr| {
if (out.cap >= 1) {
ptr[0] = @intCast(first);
out.len = 1;
return .success;
}
}
out.len = 1;
return .out_of_space;
}
out.len = 0;
if (!cell.hasText()) return .success;
var needed: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch
var needed: usize = std.unicode.utf8CodepointSequenceLength(first) catch
return .invalid_value;
for (extra) |cp| {
needed += std.unicode.utf8CodepointSequenceLength(cp) catch
@@ -622,7 +721,7 @@ fn rowCellsGetGraphemesUtf8(
const buf = out.ptr.?[0..out.cap];
var i: usize = 0;
i += std.unicode.utf8Encode(cell.codepoint(), buf[i..]) catch
i += std.unicode.utf8Encode(first, buf[i..]) catch
return .invalid_value;
for (extra) |cp| {
i += std.unicode.utf8Encode(cp, buf[i..]) catch
@@ -670,21 +769,10 @@ pub fn row_get(
data: RowData,
out: ?*anyopaque,
) callconv(lib.calling_conv) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(RowData, @intFromEnum(data)) orelse {
log.warn("render_state_row_get invalid data value={d}", .{@intFromEnum(data)});
return .invalid_value;
};
}
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| rowGetTyped(
iterator_,
comptime_data,
@ptrCast(@alignCast(out)),
),
};
const it = iterator_ orelse return .invalid_value;
const y = it.y;
if (y >= it.raws.len) return .invalid_value;
return rowGetDispatch(it, y, data, out);
}
pub fn row_get_multi(
@@ -697,8 +785,21 @@ pub fn row_get_multi(
const k = keys orelse return .invalid_value;
const v = values orelse return .invalid_value;
// Unwrap the handle and position once for the whole batch rather
// than per key. An invalid handle/position fails on the first
// key, matching the per-call behavior.
const unwrapped: ?struct { *RowIteratorWrapper, usize } = valid: {
const it = iterator_ orelse break :valid null;
const y = it.y;
if (y >= it.raws.len) break :valid null;
break :valid .{ it, y };
};
for (0..count) |i| {
const result = row_get(iterator_, k[i], v[i]);
const result = if (unwrapped) |u|
rowGetDispatch(u[0], u[1], k[i], v[i])
else
Result.invalid_value;
if (result != .success) {
if (out_written) |w| w.* = i;
return result;
@@ -708,13 +809,36 @@ pub fn row_get_multi(
return .success;
}
inline fn rowGetDispatch(
it: *RowIteratorWrapper,
y: usize,
data: RowData,
out: ?*anyopaque,
) Result {
if (comptime std.debug.runtime_safety) {
_ = std.enums.fromInt(RowData, @intFromEnum(data)) orelse {
log.warn("render_state_row_get invalid data value={d}", .{@intFromEnum(data)});
return .invalid_value;
};
}
return switch (data) {
.invalid => .invalid_value,
inline else => |comptime_data| rowGetTyped(
it,
y,
comptime_data,
@ptrCast(@alignCast(out)),
),
};
}
fn rowGetTyped(
iterator_: RowIterator,
it: *RowIteratorWrapper,
y: usize,
comptime data: RowData,
out: *data.OutType(),
) Result {
const it = iterator_ orelse return .invalid_value;
const y = it.y orelse return .invalid_value;
switch (data) {
.invalid => return .invalid_value,
.dirty => out.* = it.dirty[y],
@@ -724,7 +848,7 @@ fn rowGetTyped(
const cell_data = it.cells[y].slice();
cells.* = .{
.alloc = cells.alloc,
.x = null,
.x = position_none,
.raws = cell_data.items(.raw),
.graphemes = cell_data.items(.grapheme),
.styles = cell_data.items(.style),
@@ -772,7 +896,8 @@ fn rowSetTyped(
value: *const option.InType(),
) Result {
const it = iterator_ orelse return .invalid_value;
const y = it.y orelse return .invalid_value;
const y = it.y;
if (y >= it.raws.len) return .invalid_value;
switch (option) {
.dirty => it.dirty[y] = value.*,
}
@@ -977,7 +1102,7 @@ test "render: row iterator new/free" {
const iterator_ptr = iterator.?;
const row_data = state.?.state.row_data.slice();
try testing.expectEqual(@as(?size.CellCountInt, null), iterator_ptr.y);
try testing.expectEqual(position_none, iterator_ptr.y);
try testing.expectEqual(row_data.items(.raw).len, iterator_ptr.raws.len);
try testing.expectEqual(row_data.items(.cells).len, iterator_ptr.cells.len);
try testing.expectEqual(row_data.items(.selection).len, iterator_ptr.selection.len);
@@ -1498,16 +1623,16 @@ test "render: row iterator next" {
}
try testing.expect(row_iterator_next(iterator));
try testing.expectEqual(@as(?size.CellCountInt, 0), iterator.?.y);
try testing.expectEqual(@as(usize, 0), iterator.?.y);
var i: size.CellCountInt = 1;
while (i < rows) : (i += 1) {
try testing.expect(row_iterator_next(iterator));
try testing.expectEqual(@as(?size.CellCountInt, i), iterator.?.y);
try testing.expectEqual(i, iterator.?.y);
}
try testing.expect(!row_iterator_next(iterator));
try testing.expectEqual(@as(?size.CellCountInt, rows - 1), iterator.?.y);
try testing.expectEqual(@as(usize, rows - 1), iterator.?.y);
}
test "render: update" {

View File

@@ -1,6 +1,7 @@
const colorpkg = @This();
const std = @import("std");
const builtin = @import("builtin");
const assert = @import("../quirks.zig").inlineAssert;
const x11_color = @import("x11_color.zig");
@@ -128,10 +129,48 @@ pub const PaletteC = [256]RGB.C;
/// Convert a Palette to a PaletteC.
pub fn paletteCval(palette: *const Palette) PaletteC {
var result: PaletteC = undefined;
for (&result, palette) |*dst, src| dst.* = src.cval();
paletteCvalSlice(palette[0..], result[0..]);
return result;
}
/// Convert a slice of palette entries to their C representation.
/// Asserts that both slices are the same length.
pub fn paletteCvalSlice(src: []const RGB, dst: []RGB.C) void {
assert(src.len == dst.len);
var i: usize = 0;
// For CPUs that are LE, we can do some clever byte shuffling
// with vectorization to do a 4-to-3 conversion.
if (comptime builtin.cpu.arch.endian() == .little) {
// Process 4 entries at a time: one 16-byte load, one byte
// shuffle dropping the padding byte of each entry, and one
// 16-byte store. The store intentionally overlaps the next
// group by 4 (garbage) bytes so that it stays a single
// vector store; the loop bound guarantees the overlap stays
// in bounds and every overlapped byte is rewritten by the
// next iteration or the scalar tail.
//
// Note the input load is a memory-level reinterpretation
// (pointer cast) because a value-level @bitCast of packed
// structs operates on the 24-bit value bits, not the 4-byte
// in-memory representation.
const dst_bytes: [*]u8 = @ptrCast(dst.ptr);
while (i + 6 <= src.len) : (i += 4) {
const in: @Vector(16, u8) = @as(
*const [16]u8,
@ptrCast(src[i..][0..4]),
).*;
const out: [16]u8 = @shuffle(u8, in, undefined, [16]i32{
0, 1, 2, 4, 5, 6, 8, 9, 10, 12, 13, 14, 3, 7, 11, 15,
});
dst_bytes[i * 3 ..][0..16].* = out;
}
}
for (src[i..], dst[i..]) |entry, *out| out.* = entry.cval();
}
/// Convert a PaletteC to a Palette.
pub fn paletteZval(palette: *const PaletteC) Palette {
var result: Palette = undefined;
@@ -1276,3 +1315,35 @@ test "LAB.toRgb" {
try testing.expectEqual(expected.b, actual.b);
}
}
test paletteCvalSlice {
const testing = std.testing;
// Every length from empty through a full palette, so that the
// vectorized groups, the overlapping store bound, and the scalar
// tail are all exercised at their edges.
var src: Palette = undefined;
for (&src, 0..) |*rgb, i| rgb.* = .{
.r = @intCast(i % 256),
.g = @intCast((i * 7 + 1) % 256),
.b = @intCast((i * 13 + 2) % 256),
};
var dst: PaletteC = undefined;
for (0..src.len + 1) |len| {
// Poison the destination so unwritten bytes are detected.
@memset(std.mem.asBytes(&dst), 0xAA);
paletteCvalSlice(src[0..len], dst[0..len]);
for (src[0..len], dst[0..len]) |rgb, c| {
try testing.expectEqual(rgb.r, c.r);
try testing.expectEqual(rgb.g, c.g);
try testing.expectEqual(rgb.b, c.b);
}
// Nothing beyond the requested length may be written.
for (std.mem.asBytes(&dst)[len * 3 ..]) |b| {
try testing.expectEqual(@as(u8, 0xAA), b);
}
}
}

View File

@@ -235,6 +235,21 @@ pub const RenderState = struct {
/// The highlights within this row.
highlights: std.ArrayList(Highlight),
/// The style runs applied to this row's per-cell style data
/// by the last `endUpdate` that touched it. This is what lets
/// `endUpdate` skip the (comparatively large) per-cell style
/// fill when a rebuilt row produced identical runs, which is
/// the common case: text changes far more often than styling.
///
/// The invariant is that this always describes the current
/// contents of the cell style data for the covered ranges. It
/// is cleared whenever the cell storage is reallocated.
///
/// This uses the general allocator (NOT the row arena)
/// because it must survive row rebuilds. `endUpdate` cannot
/// allocate, so `beginUpdate` reserves the capacity.
applied_styles: std.ArrayList(StyleRun),
};
pub const Highlight = struct {
@@ -304,10 +319,12 @@ pub const RenderState = struct {
for (
self.row_data.items(.arena),
self.row_data.items(.cells),
) |state, *cells| {
self.row_data.items(.applied_styles),
) |state, *cells, *applied| {
var arena: ArenaAllocator = state.promote(alloc);
arena.deinit();
cells.deinit(alloc);
applied.deinit(alloc);
}
self.row_data.deinit(alloc);
self.pending_styles.deinit(alloc);
@@ -464,6 +481,7 @@ pub const RenderState = struct {
.dirty = true,
.selection = null,
.highlights = .empty,
.applied_styles = .empty,
});
}
} else {
@@ -471,10 +489,12 @@ pub const RenderState = struct {
for (
row_data.items(.arena)[self.rows..],
row_data.items(.cells)[self.rows..],
) |state, *cell| {
row_data.items(.applied_styles)[self.rows..],
) |state, *cell, *applied| {
var arena: ArenaAllocator = state.promote(alloc);
arena.deinit();
cell.deinit(alloc);
applied.deinit(alloc);
}
self.row_data.shrinkRetainingCapacity(self.rows);
}
@@ -490,6 +510,7 @@ pub const RenderState = struct {
const row_sels = row_data.items(.selection);
const row_highlights = row_data.items(.highlights);
const row_dirties = row_data.items(.dirty);
const row_applied = row_data.items(.applied_styles);
// If we're redrawing then every row will be rebuilt, superseding
// any pending style runs from prior updates. Clearing also
@@ -512,6 +533,7 @@ pub const RenderState = struct {
.highlights = row_highlights,
.dirties = row_dirties,
.pending_styles = &self.pending_styles,
.applied_styles = row_applied,
};
var y: usize = 0;
var any_dirty: bool = false;
@@ -735,22 +757,73 @@ pub const RenderState = struct {
const row_data = self.row_data.slice();
const row_cells = row_data.items(.cells);
for (self.pending_styles.items) |run| {
const row_applied = row_data.items(.applied_styles);
// Process the pending runs one row segment at a time. All the
// runs for a row are appended contiguously by a single
// beginUpdate, so a segment boundary is simply a change in y.
const runs = self.pending_styles.items;
var i: usize = 0;
while (i < runs.len) {
const y = runs[i].y;
var j = i + 1;
while (j < runs.len and runs[j].y == y) j += 1;
const segment = runs[i..j];
i = j;
// Defensive: the row data may have changed shape if the
// caller violated ordering (e.g. an error path skipped an
// endUpdate between updates). Any update that changes
// dimensions clears the pending list (redraw), so this
// should never actually trigger, but the cost is trivial.
if (run.y >= row_cells.len) continue;
const styles = row_cells[run.y].slice().items(.style);
const end = @min(run.end, styles.len);
const start = @min(run.start, end);
if (y >= row_cells.len) continue;
@memset(styles[start..end], run.style);
// If the segment matches the runs already denormalized
// into this row's cell data then the per-cell styles are
// already correct and the (comparatively large) fill can
// be skipped entirely. This is the common case: rebuilt
// rows usually keep their styling (only the text
// changed), and full redraws of an unchanged screen keep
// both.
const applied = &row_applied[y];
if (runsEql(applied.items, segment)) continue;
for (segment) |run| {
const styles = row_cells[run.y].slice().items(.style);
const end = @min(run.end, styles.len);
const start = @min(run.start, end);
@memset(styles[start..end], run.style);
}
// Record what we applied so the next rebuild of this row
// can skip the fill. beginUpdate reserved the capacity
// (we cannot allocate here); if it doesn't fit (e.g.
// segments merged across multiple begins without an end)
// leave the cache empty, which never matches and simply
// means the next rebuild applies its runs.
applied.clearRetainingCapacity();
if (applied.capacity >= segment.len) {
applied.appendSliceAssumeCapacity(segment);
}
}
self.pending_styles.clearRetainingCapacity();
}
/// Returns true if the two style run lists denormalize to
/// identical per-cell style data. This is a semantic comparison
/// (styles are compared field-wise, never by bytes, since padding
/// is undefined).
fn runsEql(a: []const StyleRun, b: []const StyleRun) bool {
if (a.len != b.len) return false;
for (a, b) |ar, br| {
if (ar.start != br.start or
ar.end != br.end or
!ar.style.eql(br.style)) return false;
}
return true;
}
/// Update the highlights in the render state from the given flattened
/// highlights. Because this uses flattened highlights, it does not require
/// reading from the terminal state so it should be done outside of
@@ -1022,6 +1095,7 @@ const RowBuilder = struct {
highlights: []std.ArrayList(RenderState.Highlight),
dirties: []bool,
pending_styles: *std.ArrayList(RenderState.StyleRun),
applied_styles: []std.ArrayList(RenderState.StyleRun),
fn row(
b: *const RowBuilder,
@@ -1058,7 +1132,14 @@ const RowBuilder = struct {
// Our per-row arena is only used for temporary allocations
// pertaining to cells directly (e.g. graphemes, hyperlinks).
const cells: *std.MultiArrayList(RenderState.Cell) = &b.cells[vy];
if (cells.len != b.cols) try cells.resize(b.alloc, b.cols);
if (cells.len != b.cols) {
// The cell storage (including the per-cell style data) is
// being reallocated, so the applied style cache no longer
// describes it. Clear it before the resize so an error
// can't leave it stale.
b.applied_styles[vy].clearRetainingCapacity();
try cells.resize(b.alloc, b.cols);
}
// We always copy our raw cell data. In the case we have no
// managed memory, we can skip setting any other fields.
@@ -1077,6 +1158,7 @@ const RowBuilder = struct {
const arena_alloc = arena.allocator();
const cells_grapheme = cells_slice.items(.grapheme);
const n = page_cells.len;
const runs_start = b.pending_styles.items.len;
var x: usize = 0;
scan: while (x < n) {
// Skip runs of plain cells a group at a time. Cells that
@@ -1210,6 +1292,15 @@ const RowBuilder = struct {
},
}
}
// Reserve the applied style cache capacity for the runs we
// appended so that endUpdate (which cannot allocate) is able
// to record what it applies. See Row.applied_styles.
const runs_added = b.pending_styles.items.len - runs_start;
if (runs_added > 0) try b.applied_styles[vy].ensureTotalCapacity(
b.alloc,
runs_added,
);
}
};
@@ -1601,6 +1692,62 @@ test "begin and end update" {
}
}
test "endUpdate skips unchanged style runs" {
const testing = std.testing;
const alloc = testing.allocator;
const io = testing.io;
var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 3,
});
defer t.deinit(alloc);
var s = t.vtStream();
defer s.deinit();
s.nextSlice("\x1b[1mAB"); // Bold
var state: RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
// The applied cache should record the bold run for row 0.
{
const row_data = state.row_data.slice();
const applied = row_data.items(.applied_styles);
try testing.expectEqual(1, applied[0].items.len);
try testing.expect(applied[0].items[0].style.flags.bold);
try testing.expect(state.row_data.items(.cells)[0].get(0).style.flags.bold);
}
// Rewrite the text without changing the styling: the row is
// rebuilt, the run matches the cache, and the styles must remain
// correct (the fill is skipped internally).
s.nextSlice("\x1b[1;1H\x1b[1mXY");
try state.update(alloc, &t);
{
const cells = &state.row_data.items(.cells)[0];
try testing.expectEqual('X', cells.get(0).raw.codepoint());
try testing.expect(cells.get(0).style.flags.bold);
try testing.expect(cells.get(1).style.flags.bold);
}
// Change the styling: the cache mismatches and the new styles
// must be applied and recorded.
s.nextSlice("\x1b[1;1H\x1b[0;3mZW"); // Italic
try state.update(alloc, &t);
{
const cells = &state.row_data.items(.cells)[0];
try testing.expectEqual('Z', cells.get(0).raw.codepoint());
try testing.expect(!cells.get(0).style.flags.bold);
try testing.expect(cells.get(0).style.flags.italic);
const applied = state.row_data.items(.applied_styles);
try testing.expectEqual(1, applied[0].items.len);
try testing.expect(applied[0].items[0].style.flags.italic);
}
}
test "bg color cells" {
const testing = std.testing;
const alloc = testing.allocator;