From e3056658d05bdd54db9fd37a02c196bfe81cfe39 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Fri, 14 Aug 2026 07:55:31 -0700 Subject: [PATCH] libghostty: faster render state updates and C API reads MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- src/terminal/c/render.zig | 297 +++++++++++++++++++++++++++----------- src/terminal/color.zig | 73 +++++++++- src/terminal/render.zig | 165 +++++++++++++++++++-- 3 files changed, 439 insertions(+), 96 deletions(-) diff --git a/src/terminal/c/render.zig b/src/terminal/c/render.zig index e70e7a4b7..60e67e609 100644 --- a/src/terminal/c/render.zig +++ b/src/terminal/c/render.zig @@ -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" { diff --git a/src/terminal/color.zig b/src/terminal/color.zig index c04e19e84..8bf327eac 100644 --- a/src/terminal/color.zig +++ b/src/terminal/color.zig @@ -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); + } + } +} diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 867cd6224..11db57a2a 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -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;