mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-24 16:11:43 +00:00
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:
@@ -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" {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user