diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/render.zig | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff) | |
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/render.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/render.zig | 1028 |
1 files changed, 877 insertions, 151 deletions
diff --git a/vendor/libghostty-vt/src/terminal/render.zig b/vendor/libghostty-vt/src/terminal/render.zig index 98e14224..04f0c967 100644 --- a/vendor/libghostty-vt/src/terminal/render.zig +++ b/vendor/libghostty-vt/src/terminal/render.zig @@ -41,6 +41,29 @@ const Terminal = @import("Terminal.zig"); /// defer state.deinit(alloc); /// state.update(alloc, &terminal); /// +/// ## Two-Phase Updates +/// +/// For callers that synchronize terminal access (e.g. a renderer thread +/// sharing a lock with an IO thread), the update can be split into two +/// phases to minimize the time the terminal must be held exclusively: +/// `beginUpdate` requires terminal access, while `endUpdate` completes +/// any deferred work using only memory owned by the render state. +/// +/// { +/// mutex.lock(); +/// defer mutex.unlock(); +/// try state.beginUpdate(alloc, &terminal); +/// } +/// +/// // The IO thread is free to modify the terminal while we +/// // complete the update. +/// state.endUpdate(); +/// +/// The render state must be treated as incomplete between the two calls. +/// `update` is a convenience that performs both phases in one call. +/// +/// ## Memory +/// /// Note: the render state retains as much memory as possible between updates /// to prevent future allocations. If a very large frame is rendered once, /// the render state will retain that much memory until deinit. To avoid @@ -91,6 +114,16 @@ pub const RenderState = struct { /// if possible. selection_cache: ?SelectionCache = null, + /// The pending style runs requiring an endUpdate call, in the + /// order they were recorded. If multiple begins happen without an + /// endUpdate call, runs accumulate; rows rebuilt more than once + /// may then have superseded (stale) runs in this list, which is + /// harmless: newer runs are appended later so they win, and cells + /// not covered by newer runs have a default style ID in their raw + /// data so their style is undefined by contract anyway. See + /// beginUpdate. + pending_styles: std.ArrayList(StyleRun) = .empty, + /// Initial state. pub const empty: RenderState = .{ .rows = 0, @@ -176,10 +209,16 @@ pub const RenderState = struct { /// change often. arena: ArenaAllocator.State, - /// The page pin. This is not safe to read unless you can guarantee - /// the terminal state hasn't changed since the last `update` call. + /// The page pin. Its copied values may be compared, but its node must + /// not be dereferenced unless the terminal state is protected from + /// changes since the last `update` call. pin: PageList.Pin, + /// The page node generation captured alongside `pin`. This lets + /// consumers validate the pin without dereferencing its node after + /// the terminal lock has been released. + serial: u64, + /// Raw row data. raw: page.Row, @@ -244,6 +283,23 @@ pub const RenderState = struct { br_pin: PageList.Pin, }; + /// A run of cells within one row sharing one style, pending + /// denormalization into the per-cell data. This is populated by + /// `beginUpdate` and consumed by `endUpdate`. This exists so that + /// the (potentially large) denormalization of styles into cells + /// can happen outside of any terminal locks. See `beginUpdate`. + pub const StyleRun = struct { + /// The viewport row. + y: size.CellCountInt, + + /// Start (inclusive) and end (exclusive) x coordinates. + start: size.CellCountInt, + end: size.CellCountInt, + + /// The style for this cell range. + style: Style, + }; + pub fn deinit(self: *RenderState, alloc: Allocator) void { for ( self.row_data.items(.arena), @@ -254,10 +310,17 @@ pub const RenderState = struct { cells.deinit(alloc); } self.row_data.deinit(alloc); + self.pending_styles.deinit(alloc); } /// Update the render state to the latest terminal state. /// + /// This is a convenience function that performs a full update in + /// one call, equivalent to `beginUpdate` immediately followed by + /// `endUpdate`. Callers that hold a lock over the terminal state + /// should prefer calling the two phases directly so that the lock + /// is only held for `beginUpdate`. + /// /// This will reset the terminal dirty state since it is consumed /// by this render state update. pub fn update( @@ -265,6 +328,36 @@ pub const RenderState = struct { alloc: Allocator, t: *Terminal, ) Allocator.Error!void { + try self.beginUpdate(alloc, t); + self.endUpdate(); + } + + /// Begin an update of the render state to the latest terminal + /// state. Every begin must be completed with an `endUpdate` call + /// before the render state is read. + /// + /// This two-phase structure exists for callers that lock the + /// terminal state: only this function requires terminal access, so + /// a caller can hold its lock for this call only and then call + /// `endUpdate` after releasing it. `endUpdate` exclusively reads + /// and writes memory owned by the render state. + /// + /// Work that doesn't require terminal access may be deferred to + /// `endUpdate` to keep this call (and therefore lock hold time) as + /// short as possible. At the time of writing, the deferred work is + /// the per-cell style denormalization, so between this call and + /// `endUpdate` the per-cell `style` data of any updated rows is + /// stale and must not be read. More work may be deferred in the + /// future; callers should treat the render state as incomplete + /// until `endUpdate` is called. + /// + /// This will reset the terminal dirty state since it is consumed + /// by this render state update. + pub fn beginUpdate( + self: *RenderState, + alloc: Allocator, + t: *Terminal, + ) Allocator.Error!void { const s: *Screen = t.screens.active; const viewport_pin = s.pages.getTopLeft(.viewport); const redraw = redraw: { @@ -322,7 +415,14 @@ pub const RenderState = struct { // Colors. self.colors.cursor = t.colors.cursor.get(); - self.colors.palette = t.colors.palette.current; + + // The palette is a relatively large copy (768 bytes at the time + // of writing) so we only copy it when it could have changed. All + // palette modifications set a terminal-level dirty flag (see + // Terminal.Dirty.palette), and any terminal-level dirty flag + // forces a redraw, so checking redraw is sufficient. + if (redraw) self.colors.palette = t.colors.palette.current; + bg_fg: { // Background/foreground can be unset initially which would // depend on "default" background/foreground. The expected use @@ -358,6 +458,7 @@ pub const RenderState = struct { row_data.set(y, .{ .arena = .{}, .pin = undefined, + .serial = undefined, .raw = undefined, .cells = .empty, .dirty = true, @@ -383,33 +484,62 @@ pub const RenderState = struct { const row_data = self.row_data.slice(); const row_arenas = row_data.items(.arena); const row_pins = row_data.items(.pin); + const row_serials = row_data.items(.serial); const row_rows = row_data.items(.raw); const row_cells = row_data.items(.cells); const row_sels = row_data.items(.selection); const row_highlights = row_data.items(.highlights); const row_dirties = row_data.items(.dirty); - // Track the last page that we know was dirty. This lets us - // more quickly do the full-page dirty check. - var last_dirty_page: ?*page.Page = null; - - // Go through and setup our rows. - var row_it = s.pages.rowIterator( - .right_down, - .{ .viewport = .{} }, - null, - ); - var y: size.CellCountInt = 0; + // If we're redrawing then every row will be rebuilt, superseding + // any pending style runs from prior updates. Clearing also + // guarantees pending runs always match the current dimensions + // (dimension changes force a redraw). + if (redraw) self.pending_styles.clearRetainingCapacity(); + + // Go through and setup our rows. We iterate page chunks rather + // than individual rows so that per-page work (dirty flags, cursor + // detection, memory pointers) is hoisted out of the row loop. This + // makes the common case of a clean (or mostly clean) frame very + // cheap: a contiguous scan of row dirty flags. + const builder: RowBuilder = .{ + .alloc = alloc, + .cols = self.cols, + .arenas = row_arenas, + .raws = row_rows, + .cells = row_cells, + .sels = row_sels, + .highlights = row_highlights, + .dirties = row_dirties, + .pending_styles = &self.pending_styles, + }; + var y: usize = 0; var any_dirty: bool = false; - while (row_it.next()) |row_pin| : (y = y + 1) { + var page_it = viewport_pin.pageIterator(.right_down, null); + while (y < self.rows) { + const chunk = page_it.next() orelse break; + const node = chunk.node; + const node_serial = node.serial; + const p: *page.Page = node.page(); + + // The number of rows we consume from this chunk. The chunk + // may extend beyond the viewport (the viewport is always + // exactly `rows` tall) so we clamp. + const take: usize = @min( + @as(usize, chunk.end - chunk.start), + self.rows - y, + ); + // Find our cursor if we haven't found it yet. We do this even - // if the row is not dirty because the cursor is unrelated. + // if rows are not dirty because the cursor is unrelated. We + // can check the chunk bounds once rather than every row. if (self.cursor.viewport == null and - row_pin.node == s.cursor.page_pin.node and - row_pin.y == s.cursor.page_pin.y) - { + node == s.cursor.page_pin.node) + cursor: { + const cy = s.cursor.page_pin.y; + if (cy < chunk.start or cy >= chunk.start + take) break :cursor; self.cursor.viewport = .{ - .y = y, + .y = @intCast(y + (cy - chunk.start)), .x = s.cursor.x, // Future: we should use our own state here to look this @@ -421,135 +551,82 @@ pub const RenderState = struct { }; } - // Store our pin. We have to store these even if we're not dirty - // because dirty is only a renderer optimization. It doesn't - // apply to memory movement. This will let us remap any cell - // pins back to an exact entry in our RenderState. - row_pins[y] = row_pin; - - // Get all our cells in the page. - const p: *page.Page = &row_pin.node.data; - const page_rac = row_pin.rowAndCell(); - - dirty: { - // If we're redrawing then we're definitely dirty. - if (redraw) break :dirty; - - // If our page is the same as last time then its dirty. - if (p == last_dirty_page) break :dirty; - if (p.dirty) { - // If this page is dirty then clear the dirty flag - // of the last page and then store this one. This benchmarks - // faster than iterating pages again later. - if (last_dirty_page) |last_p| last_p.dirty = false; - last_dirty_page = p; - break :dirty; + // The page-level dirty flag applies to every row in the chunk. + // We consume (clear) it now; each node appears at most once in + // this iteration and we're the only consumer of dirty state. + const page_dirty = p.dirty; + if (page_dirty) p.dirty = false; + + // Get our contiguous rows for this chunk. + const page_rows: []page.Row = p.rows.ptr(p.memory)[chunk.start..][0..take]; + assert(p.size.cols == self.cols); + + // Store our pins and their node generations. We have to store + // these even for rows that aren't dirty because dirty is only a + // renderer optimization; it doesn't apply to memory movement. + // This lets us remap any cell pins back to an exact entry in our + // RenderState and validate them later without dereferencing a + // potentially stale node. + // + // We can skip the writes when the pins and serials are unchanged: + // if we're not redrawing, every value was stored by a prior update + // (row count changes force a redraw). Within a single update a + // node appears at most once and its stored pins have consecutive + // y values, so if the first and last entries of this chunk's range + // already match then every entry in between matches too. + if (redraw or + row_pins[y].node != node or + row_pins[y].y != chunk.start or + row_serials[y] != node_serial or + row_pins[y + take - 1].node != node or + row_pins[y + take - 1].y != chunk.start + take - 1 or + row_serials[y + take - 1] != node_serial) + { + for ( + row_pins[y..][0..take], + row_serials[y..][0..take], + chunk.start.., + ) |*pin, *serial, py| { + pin.* = .{ .node = node, .y = @intCast(py) }; + serial.* = node_serial; } - - // If our row is dirty then we're dirty. - if (page_rac.row.dirty) break :dirty; - - // Not dirty! - continue; } - // Set that at least one row was dirty. - any_dirty = true; - - // Clear our row dirty, we'll clear our page dirty later. - // We can't clear it now because we have more rows to go through. - page_rac.row.dirty = false; - - // Promote our arena. State is copied by value so we need to - // restore it on all exit paths so we don't leak memory. - var arena = row_arenas[y].promote(alloc); - defer row_arenas[y] = arena.state; - - // Reset our cells if we're rebuilding this row. - if (row_cells[y].len > 0) { - _ = arena.reset(.retain_capacity); - row_cells[y].clearRetainingCapacity(); - row_sels[y] = null; - row_highlights[y] = .empty; - } - row_dirties[y] = true; - - // Get all our cells in the page. - const page_cells: []const page.Cell = p.getCells(page_rac.row); - assert(page_cells.len == self.cols); - - // Copy our raw row data - row_rows[y] = page_rac.row.*; - - // Note: our cells MultiArrayList uses our general allocator. - // We do this on purpose because as rows become dirty, we do - // not want to reallocate space for cells (which are large). This - // was a source of huge slowdown. - // - // Our per-row arena is only used for temporary allocations - // pertaining to cells directly (e.g. graphemes, hyperlinks). - const cells: *std.MultiArrayList(Cell) = &row_cells[y]; - try cells.resize(alloc, self.cols); - - // We always copy our raw cell data. In the case we have no - // managed memory, we can skip setting any other fields. - // - // This is an important optimization. For plain-text screens - // this ends up being something around 300% faster based on - // the `screen-clone` benchmark. - const cells_slice = cells.slice(); - fastmem.copy( - page.Cell, - cells_slice.items(.raw), - page_cells, - ); - if (!page_rac.row.managedMemory()) continue; - - const arena_alloc = arena.allocator(); - const cells_grapheme = cells_slice.items(.grapheme); - const cells_style = cells_slice.items(.style); - for (page_cells, 0..) |*page_cell, x| { - // Append assuming its a single-codepoint, styled cell - // (most common by far). - if (page_cell.style_id > 0) cells_style[x] = p.styles.get( - p.memory, - page_cell.style_id, - ).*; - - // Switch on our content tag to handle less likely cases. - switch (page_cell.content_tag) { - .codepoint => { + if (!redraw and !page_dirty) { + // Only dirty rows (usually none) need a rebuild. Scan the + // dirty flags a group at a time; the dirty bit is directly + // testable on the packed row representation. + var i: usize = 0; + while (take - i >= RowDirtyMask.group_len) : (i += RowDirtyMask.group_len) { + if (RowDirtyMask.match(page_rows, i)) { @branchHint(.likely); - // Primary codepoint goes into `raw` field. - }, - - // If we have a multi-codepoint grapheme, look it up and - // set our content type. - .codepoint_grapheme => { - @branchHint(.unlikely); - cells_grapheme[x] = try arena_alloc.dupe( - u21, - p.lookupGrapheme(page_cell) orelse &.{}, - ); - }, - - .bg_color_rgb => { - @branchHint(.unlikely); - cells_style[x] = .{ .bg_color = .{ .rgb = .{ - .r = page_cell.content.color_rgb.r, - .g = page_cell.content.color_rgb.g, - .b = page_cell.content.color_rgb.b, - } } }; - }, + continue; + } - .bg_color_palette => { - @branchHint(.unlikely); - cells_style[x] = .{ .bg_color = .{ - .palette = page_cell.content.color_palette, - } }; - }, + for (page_rows[i..][0..RowDirtyMask.group_len], i..) |*page_row, j| { + if (!page_row.dirty) continue; + page_row.dirty = false; + any_dirty = true; + try builder.row(p, page_row, y + j); + } + } + while (i < take) : (i += 1) { + const page_row = &page_rows[i]; + if (!page_row.dirty) continue; + page_row.dirty = false; + any_dirty = true; + try builder.row(p, page_row, y + i); + } + } else { + // Rebuild every row in the chunk. + any_dirty = true; + for (page_rows, 0..) |*page_row, i| { + page_row.dirty = false; + try builder.row(p, page_row, y + i); } } + + y += take; } assert(y == self.rows); @@ -640,14 +717,40 @@ pub const RenderState = struct { self.dirty = .partial; } - // Finalize our final dirty page - if (last_dirty_page) |last_p| last_p.dirty = false; - // Clear our dirty flags t.flags.dirty = .{}; s.dirty = .{}; } + /// Complete a prior `beginUpdate` call by performing any deferred + /// work. At the time of writing, this denormalizes the pending + /// style runs into the per-cell style data. + /// + /// This only reads and writes memory owned by the render state, so + /// it is safe to call while the terminal is being modified (no + /// terminal lock is required). + pub fn endUpdate(self: *RenderState) void { + // Common case: no styled rows were rebuilt. + if (self.pending_styles.items.len == 0) return; + + const row_data = self.row_data.slice(); + const row_cells = row_data.items(.cells); + for (self.pending_styles.items) |run| { + // 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); + + @memset(styles[start..end], run.style); + } + self.pending_styles.clearRetainingCapacity(); + } + /// 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 @@ -678,22 +781,28 @@ pub const RenderState = struct { const row_arenas = row_data.items(.arena); const row_dirties = row_data.items(.dirty); const row_pins = row_data.items(.pin); + const row_serials = row_data.items(.serial); const row_highlights_slice = row_data.items(.highlights); for ( row_arenas, row_pins, + row_serials, row_highlights_slice, row_dirties, - ) |*row_arena, row_pin, *row_highlights, *dirty| { + ) |*row_arena, row_pin, row_serial, *row_highlights, *dirty| { for (hls) |hl| { const chunks_slice = hl.chunks.slice(); const nodes = chunks_slice.items(.node); + const serials = chunks_slice.items(.serial); const starts = chunks_slice.items(.start); const ends = chunks_slice.items(.end); for (0.., nodes) |i, node| { - // If this node doesn't match or we're not within - // the row range, skip it. + // If this node generation doesn't match or we're not + // within the row range, skip it. Both serials are copied + // values, so this never dereferences a node outside the + // terminal lock. if (node != row_pin.node or + serials[i] != row_serial or row_pin.y < starts[i] or row_pin.y >= ends[i]) continue; @@ -825,7 +934,7 @@ pub const RenderState = struct { // Grab our link ID const link_pin: PageList.Pin = row_pins[viewport_point.y]; - const link_page: *page.Page = &link_pin.node.data; + const link_page: *page.Page = link_pin.node.page(); const link = link: { const rac = link_page.getRowAndCell( viewport_point.x, @@ -854,7 +963,7 @@ pub const RenderState = struct { for (0.., cells.items(.raw)) |x, cell| { if (!cell.hyperlink) continue; - const other_page: *page.Page = &pin.node.data; + const other_page: *page.Page = pin.node.page(); const other = link: { const rac = other_page.getRowAndCell(x, pin.y); const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; @@ -879,6 +988,231 @@ pub const RenderState = struct { } }; +/// The number of rows/cells we scan as a single group when looking for +/// dirty rows or special cells. Rows and cells are small packed structs +/// so a group is scanned with a handful of vector operations. +const scan_group_len = 8; + +/// Group scan helper for the row dirty flag. A row that matches has +/// its dirty flag unset. +const RowDirtyMask = page.Mask( + page.Row, + &.{"dirty"}, + scan_group_len, +); + +/// Group scan helper for the cell fields that require managed memory +/// handling. A cell that matches is a plain (possibly zero) codepoint +/// with a default style, requiring no work beyond the raw copy. See +/// RowBuilder.row. +const CellSpecialMask = page.Mask(page.Cell, &.{ + "content_tag", + "style_id", +}, scan_group_len); + +/// Internal helper for RenderState.update that rebuilds a single row of +/// the render state from the current page contents. +const RowBuilder = struct { + alloc: Allocator, + cols: usize, + arenas: []ArenaAllocator.State, + raws: []page.Row, + cells: []std.MultiArrayList(RenderState.Cell), + sels: []?[2]size.CellCountInt, + highlights: []std.ArrayList(RenderState.Highlight), + dirties: []bool, + pending_styles: *std.ArrayList(RenderState.StyleRun), + + fn row( + b: *const RowBuilder, + p: *page.Page, + page_row: *const page.Row, + vy: usize, + ) Allocator.Error!void { + // Promote our arena. State is copied by value so we need to + // restore it on all exit paths so we don't leak memory. + var arena = b.arenas[vy].promote(b.alloc); + defer b.arenas[vy] = arena.state; + + // Reset our per-row state if we're rebuilding this row. A + // non-zero cell length means the row was populated by a prior + // update. + if (b.cells[vy].len > 0) { + _ = arena.reset(.retain_capacity); + b.sels[vy] = null; + b.highlights[vy] = .empty; + } + b.dirties[vy] = true; + + // Get all our cells in the page. + const page_cells: []const page.Cell = page_row.cells.ptr(p.memory)[0..b.cols]; + + // Copy our raw row data + b.raws[vy] = page_row.*; + + // Note: our cells MultiArrayList uses our general allocator. + // We do this on purpose because as rows become dirty, we do + // not want to reallocate space for cells (which are large). This + // was a source of huge slowdown. + // + // 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); + + // We always copy our raw cell data. In the case we have no + // managed memory, we can skip setting any other fields. + // + // This is an important optimization. For plain-text screens + // this ends up being something around 300% faster based on + // the `screen-clone` benchmark. + const cells_slice = cells.slice(); + fastmem.copy( + page.Cell, + cells_slice.items(.raw), + page_cells, + ); + if (!page_row.managedMemory()) return; + + const arena_alloc = arena.allocator(); + const cells_grapheme = cells_slice.items(.grapheme); + const n = page_cells.len; + var x: usize = 0; + scan: while (x < n) { + // Skip runs of plain cells a group at a time. Cells that + // need managed handling are often rare even within rows that + // have managed memory (e.g. a row is "styled" if a single + // cell has a style) so groups are skipped with a few vector + // operations. + while (n - x >= CellSpecialMask.group_len) { + if (!CellSpecialMask.match(page_cells, x)) break; + x += CellSpecialMask.group_len; + } + + // Scalar scan to the next special cell. + while (true) { + if (x >= n) break :scan; + if (!CellSpecialMask.matchScalar(page_cells[x])) break; + x += 1; + } + + const page_cell = &page_cells[x]; + + switch (page_cell.content_tag) { + // Single-codepoint styled cells are by far the most + // common special cells, and they usually come in long + // runs sharing one style ID (e.g. a fully styled row + // usually uses a single style). Find the run and record + // it: this does one style lookup per run and defers the + // (large) per-cell fill to endUpdate, outside of any + // terminal locks. + .codepoint => { + @branchHint(.likely); + const sid = page_cell.style_id; + assert(sid > 0); // special + codepoint implies styled + const style_val: Style = p.styles.get(p.memory, sid).*; + + // A cell continues the run if its masked special + // bits are exactly the style ID of the run (in + // particular the content tag must be a plain + // codepoint). We can check groups of cells at a + // time this way. + const pattern = CellSpecialMask.pattern(page_cell.*); + const start = x; + x += 1; + while (n - x >= CellSpecialMask.group_len) { + if (!CellSpecialMask.eql( + page_cells, + x, + pattern, + )) break; + x += CellSpecialMask.group_len; + } + while (x < n) : (x += 1) { + if (!CellSpecialMask.eqlScalar( + page_cells[x], + pattern, + )) break; + } + + try b.pending_styles.append(b.alloc, .{ + .y = @intCast(vy), + .start = @intCast(start), + .end = @intCast(x), + .style = style_val, + }); + }, + + // If we have a multi-codepoint grapheme, look it up and + // set our content type. Note grapheme cells may also + // be styled. The style must be recorded as a run (rather + // than written directly) so that it is ordered correctly + // relative to possibly-stale runs from prior updates. + .codepoint_grapheme => { + if (page_cell.style_id > 0) { + try b.pending_styles.append(b.alloc, .{ + .y = @intCast(vy), + .start = @intCast(x), + .end = @intCast(x + 1), + .style = p.styles.get( + p.memory, + page_cell.style_id, + ).*, + }); + } + cells_grapheme[x] = try arena_alloc.dupe( + u21, + p.lookupGrapheme(page_cell) orelse &.{}, + ); + x += 1; + }, + + // Background-color-only cells. The style is derived + // entirely from the cell contents. Consecutive cleared + // cells with the same background are bit-identical, so + // we run-detect on full equality (e.g. a line cleared + // with a background color pending is one run). + .bg_color_rgb, .bg_color_palette => { + const style_val: Style = switch (page_cell.content_tag) { + .bg_color_rgb => .{ .bg_color = .{ .rgb = .{ + .r = page_cell.content.color_rgb.r, + .g = page_cell.content.color_rgb.g, + .b = page_cell.content.color_rgb.b, + } } }, + .bg_color_palette => .{ .bg_color = .{ + .palette = page_cell.content.color_palette, + } }, + else => unreachable, + }; + + const first_bits = CellSpecialMask.bits(page_cell.*); + const start = x; + x += 1; + while (n - x >= CellSpecialMask.group_len) { + if (!CellSpecialMask.eqlExact( + page_cells, + x, + first_bits, + )) break; + x += CellSpecialMask.group_len; + } + while (x < n) : (x += 1) { + if (CellSpecialMask.bits(page_cells[x]) != first_bits) + break; + } + + try b.pending_styles.append(b.alloc, .{ + .y = @intCast(vy), + .start = @intCast(start), + .end = @intCast(x), + .style = style_val, + }); + }, + } + } + } +}; + test "styled" { const testing = std.testing; const alloc = testing.allocator; @@ -979,6 +1313,331 @@ test "styled text" { try testing.expectEqual(0, cells[0].get(3).raw.codepoint()); } +/// Verifies that an incrementally updated render state has identical +/// contents to a from-scratch rebuild. This is the load-bearing check +/// for our dirty tracking: if any terminal operation changes row +/// contents without setting a dirty signal that `update` honors +/// (terminal dirty, screen dirty, page dirty, row dirty, viewport pin, +/// or dimensions), the incremental state will contain stale rows and +/// this comparison will fail. +fn testCompareStates( + incremental: *const RenderState, + fresh: *const RenderState, +) !void { + const testing = std.testing; + + // Row metadata that is allowed to be stale in an incremental + // update. Dirty tracking only guarantees that VISUAL changes are + // flagged (see page.Row.dirty); these fields are non-visual + // metadata that the terminal may change without dirtying the row + // (e.g. Screen.cursorResetWrap clears wrap flags without a dirty + // mark). This staleness predates the chunked update + // implementation; it is present in the row-iterator implementation + // as well. + const StaleOkMask = page.Mask(page.Row, &.{ + "wrap", + "wrap_continuation", + "semantic_prompt", + "dirty", + }, 1); + + try testing.expectEqual(fresh.rows, incremental.rows); + try testing.expectEqual(fresh.cols, incremental.cols); + try testing.expectEqual(fresh.cursor.active, incremental.cursor.active); + try testing.expectEqual(fresh.cursor.viewport, incremental.cursor.viewport); + try testing.expectEqual( + @as(page.Cell.Backing, @bitCast(fresh.cursor.cell)), + @as(page.Cell.Backing, @bitCast(incremental.cursor.cell)), + ); + + const inc_data = incremental.row_data.slice(); + const new_data = fresh.row_data.slice(); + try testing.expectEqual(new_data.len, inc_data.len); + for (0..new_data.len) |y| { + errdefer std.log.warn("mismatch on row y={}", .{y}); + + // Pins must match exactly. + const inc_pin = inc_data.items(.pin)[y]; + const new_pin = new_data.items(.pin)[y]; + try testing.expectEqual(new_pin.node, inc_pin.node); + try testing.expectEqual(new_pin.y, inc_pin.y); + + // Raw row data must match, except for non-visual metadata + // fields which may legitimately be stale (see StaleOkMask). + const inc_row = inc_data.items(.raw)[y]; + const new_row = new_data.items(.raw)[y]; + try testing.expectEqual( + StaleOkMask.strip(new_row), + StaleOkMask.strip(inc_row), + ); + + const inc_cells = inc_data.items(.cells)[y].slice(); + const new_cells = new_data.items(.cells)[y].slice(); + try testing.expectEqual(new_cells.len, inc_cells.len); + const managed = new_row.managedMemory(); + for (0..new_cells.len) |x| { + errdefer std.log.warn("mismatch on cell x={}", .{x}); + + // Raw cell contents must match. + const inc_cell = inc_cells.items(.raw)[x]; + const new_cell = new_cells.items(.raw)[x]; + try testing.expectEqual( + @as(page.Cell.Backing, @bitCast(new_cell)), + @as(page.Cell.Backing, @bitCast(inc_cell)), + ); + + // The style is only defined if the cell is styled or is + // a bg-color cell within a row that has managed memory. + if (new_cell.style_id != 0 or + (managed and switch (new_cell.content_tag) { + .bg_color_rgb, .bg_color_palette => true, + else => false, + })) + { + try testing.expect(std.meta.eql( + new_cells.items(.style)[x], + inc_cells.items(.style)[x], + )); + } + + // Graphemes are only defined for grapheme cells. + if (new_cell.content_tag == .codepoint_grapheme) { + try testing.expectEqualSlices( + u21, + new_cells.items(.grapheme)[x], + inc_cells.items(.grapheme)[x], + ); + } + } + } +} + +test "incremental updates match full rebuild" { + const testing = std.testing; + const alloc = testing.allocator; + + // Deterministic so failures are reproducible. + var prng = std.Random.DefaultPrng.init(0xB0BA_CAFE); + const rand = prng.random(); + + var t = try Terminal.init(alloc, .{ + .cols = 20, + .rows = 8, + .max_scrollback = 500, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + var inc: RenderState = .empty; + defer inc.deinit(alloc); + + var buf: [64]u8 = undefined; + for (0..300) |_| { + // Perform a random batch of operations between updates. + for (0..rand.intRangeAtMost(usize, 1, 6)) |_| { + switch (rand.intRangeAtMost(u8, 0, 18)) { + // Plain text (possibly wrapping and scrolling). + 0, 1, 2 => for (0..rand.intRangeAtMost(usize, 1, 30)) |_| { + s.nextSlice(&.{rand.intRangeAtMost(u8, 'A', 'Z')}); + }, + + // Newlines to build scrollback and trigger pruning. + 3, 4 => for (0..rand.intRangeAtMost(usize, 1, 10)) |_| { + s.nextSlice("x\r\n"); + }, + + // Cursor movement. + 5 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{};{}H", .{ + rand.intRangeAtMost(u16, 1, 8), + rand.intRangeAtMost(u16, 1, 20), + })), + + // Styling: bold, truecolor bg, palette fg, reset. + 6 => s.nextSlice(switch (rand.intRangeAtMost(u8, 0, 3)) { + 0 => "\x1b[1m", + 1 => "\x1b[48;2;30;60;90m", + 2 => "\x1b[38;5;120m", + else => "\x1b[0m", + }), + + // Erase ops (EL, ED variants including scrollback). + 7 => s.nextSlice(switch (rand.intRangeAtMost(u8, 0, 4)) { + 0 => "\x1b[K", + 1 => "\x1b[1K", + 2 => "\x1b[J", + 3 => "\x1b[2J", + else => "\x1b[3J", + }), + + // Insert/delete lines (row rotations within regions). + 8 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}L", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + 9 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}M", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + + // Scroll up/down (page-dirty row rotations). + 10 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}S", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + 11 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}T", .{ + rand.intRangeAtMost(u16, 1, 4), + })), + + // Set/reset scroll regions to exercise bounded scrolls. + 12 => { + const top = rand.intRangeAtMost(u16, 1, 4); + const bot = rand.intRangeAtMost(u16, top + 1, 8); + s.nextSlice(try std.fmt.bufPrint( + &buf, + "\x1b[{};{}r", + .{ top, bot }, + )); + }, + + // Insert/delete/erase chars within a row. + 13 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}@", .{ + rand.intRangeAtMost(u16, 1, 5), + })), + 14 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}P", .{ + rand.intRangeAtMost(u16, 1, 5), + })), + + // Reverse index (scroll down at top). + 15 => s.nextSlice("\x1bM"), + + // Wide chars and multi-codepoint graphemes. + 16 => s.nextSlice("字👨👩👧"), + + // Alternate screen switching (screen key redraw path). + 17 => s.nextSlice(if (rand.boolean()) + "\x1b[?1049h" + else + "\x1b[?1049l"), + + // DECALN full-screen fill. + 18 => s.nextSlice("\x1b#8"), + + else => unreachable, + } + } + + // Occasionally scroll the viewport into scrollback and back. + switch (rand.intRangeAtMost(u8, 0, 9)) { + 0 => t.scrollViewport(.{ .delta = -3 }), + 1 => t.scrollViewport(.{ .delta = 2 }), + 2 => t.scrollViewport(.bottom), + 3 => t.scrollViewport(.top), + else => {}, + } + + // Update our incremental state first: it must consume the dirty + // state. The fresh state always fully rebuilds (its dimensions + // start empty so it always redraws) and so does not depend on + // any dirty flags. + try inc.update(alloc, &t); + + var fresh: RenderState = .empty; + defer fresh.deinit(alloc); + try fresh.update(alloc, &t); + + try testCompareStates(&inc, &fresh); + } +} + +test "begin and end update" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice("\x1b[1mAB"); // Bold + s.nextSlice("\x1b[0;3mC"); // Italic + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.beginUpdate(alloc, &t); + + // We should have pending style runs on row 0: one for the bold + // run and one for the italic run. + { + const runs = state.pending_styles.items; + try testing.expectEqual(2, runs.len); + try testing.expectEqual(0, runs[0].y); + try testing.expectEqual(0, runs[0].start); + try testing.expectEqual(2, runs[0].end); + try testing.expect(runs[0].style.flags.bold); + try testing.expectEqual(0, runs[1].y); + try testing.expectEqual(2, runs[1].start); + try testing.expectEqual(3, runs[1].end); + try testing.expect(runs[1].style.flags.italic); + } + + // End our update. This should denormalize the runs into cells + // and clear the pending runs. + state.endUpdate(); + { + try testing.expectEqual(0, state.pending_styles.items.len); + + const row_data = state.row_data.slice(); + const cells = row_data.items(.cells); + try testing.expect(cells[0].get(0).style.flags.bold); + try testing.expect(cells[0].get(1).style.flags.bold); + try testing.expect(cells[0].get(2).style.flags.italic); + } +} + +test "bg color cells" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Write a styled cell (so the row has managed memory) then erase + // the rest of the line with a palette background pending. The + // erase produces bg_color content cells rather than styled cells. + s.nextSlice("\x1b[1mA\x1b[48;5;1m\x1b[K"); + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const row_data = state.row_data.slice(); + const cells = row_data.items(.cells); + { + const cell = cells[0].get(0); + try testing.expectEqual('A', cell.raw.codepoint()); + try testing.expect(cell.style.flags.bold); + } + for (1..10) |x| { + const cell = cells[0].get(x); + try testing.expectEqual( + page.Cell.ContentTag.bg_color_palette, + cell.raw.content_tag, + ); + try testing.expectEqual( + Style.Color{ .palette = 1 }, + cell.style.bg_color, + ); + } +} + test "grapheme" { const testing = std.testing; const alloc = testing.allocator; @@ -1343,7 +2002,7 @@ test "linkCells with scrollback spanning pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_cap = pages.pages.first.?.data.capacity.rows; + const first_page_cap = pages.pages.first.?.capacity().rows; // Fill first page for (0..first_page_cap - 1) |_| s.nextSlice("\r\n"); @@ -1405,6 +2064,73 @@ test "linkCells with invalid viewport point" { } } +test "flattened highlights require matching page serial" { + const testing = std.testing; + const alloc = testing.allocator; + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + // Capture the live generation while terminal-owned state is in scope so + // we can also verify beginUpdate copies it into the render row. + const live_pin = t.screens.active.pages.getTopLeft(.viewport); + const live_serial = live_pin.node.serial; + + var state: RenderState = .empty; + defer state.deinit(alloc); + try state.update(alloc, &t); + + const pin: PageList.Pin = pin: { + const row_data = state.row_data.slice(); + @memset(row_data.items(.dirty), false); + state.dirty = .false; + break :pin row_data.items(.pin)[0]; + }; + const row_serial = state.row_data.items(.serial)[0]; + try testing.expectEqual(live_pin.node, pin.node); + try testing.expectEqual(live_serial, row_serial); + + // Use the exact node pointer and row captured by the render state, but a + // different generation. A reused node address must not make this stale + // flattened highlight match. + var hl: highlight.Flattened = .{ + .chunks = .empty, + .top_x = 2, + .bot_x = 4, + }; + defer hl.deinit(alloc); + try hl.chunks.append(alloc, .{ + .node = pin.node, + .serial = live_serial ^ 1, + .start = pin.y, + .end = pin.y + 1, + }); + + try state.updateHighlightsFlattened(alloc, 42, &.{hl}); + { + const row_data = state.row_data.slice(); + try testing.expectEqual(0, row_data.items(.highlights)[0].items.len); + try testing.expect(!row_data.items(.dirty)[0]); + try testing.expectEqual(.false, state.dirty); + } + + // The same chunk is accepted once its copied serial also matches. + hl.chunks.items(.serial)[0] = live_serial; + try state.updateHighlightsFlattened(alloc, 42, &.{hl}); + { + const row_data = state.row_data.slice(); + const row_highlights = row_data.items(.highlights)[0].items; + try testing.expectEqual(1, row_highlights.len); + try testing.expectEqual(42, row_highlights[0].tag); + try testing.expectEqual([2]size.CellCountInt{ 2, 4 }, row_highlights[0].range); + try testing.expect(row_data.items(.dirty)[0]); + try testing.expectEqual(.partial, state.dirty); + } +} + test "dirty row resets highlights" { const testing = std.testing; const alloc = testing.allocator; |
