terminal: RenderState

pull/9662/head
Mitchell Hashimoto 2025-11-17 13:05:00 -10:00
parent 5f3645433c
commit a90fe1656a
No known key found for this signature in database
GPG Key ID: 523D5DC389D273BC
2 changed files with 264 additions and 0 deletions

View File

@ -5,6 +5,7 @@ const stream = @import("stream.zig");
const ansi = @import("ansi.zig"); const ansi = @import("ansi.zig");
const csi = @import("csi.zig"); const csi = @import("csi.zig");
const hyperlink = @import("hyperlink.zig"); const hyperlink = @import("hyperlink.zig");
const render = @import("render.zig");
const stream_readonly = @import("stream_readonly.zig"); const stream_readonly = @import("stream_readonly.zig");
const style = @import("style.zig"); const style = @import("style.zig");
pub const apc = @import("apc.zig"); pub const apc = @import("apc.zig");
@ -40,6 +41,7 @@ pub const Pin = PageList.Pin;
pub const Point = point.Point; pub const Point = point.Point;
pub const ReadonlyHandler = stream_readonly.Handler; pub const ReadonlyHandler = stream_readonly.Handler;
pub const ReadonlyStream = stream_readonly.Stream; pub const ReadonlyStream = stream_readonly.Stream;
pub const RenderState = render.RenderState;
pub const Screen = @import("Screen.zig"); pub const Screen = @import("Screen.zig");
pub const ScreenSet = @import("ScreenSet.zig"); pub const ScreenSet = @import("ScreenSet.zig");
pub const Scrollbar = PageList.Scrollbar; pub const Scrollbar = PageList.Scrollbar;

262
src/terminal/render.zig Normal file
View File

