aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/render.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-10 21:51:57 +0300
committerGitHub <noreply@github.com>2026-09-10 21:51:57 +0300
commit425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch)
treee52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/render.zig
parent90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff)
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/render.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/render.zig268
1 files changed, 237 insertions, 31 deletions
diff --git a/vendor/libghostty-vt/src/terminal/render.zig b/vendor/libghostty-vt/src/terminal/render.zig
index 04f0c967..6bcddb2c 100644
--- a/vendor/libghostty-vt/src/terminal/render.zig
+++ b/vendor/libghostty-vt/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,113 @@ 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;
+
+ // 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);
+
+ fillStyles(styles[start..end], run.style);
+ }
- @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();
}
+ /// Mark all render-state data as consumed by the renderer.
+ ///
+ /// This clears both the global dirty state and every per-row dirty flag.
+ /// Callers that only consume part of a frame should clear the two layers
+ /// individually instead.
+ pub fn clean(self: *RenderState) void {
+ self.dirty = .false;
+ @memset(self.row_data.items(.dirty), false);
+ }
+
+ /// Fill a slice of styles with one value.
+ ///
+ /// This is equivalent to `@memset(dst, value)` but manually vectorized:
+ /// `@memset` with a struct value lowers to a per-element field copy
+ /// that reloads the source at every iteration because LLVM cannot
+ /// prove the destination doesn't alias it. And, Zig 0.16 disables
+ /// auto-vectorization due to an LLVM bug.
+ ///
+ /// This complexity is justified by accounting for ~10% of the endUpdate
+ /// times under heavily styled cases.
+ fn fillStyles(dst: []Style, value: Style) void {
+ // Each element is written as two overlapping 16-byte vector
+ // stores held in registers, which requires 16 <= size <= 32.
+ const elem_size = @sizeOf(Style);
+ comptime assert(elem_size >= 16 and elem_size <= 32);
+
+ const V = @Vector(16, u8);
+ const src: *align(@alignOf(Style)) const [elem_size]u8 = @ptrCast(&value);
+ const lo = @as(*align(4) const V, @ptrCast(src[0..16])).*;
+ const hi = @as(*align(1) const V, @ptrCast(src[elem_size - 16 ..][0..16])).*;
+
+ const dst_bytes = std.mem.sliceAsBytes(dst);
+ var off: usize = 0;
+ for (0..dst.len) |_| {
+ @as(*align(1) V, @ptrCast(dst_bytes[off..][0..16])).* = lo;
+ @as(*align(1) V, @ptrCast(dst_bytes[off + elem_size - 16 ..][0..16])).* = hi;
+ off += elem_size;
+ }
+ }
+
+ /// 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 +1135,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 +1172,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 +1198,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
@@ -1180,7 +1302,7 @@ const RowBuilder = struct {
.b = page_cell.content.color_rgb.b,
} } },
.bg_color_palette => .{ .bg_color = .{
- .palette = page_cell.content.color_palette,
+ .palette = page_cell.content.color_palette.data,
} },
else => unreachable,
};
@@ -1210,14 +1332,24 @@ 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,
+ );
}
};
test "styled" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 80,
.rows = 24,
});
@@ -1234,8 +1366,9 @@ test "styled" {
test "basic text" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 3,
});
@@ -1270,8 +1403,9 @@ test "basic text" {
test "styled text" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 3,
});
@@ -1415,15 +1549,16 @@ fn testCompareStates(
test "incremental updates match full rebuild" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
// Deterministic so failures are reproducible.
var prng = std.Random.DefaultPrng.init(0xB0BA_CAFE);
const rand = prng.random();
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 20,
.rows = 8,
- .max_scrollback = 500,
+ .max_scrollback_bytes = 500,
});
defer t.deinit(alloc);
@@ -1551,8 +1686,9 @@ test "incremental updates match full rebuild" {
test "begin and end update" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 3,
});
@@ -1596,11 +1732,68 @@ 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;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 3,
});
@@ -1641,8 +1834,9 @@ test "bg color cells" {
test "grapheme" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 3,
});
@@ -1688,8 +1882,9 @@ test "grapheme" {
test "cursor state in viewport" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 5,
});
@@ -1730,8 +1925,9 @@ test "cursor state in viewport" {
test "cursor state out of viewport" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 2,
});
@@ -1764,8 +1960,9 @@ test "cursor state out of viewport" {
test "dirty state" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 5,
});
@@ -1813,8 +2010,9 @@ test "dirty state" {
test "colors" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 5,
});
@@ -1850,8 +2048,9 @@ test "colors" {
test "selection single line" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t: Terminal = try .init(alloc, .{
+ var t: Terminal = try .init(io, alloc, .{
.cols = 10,
.rows = 3,
});
@@ -1885,8 +2084,9 @@ test "selection single line" {
test "selection multiple lines" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t: Terminal = try .init(alloc, .{
+ var t: Terminal = try .init(io, alloc, .{
.cols = 10,
.rows = 3,
});
@@ -1921,8 +2121,9 @@ test "selection multiple lines" {
test "linkCells" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 5,
});
@@ -1957,8 +2158,9 @@ test "linkCells" {
test "string" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 5,
.rows = 2,
});
@@ -1987,14 +2189,15 @@ test "string" {
test "linkCells with scrollback spanning pages" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
const viewport_rows: size.CellCountInt = 10;
const tail_rows: size.CellCountInt = 5;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = page.std_capacity.cols,
.rows = viewport_rows,
- .max_scrollback = 10_000,
+ .max_scrollback_bytes = 10_000,
});
defer t.deinit(alloc);
@@ -2029,8 +2232,9 @@ test "linkCells with scrollback spanning pages" {
test "linkCells with invalid viewport point" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 5,
});
@@ -2067,8 +2271,9 @@ test "linkCells with invalid viewport point" {
test "flattened highlights require matching page serial" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 3,
});
@@ -2134,8 +2339,9 @@ test "flattened highlights require matching page serial" {
test "dirty row resets highlights" {
const testing = std.testing;
const alloc = testing.allocator;
+ const io = testing.io;
- var t = try Terminal.init(alloc, .{
+ var t = try Terminal.init(io, alloc, .{
.cols = 10,
.rows = 3,
});