diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/render.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (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.zig | 268 |
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, }); |