@ -0,0 +1,262 @@
const std = @import("std");
const assert = std.debug.assert;
const Allocator = std.mem.Allocator;
const ArenaAllocator = std.heap.ArenaAllocator;
const size = @import("size.zig");
const page = @import("page.zig");
const Screen = @import("Screen.zig");
const ScreenSet = @import("ScreenSet.zig");
const Style = @import("style.zig").Style;
const Terminal = @import("Terminal.zig");
// Developer note: this is in src/terminal and not src/renderer because
// the goal is that this remains generic to multiple renderers. This can
// aid specifically with libghostty-vt with converting terminal state to
// a renderable form.
/// Contains the state required to render the screen, including optimizing
/// for repeated render calls and only rendering dirty regions.
///
/// Previously, our renderer would use `clone` to clone the screen within
/// the viewport to perform rendering. This worked well enough that we kept
/// it all the way up through the Ghostty 1.2.x series, but the clone time
/// was repeatedly a bottleneck blocking IO.
///
/// Rather than a generic clone that tries to clone all screen state per call
/// (within a region), a stateful approach that optimizes for only what a
/// renderer needs to do makes more sense.
pub const RenderState = struct {
/// The current screen dimensions. It is possible that these don't match
/// the renderer's current dimensions in grid cells because resizing
/// can happen asynchronously. For example, for Metal, our NSView resizes
/// at a different time than when our internal terminal state resizes.
/// This can lead to a one or two frame mismatch a renderer needs to
/// handle.
///
/// The viewport is always exactly equal to the active area size so this
/// is also the viewport size.
rows: size.CellCountInt,
cols: size.CellCountInt,
/// The viewport is at the bottom of the terminal, viewing the active
/// area and scrolling with new output.
viewport_is_bottom: bool,
/// The rows (y=0 is top) of the viewport.
row_data: std.ArrayList(Row),
/// The screen type that this state represents. This is used primarily
/// to detect changes.
screen: ScreenSet.Key,
/// Initial state.
pub const empty: RenderState = .{
.rows = 0,
.cols = 0,
.viewport_is_bottom = false,
.row_data = .empty,
.screen = .primary,
};
/// A row within the viewport.
pub const Row = struct {
/// Arena used for any heap allocations for this row,
arena: ArenaAllocator.State,
/// The cells in this row, always `cols`` length.
cells: std.MultiArrayList(Cell),
};
pub const Cell = struct {
content: union(enum) {
empty,
single: u21,
slice: []const u21,
},
wide: page.Cell.Wide,
style: Style,
};
pub fn deinit(self: *RenderState, alloc: Allocator) void {
for (self.row_data.items) |row| {
var arena: ArenaAllocator = row.arena.promote(alloc);
arena.deinit();
}
self.row_data.deinit(alloc);
}
/// Update the render state to the latest terminal state.
///
/// This will reset the terminal dirty state since it is consumed
/// by this render state update.
pub fn update(
self: *RenderState,
alloc: Allocator,
t: *Terminal,
) Allocator.Error!void {
const full_rebuild: bool = rebuild: {
// If our screen key changed, we need to do a full rebuild
// because our render state is viewport-specific.
if (t.screens.active_key != self.screen) break :rebuild true;
// If our terminal is dirty at all, we do a full rebuild. These
// dirty values are full-terminal dirty values.
{
const Int = @typeInfo(Terminal.Dirty).@"struct".backing_integer.?;
const v: Int = @bitCast(t.flags.dirty);
if (v > 0) break :rebuild true;
}
// If our screen is dirty at all, we do a full rebuild. This is
// a full screen dirty tracker.
{
const Int = @typeInfo(Screen.Dirty).@"struct".backing_integer.?;
const v: Int = @bitCast(t.screens.active.dirty);
if (v > 0) break :rebuild true;
}
break :rebuild false;
};
// Full rebuild resets our state completely.
if (full_rebuild) {
self.* = .empty;
self.screen = t.screens.active_key;
}
const s: *Screen = t.screens.active;
// Always set our cheap fields, its more expensive to compare
self.rows = s.pages.rows;
self.cols = s.pages.cols;
self.viewport_is_bottom = s.viewportIsBottom();
// Ensure our row length is exactly our height, freeing or allocating
// data as necessary.
if (self.row_data.items.len <= self.rows) {
@branchHint(.likely);
try self.row_data.ensureTotalCapacity(alloc, self.rows);
for (self.row_data.items.len..self.rows) |_| {
self.row_data.appendAssumeCapacity(.{
.arena = .{},
.cells = .empty,
});
}
} else {
for (self.row_data.items[self.rows..]) |row| {
var arena: ArenaAllocator = row.arena.promote(alloc);
arena.deinit();
}
self.row_data.shrinkRetainingCapacity(self.rows);
}
// Go through and setup our rows.
var row_it = s.pages.rowIterator(
.left_up,
.{ .viewport = .{} },
null,
);
var y: size.CellCountInt = 0;
while (row_it.next()) |row_pin| : (y = y + 1) {
// If the row isn't dirty then we assume it is unchanged.
if (!full_rebuild and !row_pin.isDirty()) continue;
// If we have an existing row, reuse it. Guaranteed to exist
// because we setup our row data above.
const row: *Row = &self.row_data.items[y];
// 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.arena.promote(alloc);
defer row.arena = arena.state;
const arena_alloc = arena.allocator();
// Reset our cells if we're rebuilding this row.
if (row.cells.len > 0) {
_ = arena.reset(.retain_capacity);
row.cells = .empty;
}
// Get all our cells in the page.
const p: *page.Page = &row_pin.node.data;
const page_rac = row_pin.rowAndCell();
const page_cells: []const page.Cell = p.getCells(page_rac.row);
assert(page_cells.len == self.cols);
try row.cells.ensureTotalCapacity(arena_alloc, self.cols);
for (page_cells) |*page_cell| {
// Append assuming its a single-codepoint, styled cell
// (most common by far).
row.cells.appendAssumeCapacity(.{
.content = .{ .single = page_cell.content.codepoint },
.wide = page_cell.wide,
.style = 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 => {
@branchHint(.likely);
},
// If we have a multi-codepoint grapheme, look it up and
// set our content type.
.codepoint_grapheme => grapheme: {
@branchHint(.unlikely);
const extra = p.lookupGrapheme(page_cell) orelse break :grapheme;
var cps = try arena_alloc.alloc(u21, extra.len + 1);
cps[0] = page_cell.content.codepoint;
@memcpy(cps[1..], extra);
const idx = row.cells.len - 1;
var content = row.cells.items(.content);
content[idx] = .{ .slice = cps };
},
.bg_color_rgb => {
@branchHint(.unlikely);
const idx = row.cells.len - 1;
var content = row.cells.items(.style);
content[idx] = .{ .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 => {
@branchHint(.unlikely);
const idx = row.cells.len - 1;
var content = row.cells.items(.style);
content[idx] = .{ .bg_color = .{
.palette = page_cell.content.color_palette,
} };
},
}
}
}
assert(y == self.rows);
// Clear our dirty flags
t.flags.dirty = .{};
s.dirty = .{};
}
};
test {
const testing = std.testing;
const alloc = testing.allocator;
var t = try Terminal.init(alloc, .{
.cols = 80,
.rows = 24,
});
defer t.deinit(alloc);
var state: RenderState = .empty;
defer state.deinit(alloc);
try state.update(alloc, &t);
}