terminal: RenderState
parent
5f3645433c
commit
a90fe1656a
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue