mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-09-20 20:48:06 +00:00
2211 lines
78 KiB
Zig
2211 lines
78 KiB
Zig
const std = @import("std");
|
|
const assert = @import("../quirks.zig").inlineAssert;
|
|
const Allocator = std.mem.Allocator;
|
|
const ArenaAllocator = std.heap.ArenaAllocator;
|
|
const fastmem = @import("../fastmem.zig");
|
|
const lib = @import("lib.zig");
|
|
const color = @import("color.zig");
|
|
const cursor = @import("cursor.zig");
|
|
const highlight = @import("highlight.zig");
|
|
const point = @import("point.zig");
|
|
const size = @import("size.zig");
|
|
const page = @import("page.zig");
|
|
const PageList = @import("PageList.zig");
|
|
const Selection = @import("Selection.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.
|
|
///
|
|
/// To use this, initialize the render state to empty, then call `update`
|
|
/// on each frame to update the state to the latest terminal state.
|
|
///
|
|
/// var state: RenderState = .empty;
|
|
/// 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
|
|
/// waste, it is recommended that the caller `deinit` and start with an
|
|
/// empty render state every so often.
|
|
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 color state for the terminal.
|
|
colors: Colors,
|
|
|
|
/// Cursor state within the viewport.
|
|
cursor: Cursor,
|
|
|
|
/// The rows (y=0 is top) of the viewport. Guaranteed to be `rows` length.
|
|
///
|
|
/// This is a MultiArrayList because only the update cares about
|
|
/// the allocators. Callers care about all the other properties, and
|
|
/// this better optimizes cache locality for read access for those
|
|
/// use cases.
|
|
row_data: std.MultiArrayList(Row),
|
|
|
|
/// The dirty state of the render state. This is set by the update method.
|
|
/// The renderer/caller should set this to false when it has handled
|
|
/// the dirty state.
|
|
dirty: Dirty,
|
|
|
|
/// The screen type that this state represents. This is used primarily
|
|
/// to detect changes.
|
|
screen: ScreenSet.Key,
|
|
|
|
/// The last viewport pin used to generate this state. This is NOT
|
|
/// a tracked pin and is generally NOT safe to read other than the direct
|
|
/// values for comparison.
|
|
viewport_pin: ?PageList.Pin = null,
|
|
|
|
/// The cached selection so we can avoid expensive selection calculations
|
|
/// 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,
|
|
.cols = 0,
|
|
.colors = .{
|
|
.background = .{ .r = 0, .g = 0, .b = 0 },
|
|
.foreground = .{ .r = 0xff, .g = 0xff, .b = 0xff },
|
|
.cursor = null,
|
|
.palette = color.default,
|
|
},
|
|
.cursor = .{
|
|
.active = .{ .x = 0, .y = 0 },
|
|
.viewport = null,
|
|
.cell = .{},
|
|
.style = undefined,
|
|
.visual_style = .block,
|
|
.password_input = false,
|
|
.visible = true,
|
|
.blinking = false,
|
|
},
|
|
.row_data = .empty,
|
|
.dirty = .false,
|
|
.screen = .primary,
|
|
};
|
|
|
|
/// The color state for the terminal.
|
|
///
|
|
/// The background/foreground will be reversed if the terminal reverse
|
|
/// color mode is on! You do not need to handle that manually!
|
|
pub const Colors = struct {
|
|
background: color.RGB,
|
|
foreground: color.RGB,
|
|
cursor: ?color.RGB,
|
|
palette: color.Palette,
|
|
};
|
|
|
|
pub const Cursor = struct {
|
|
/// The x/y position of the cursor within the active area.
|
|
active: point.Coordinate,
|
|
|
|
/// The x/y position of the cursor within the viewport. This
|
|
/// may be null if the cursor is not visible within the viewport.
|
|
viewport: ?Viewport,
|
|
|
|
/// The cell data for the cursor position. Managed memory is not
|
|
/// safe to access from this.
|
|
cell: page.Cell,
|
|
|
|
/// The style, always valid even if the cell is default style.
|
|
style: Style,
|
|
|
|
/// The visual style of the cursor itself, such as a block or
|
|
/// bar.
|
|
visual_style: cursor.Style,
|
|
|
|
/// True if the cursor is detected to be at a password input field.
|
|
password_input: bool,
|
|
|
|
/// Cursor visibility state determined by the terminal mode.
|
|
visible: bool,
|
|
|
|
/// Cursor blink state determined by the terminal mode.
|
|
blinking: bool,
|
|
|
|
pub const Viewport = struct {
|
|
/// The x/y position of the cursor within the viewport.
|
|
x: size.CellCountInt,
|
|
y: size.CellCountInt,
|
|
|
|
/// Whether the cursor is part of a wide character and
|
|
/// on the tail of it. If so, some renderers may use this
|
|
/// to move the cursor back one.
|
|
wide_tail: bool,
|
|
};
|
|
};
|
|
|
|
/// A row within the viewport.
|
|
pub const Row = struct {
|
|
/// Arena used for any heap allocations for cell contents
|
|
/// in this row. Importantly, this is NOT used for the MultiArrayList
|
|
/// itself. We do this on purpose so that we can easily clear rows,
|
|
/// but retain cached MultiArrayList capacities since grid sizes don't
|
|
/// change often.
|
|
arena: ArenaAllocator.State,
|
|
|
|
/// 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,
|
|
|
|
/// The cells in this row. Guaranteed to be `cols` length.
|
|
cells: std.MultiArrayList(Cell),
|
|
|
|
/// A dirty flag that can be used by the renderer to track
|
|
/// its own draw state. `update` will mark this true whenever
|
|
/// this row is changed, too.
|
|
dirty: bool,
|
|
|
|
/// The x range of the selection within this row.
|
|
selection: ?[2]size.CellCountInt,
|
|
|
|
/// The highlights within this row.
|
|
highlights: std.ArrayList(Highlight),
|
|
};
|
|
|
|
pub const Highlight = struct {
|
|
/// A special tag that can be used by the caller to differentiate
|
|
/// different highlight types. The value is opaque to the RenderState.
|
|
tag: u8,
|
|
|
|
/// The x ranges of highlights within this row.
|
|
range: [2]size.CellCountInt,
|
|
};
|
|
|
|
pub const Cell = struct {
|
|
/// Always set, this is the raw copied cell data from page.Cell.
|
|
/// The managed memory (hyperlinks, graphames, etc.) is NOT safe
|
|
/// to access from here. It is duplicated into the other fields if
|
|
/// it exists.
|
|
raw: page.Cell,
|
|
|
|
/// Grapheme data for the cell. This is undefined unless the
|
|
/// raw cell's content_tag is `codepoint_grapheme`.
|
|
grapheme: []const u21,
|
|
|
|
/// The style data for the cell. This is undefined unless
|
|
/// the style_id is non-default on raw.
|
|
style: Style,
|
|
};
|
|
|
|
// Dirty state.
|
|
pub const Dirty = lib.Enum(lib.target, &.{
|
|
// Not dirty at all. Can skip rendering if prior state was
|
|
// already rendered.
|
|
"false",
|
|
|
|
// Some rows changed but not all. None of the global state
|
|
// changed such as colors.
|
|
"partial",
|
|
|
|
// Global state changed or dimensions changed. All rows should
|
|
// be redrawn.
|
|
"full",
|
|
});
|
|
|
|
const SelectionCache = struct {
|
|
selection: Selection,
|
|
tl_pin: PageList.Pin,
|
|
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),
|
|
self.row_data.items(.cells),
|
|
) |state, *cells| {
|
|
var arena: ArenaAllocator = state.promote(alloc);
|
|
arena.deinit();
|
|
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(
|
|
self: *RenderState,
|
|
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: {
|
|
// 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 :redraw 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 :redraw 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 :redraw true;
|
|
}
|
|
|
|
// If our dimensions changed, we do a full rebuild.
|
|
if (self.rows != s.pages.rows or
|
|
self.cols != s.pages.cols)
|
|
{
|
|
break :redraw true;
|
|
}
|
|
|
|
// If our viewport pin changed, we do a full rebuild.
|
|
if (self.viewport_pin) |old| {
|
|
if (!old.eql(viewport_pin)) break :redraw true;
|
|
}
|
|
|
|
break :redraw false;
|
|
};
|
|
|
|
// Always set our cheap fields, its more expensive to compare
|
|
self.rows = s.pages.rows;
|
|
self.cols = s.pages.cols;
|
|
self.viewport_pin = viewport_pin;
|
|
self.cursor.active = .{ .x = s.cursor.x, .y = s.cursor.y };
|
|
self.cursor.cell = s.cursor.page_cell.*;
|
|
self.cursor.style = s.cursor.style;
|
|
self.cursor.visual_style = s.cursor.cursor_style;
|
|
self.cursor.password_input = t.flags.password_input;
|
|
self.cursor.visible = t.modes.get(.cursor_visible);
|
|
self.cursor.blinking = t.modes.get(.cursor_blinking);
|
|
|
|
// Always reset the cursor viewport position. In the future we can
|
|
// probably cache this by comparing the cursor pin and viewport pin
|
|
// but may not be worth it.
|
|
self.cursor.viewport = null;
|
|
|
|
// Colors.
|
|
self.colors.cursor = t.colors.cursor.get();
|
|
|
|
// 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
|
|
// case of Terminal is that the caller set their own configured
|
|
// defaults on load so this doesn't happen.
|
|
const bg = t.colors.background.get() orelse break :bg_fg;
|
|
const fg = t.colors.foreground.get() orelse break :bg_fg;
|
|
if (t.modes.get(.reverse_colors)) {
|
|
self.colors.background = fg;
|
|
self.colors.foreground = bg;
|
|
} else {
|
|
self.colors.background = bg;
|
|
self.colors.foreground = fg;
|
|
}
|
|
}
|
|
|
|
// Ensure our row length is exactly our height, freeing or allocating
|
|
// data as necessary. In most cases we'll have a perfectly matching
|
|
// size.
|
|
if (self.row_data.len != self.rows) {
|
|
@branchHint(.unlikely);
|
|
|
|
if (self.row_data.len < self.rows) {
|
|
// Resize our rows to the desired length, marking any added
|
|
// values undefined.
|
|
const old_len = self.row_data.len;
|
|
try self.row_data.resize(alloc, self.rows);
|
|
|
|
// Initialize all our values. Its faster to use slice() + set()
|
|
// because appendAssumeCapacity does this multiple times.
|
|
var row_data = self.row_data.slice();
|
|
for (old_len..self.rows) |y| {
|
|
row_data.set(y, .{
|
|
.arena = .{},
|
|
.pin = undefined,
|
|
.serial = undefined,
|
|
.raw = undefined,
|
|
.cells = .empty,
|
|
.dirty = true,
|
|
.selection = null,
|
|
.highlights = .empty,
|
|
});
|
|
}
|
|
} else {
|
|
const row_data = self.row_data.slice();
|
|
for (
|
|
row_data.items(.arena)[self.rows..],
|
|
row_data.items(.cells)[self.rows..],
|
|
) |state, *cell| {
|
|
var arena: ArenaAllocator = state.promote(alloc);
|
|
arena.deinit();
|
|
cell.deinit(alloc);
|
|
}
|
|
self.row_data.shrinkRetainingCapacity(self.rows);
|
|
}
|
|
}
|
|
|
|
// Break down our row data
|
|
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);
|
|
|
|
// 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;
|
|
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 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
|
|
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 = @intCast(y + (cy - chunk.start)),
|
|
.x = s.cursor.x,
|
|
|
|
// Future: we should use our own state here to look this
|
|
// up rather than calling this.
|
|
.wide_tail = if (s.cursor.x > 0)
|
|
s.cursorCellLeft(1).wide == .wide
|
|
else
|
|
false,
|
|
};
|
|
}
|
|
|
|
// 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 (!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);
|
|
continue;
|
|
}
|
|
|
|
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);
|
|
|
|
// If our screen has a selection, then mark the rows with the
|
|
// selection. We do this outside of the loop above because its unlikely
|
|
// a selection exists and because the way our selections are structured
|
|
// today is very inefficient.
|
|
//
|
|
// NOTE: To improve the performance of the block below, we'll need
|
|
// to rethink how we model selections in general.
|
|
//
|
|
// There are performance improvements that can be made here, though.
|
|
// For example, `containedRow` recalculates a bunch of information
|
|
// we can cache.
|
|
if (s.selection) |*sel| selection: {
|
|
@branchHint(.unlikely);
|
|
|
|
// Populate our selection cache to avoid some expensive
|
|
// recalculation.
|
|
const cache: *const SelectionCache = cache: {
|
|
if (self.selection_cache) |*c| cache_check: {
|
|
// If we're redrawing, we recalculate the cache just to
|
|
// be safe.
|
|
if (redraw) break :cache_check;
|
|
|
|
// If our selection isn't equal, we aren't cached!
|
|
if (!c.selection.eql(sel.*)) break :cache_check;
|
|
|
|
// If we have no dirty rows, we can not recalculate.
|
|
if (!any_dirty) break :selection;
|
|
|
|
// We have dirty rows, we can utilize the cache.
|
|
break :cache c;
|
|
}
|
|
|
|
// Create a new cache
|
|
const tl_pin = sel.topLeft(s);
|
|
const br_pin = sel.bottomRight(s);
|
|
self.selection_cache = .{
|
|
.selection = .init(tl_pin, br_pin, sel.rectangle),
|
|
.tl_pin = tl_pin,
|
|
.br_pin = br_pin,
|
|
};
|
|
break :cache &self.selection_cache.?;
|
|
};
|
|
|
|
// Grab the inefficient data we need from the selection. At
|
|
// least we can cache it.
|
|
const tl = s.pages.pointFromPin(.screen, cache.tl_pin).?.screen;
|
|
const br = s.pages.pointFromPin(.screen, cache.br_pin).?.screen;
|
|
|
|
// We need to determine if our selection is within the viewport.
|
|
// The viewport is generally very small so the efficient way to
|
|
// do this is to traverse the viewport pages and check for the
|
|
// matching selection pages.
|
|
for (
|
|
row_pins,
|
|
row_sels,
|
|
) |pin, *sel_bounds| {
|
|
const p = s.pages.pointFromPin(.screen, pin).?.screen;
|
|
const row_sel = sel.containedRowCached(
|
|
s,
|
|
cache.tl_pin,
|
|
cache.br_pin,
|
|
pin,
|
|
tl,
|
|
br,
|
|
p,
|
|
) orelse continue;
|
|
const start = row_sel.start();
|
|
const end = row_sel.end();
|
|
assert(start.node == end.node);
|
|
assert(start.x <= end.x);
|
|
assert(start.y == end.y);
|
|
sel_bounds.* = .{ start.x, end.x };
|
|
}
|
|
}
|
|
|
|
// Handle dirty state.
|
|
if (redraw) {
|
|
// Fully redraw resets some other state.
|
|
self.screen = t.screens.active_key;
|
|
self.dirty = .full;
|
|
|
|
// Note: we don't clear any row_data here because our rebuild
|
|
// above did this.
|
|
} else if (any_dirty and self.dirty == .false) {
|
|
self.dirty = .partial;
|
|
}
|
|
|
|
// 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
|
|
/// any critical sections.
|
|
///
|
|
/// This will not clear any previous highlights, so the caller must
|
|
/// manually clear them if desired.
|
|
pub fn updateHighlightsFlattened(
|
|
self: *RenderState,
|
|
alloc: Allocator,
|
|
tag: u8,
|
|
hls: []const highlight.Flattened,
|
|
) Allocator.Error!void {
|
|
// Fast path, we have no highlights!
|
|
if (hls.len == 0) return;
|
|
|
|
// This is, admittedly, horrendous. This is some low hanging fruit
|
|
// to optimize. In my defense, screens are usually small, the number
|
|
// of highlights is usually small, and this only happens on the
|
|
// viewport outside of a locked area. Still, I'd love to see this
|
|
// improved someday.
|
|
|
|
// We need to track whether any row had a match so we can mark
|
|
// the dirty state.
|
|
var any_dirty: bool = false;
|
|
|
|
const row_data = self.row_data.slice();
|
|
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_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 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;
|
|
|
|
// We're a match!
|
|
var arena = row_arena.promote(alloc);
|
|
defer row_arena.* = arena.state;
|
|
const arena_alloc = arena.allocator();
|
|
try row_highlights.append(
|
|
arena_alloc,
|
|
.{
|
|
.tag = tag,
|
|
.range = .{
|
|
if (i == 0 and
|
|
row_pin.y == starts[0])
|
|
hl.top_x
|
|
else
|
|
0,
|
|
if (i == nodes.len - 1 and
|
|
row_pin.y == ends[nodes.len - 1] - 1)
|
|
hl.bot_x
|
|
else
|
|
self.cols - 1,
|
|
},
|
|
},
|
|
);
|
|
|
|
dirty.* = true;
|
|
any_dirty = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Mark our dirty state.
|
|
if (any_dirty and self.dirty == .false) self.dirty = .partial;
|
|
}
|
|
|
|
pub const StringMap = std.ArrayListUnmanaged(point.Coordinate);
|
|
|
|
/// Convert the current render state contents to a UTF-8 encoded
|
|
/// string written to the given writer. This will unwrap all the wrapped
|
|
/// rows. This is useful for a minimal viewport search.
|
|
///
|
|
/// This currently writes empty cell contents as \x00 and writes all
|
|
/// blank lines. This is fine for our current usage (link search) but
|
|
/// we can adjust this later.
|
|
///
|
|
/// NOTE: There is a limitation in that wrapped lines before/after
|
|
/// the top/bottom line of the viewport are not included, since
|
|
/// the render state cuts them off.
|
|
pub fn string(
|
|
self: *const RenderState,
|
|
writer: *std.Io.Writer,
|
|
map: ?struct {
|
|
alloc: Allocator,
|
|
map: *StringMap,
|
|
},
|
|
) (Allocator.Error || std.Io.Writer.Error)!void {
|
|
const row_slice = self.row_data.slice();
|
|
const row_rows = row_slice.items(.raw);
|
|
const row_cells = row_slice.items(.cells);
|
|
|
|
for (
|
|
0..,
|
|
row_rows,
|
|
row_cells,
|
|
) |y, row, cells| {
|
|
const cells_slice = cells.slice();
|
|
for (
|
|
0..,
|
|
cells_slice.items(.raw),
|
|
cells_slice.items(.grapheme),
|
|
) |x, cell, graphemes| {
|
|
var len: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch
|
|
return error.WriteFailed;
|
|
try writer.print("{u}", .{cell.codepoint()});
|
|
if (cell.hasGrapheme()) {
|
|
for (graphemes) |cp| {
|
|
len += std.unicode.utf8CodepointSequenceLength(cp) catch
|
|
return error.WriteFailed;
|
|
try writer.print("{u}", .{cp});
|
|
}
|
|
}
|
|
|
|
if (map) |m| try m.map.appendNTimes(m.alloc, .{
|
|
.x = @intCast(x),
|
|
.y = @intCast(y),
|
|
}, len);
|
|
}
|
|
|
|
if (!row.wrap) {
|
|
try writer.writeAll("\n");
|
|
if (map) |m| try m.map.append(m.alloc, .{
|
|
.x = @intCast(cells_slice.len),
|
|
.y = @intCast(y),
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
/// A set of coordinates representing cells.
|
|
pub const CellSet = std.AutoArrayHashMapUnmanaged(point.Coordinate, void);
|
|
|
|
/// Returns a map of the cells that match to an OSC8 hyperlink over the
|
|
/// given point in the render state.
|
|
///
|
|
/// IMPORTANT: The terminal must not have updated since the last call to
|
|
/// `update`. If there is any chance the terminal has updated, the caller
|
|
/// must first call `update` again to refresh the render state.
|
|
///
|
|
/// For example, you may want to hold a lock for the duration of the
|
|
/// update and hyperlink lookup to ensure no updates happen in between.
|
|
pub fn linkCells(
|
|
self: *const RenderState,
|
|
alloc: Allocator,
|
|
viewport_point: point.Coordinate,
|
|
) Allocator.Error!CellSet {
|
|
var result: CellSet = .empty;
|
|
errdefer result.deinit(alloc);
|
|
|
|
const row_slice = self.row_data.slice();
|
|
const row_pins = row_slice.items(.pin);
|
|
const row_cells = row_slice.items(.cells);
|
|
|
|
// Our viewport point is sent in by the caller and can't be trusted.
|
|
// If it is outside the valid area then just return empty because
|
|
// we can't possibly have a link there.
|
|
if (viewport_point.x >= self.cols or
|
|
viewport_point.y >= row_pins.len) return result;
|
|
|
|
// Grab our link ID
|
|
const link_pin: PageList.Pin = row_pins[viewport_point.y];
|
|
const link_page: *page.Page = link_pin.node.page();
|
|
const link = link: {
|
|
const rac = link_page.getRowAndCell(
|
|
viewport_point.x,
|
|
link_pin.y,
|
|
);
|
|
|
|
// The likely scenario is that our mouse isn't even over a link.
|
|
if (!rac.cell.hyperlink) {
|
|
@branchHint(.likely);
|
|
return result;
|
|
}
|
|
|
|
const link_id = link_page.lookupHyperlink(rac.cell) orelse
|
|
return result;
|
|
break :link link_page.hyperlink_set.get(
|
|
link_page.memory,
|
|
link_id,
|
|
);
|
|
};
|
|
|
|
for (
|
|
0..,
|
|
row_pins,
|
|
row_cells,
|
|
) |y, pin, cells| {
|
|
for (0.., cells.items(.raw)) |x, cell| {
|
|
if (!cell.hyperlink) continue;
|
|
|
|
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;
|
|
break :link other_page.hyperlink_set.get(
|
|
other_page.memory,
|
|
link_id,
|
|
);
|
|
};
|
|
|
|
if (link.eql(
|
|
link_page.memory,
|
|
other,
|
|
other_page.memory,
|
|
)) try result.put(alloc, .{
|
|
.y = @intCast(y),
|
|
.x = @intCast(x),
|
|
}, {});
|
|
}
|
|
}
|
|
|
|
return result;
|
|
}
|
|
};
|
|
|
|
/// 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.data,
|
|
} },
|
|
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;
|
|
const io = testing.io;
|
|
|
|
var t = try Terminal.init(io, alloc, .{
|
|
.cols = 80,
|
|
.rows = 24,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
// This fills the screen up
|
|
try t.decaln();
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
}
|
|
|
|
test "basic text" {
|
|
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("ABCD");
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
|
|
// Verify we have the right number of rows
|
|
const row_data = state.row_data.slice();
|
|
try testing.expectEqual(3, row_data.len);
|
|
|
|
// All rows should have cols cells
|
|
const cells = row_data.items(.cells);
|
|
try testing.expectEqual(10, cells[0].len);
|
|
try testing.expectEqual(10, cells[1].len);
|
|
try testing.expectEqual(10, cells[2].len);
|
|
|
|
// Row zero should contain our text
|
|
try testing.expectEqual('A', cells[0].get(0).raw.codepoint());
|
|
try testing.expectEqual('B', cells[0].get(1).raw.codepoint());
|
|
try testing.expectEqual('C', cells[0].get(2).raw.codepoint());
|
|
try testing.expectEqual('D', cells[0].get(3).raw.codepoint());
|
|
try testing.expectEqual(0, cells[0].get(4).raw.codepoint());
|
|
}
|
|
|
|
test "styled text" {
|
|
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[1mA"); // Bold
|
|
s.nextSlice("\x1b[0;3mB"); // Italic
|
|
s.nextSlice("\x1b[0;4mC"); // Underline
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
|
|
// Verify we have the right number of rows
|
|
const row_data = state.row_data.slice();
|
|
try testing.expectEqual(3, row_data.len);
|
|
|
|
// All rows should have cols cells
|
|
const cells = row_data.items(.cells);
|
|
try testing.expectEqual(10, cells[0].len);
|
|
try testing.expectEqual(10, cells[1].len);
|
|
try testing.expectEqual(10, cells[2].len);
|
|
|
|
// Row zero should contain our text
|
|
{
|
|
const cell = cells[0].get(0);
|
|
try testing.expectEqual('A', cell.raw.codepoint());
|
|
try testing.expect(cell.style.flags.bold);
|
|
}
|
|
{
|
|
const cell = cells[0].get(1);
|
|
try testing.expectEqual('B', cell.raw.codepoint());
|
|
try testing.expect(!cell.style.flags.bold);
|
|
try testing.expect(cell.style.flags.italic);
|
|
}
|
|
try testing.expectEqual('C', cells[0].get(2).raw.codepoint());
|
|
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;
|
|
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(io, alloc, .{
|
|
.cols = 20,
|
|
.rows = 8,
|
|
.max_scrollback_bytes = 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;
|
|
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
|
|
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;
|
|
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();
|
|
|
|
// 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;
|
|
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("A");
|
|
s.nextSlice("👨"); // this has a ZWJ
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
|
|
// Verify we have the right number of rows
|
|
const row_data = state.row_data.slice();
|
|
try testing.expectEqual(3, row_data.len);
|
|
|
|
// All rows should have cols cells
|
|
const cells = row_data.items(.cells);
|
|
try testing.expectEqual(10, cells[0].len);
|
|
try testing.expectEqual(10, cells[1].len);
|
|
try testing.expectEqual(10, cells[2].len);
|
|
|
|
// Row zero should contain our text
|
|
{
|
|
const cell = cells[0].get(0);
|
|
try testing.expectEqual('A', cell.raw.codepoint());
|
|
}
|
|
{
|
|
const cell = cells[0].get(1);
|
|
try testing.expectEqual(0x1F468, cell.raw.codepoint());
|
|
try testing.expectEqual(.wide, cell.raw.wide);
|
|
try testing.expectEqualSlices(u21, &.{0x200D}, cell.grapheme);
|
|
}
|
|
{
|
|
const cell = cells[0].get(2);
|
|
try testing.expectEqual(0, cell.raw.codepoint());
|
|
try testing.expectEqual(.spacer_tail, cell.raw.wide);
|
|
}
|
|
}
|
|
|
|
test "cursor state in viewport" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var t = try Terminal.init(io, alloc, .{
|
|
.cols = 10,
|
|
.rows = 5,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
s.nextSlice("A\x1b[H");
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
|
|
// Initial update
|
|
try state.update(alloc, &t);
|
|
try testing.expectEqual(0, state.cursor.active.x);
|
|
try testing.expectEqual(0, state.cursor.active.y);
|
|
try testing.expectEqual(0, state.cursor.viewport.?.x);
|
|
try testing.expectEqual(0, state.cursor.viewport.?.y);
|
|
try testing.expectEqual('A', state.cursor.cell.codepoint());
|
|
try testing.expect(state.cursor.style.default());
|
|
|
|
// Set a style on the cursor
|
|
s.nextSlice("\x1b[1m"); // Bold
|
|
try state.update(alloc, &t);
|
|
try testing.expect(!state.cursor.style.default());
|
|
try testing.expect(state.cursor.style.flags.bold);
|
|
s.nextSlice("\x1b[0m"); // Reset style
|
|
|
|
// Move cursor to 2,1
|
|
s.nextSlice("\x1b[2;3H");
|
|
try state.update(alloc, &t);
|
|
try testing.expectEqual(2, state.cursor.active.x);
|
|
try testing.expectEqual(1, state.cursor.active.y);
|
|
try testing.expectEqual(2, state.cursor.viewport.?.x);
|
|
try testing.expectEqual(1, state.cursor.viewport.?.y);
|
|
}
|
|
|
|
test "cursor state out of viewport" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var t = try Terminal.init(io, alloc, .{
|
|
.cols = 10,
|
|
.rows = 2,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
s.nextSlice("A\r\nB\r\nC\r\nD\r\n");
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
|
|
// Initial update
|
|
try state.update(alloc, &t);
|
|
try testing.expectEqual(0, state.cursor.active.x);
|
|
try testing.expectEqual(1, state.cursor.active.y);
|
|
try testing.expectEqual(0, state.cursor.viewport.?.x);
|
|
try testing.expectEqual(1, state.cursor.viewport.?.y);
|
|
|
|
// Scroll the viewport
|
|
t.scrollViewport(.top);
|
|
try state.update(alloc, &t);
|
|
|
|
// Set a style on the cursor
|
|
try testing.expectEqual(0, state.cursor.active.x);
|
|
try testing.expectEqual(1, state.cursor.active.y);
|
|
try testing.expect(state.cursor.viewport == null);
|
|
}
|
|
|
|
test "dirty state" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var t = try Terminal.init(io, alloc, .{
|
|
.cols = 10,
|
|
.rows = 5,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
|
|
// First update should trigger redraw due to resize
|
|
try state.update(alloc, &t);
|
|
try testing.expectEqual(.full, state.dirty);
|
|
|
|
// Reset dirty flag and dirty rows
|
|
state.dirty = .false;
|
|
{
|
|
const row_data = state.row_data.slice();
|
|
const dirty = row_data.items(.dirty);
|
|
@memset(dirty, false);
|
|
}
|
|
|
|
// Second update with no changes - no dirty rows
|
|
try state.update(alloc, &t);
|
|
try testing.expectEqual(.false, state.dirty);
|
|
{
|
|
const row_data = state.row_data.slice();
|
|
const dirty = row_data.items(.dirty);
|
|
for (dirty) |d| try testing.expect(!d);
|
|
}
|
|
|
|
// Write to first line
|
|
s.nextSlice("A");
|
|
try state.update(alloc, &t);
|
|
try testing.expectEqual(.partial, state.dirty);
|
|
{
|
|
const row_data = state.row_data.slice();
|
|
const dirty = row_data.items(.dirty);
|
|
try testing.expect(dirty[0]); // First row dirty
|
|
try testing.expect(!dirty[1]); // Second row clean
|
|
}
|
|
}
|
|
|
|
test "colors" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var t = try Terminal.init(io, alloc, .{
|
|
.cols = 10,
|
|
.rows = 5,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
|
|
// Default colors
|
|
try state.update(alloc, &t);
|
|
|
|
// Change cursor color
|
|
s.nextSlice("\x1b]12;#FF0000\x07");
|
|
try state.update(alloc, &t);
|
|
|
|
const c = state.colors.cursor.?;
|
|
try testing.expectEqual(0xFF, c.r);
|
|
try testing.expectEqual(0, c.g);
|
|
try testing.expectEqual(0, c.b);
|
|
|
|
// Change palette color 0 to White
|
|
s.nextSlice("\x1b]4;0;#FFFFFF\x07");
|
|
try state.update(alloc, &t);
|
|
const p0 = state.colors.palette[0];
|
|
try testing.expectEqual(0xFF, p0.r);
|
|
try testing.expectEqual(0xFF, p0.g);
|
|
try testing.expectEqual(0xFF, p0.b);
|
|
}
|
|
|
|
test "selection single line" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var t: Terminal = try .init(io, alloc, .{
|
|
.cols = 10,
|
|
.rows = 3,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
const screen: *Screen = t.screens.active;
|
|
try screen.select(.init(
|
|
screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
|
|
screen.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?,
|
|
false,
|
|
));
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
|
|
const row_data = state.row_data.slice();
|
|
const sels = row_data.items(.selection);
|
|
try testing.expectEqual(null, sels[0]);
|
|
try testing.expectEqualSlices(size.CellCountInt, &.{ 0, 2 }, &sels[1].?);
|
|
try testing.expectEqual(null, sels[2]);
|
|
|
|
// Clear the selection
|
|
try screen.select(null);
|
|
try state.update(alloc, &t);
|
|
try testing.expectEqual(null, sels[0]);
|
|
try testing.expectEqual(null, sels[1]);
|
|
try testing.expectEqual(null, sels[2]);
|
|
}
|
|
|
|
test "selection multiple lines" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var t: Terminal = try .init(io, alloc, .{
|
|
.cols = 10,
|
|
.rows = 3,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
const screen: *Screen = t.screens.active;
|
|
try screen.select(.init(
|
|
screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
|
|
screen.pages.pin(.{ .active = .{ .x = 2, .y = 2 } }).?,
|
|
false,
|
|
));
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
|
|
const row_data = state.row_data.slice();
|
|
const sels = row_data.items(.selection);
|
|
try testing.expectEqual(null, sels[0]);
|
|
try testing.expectEqualSlices(
|
|
size.CellCountInt,
|
|
&.{ 0, screen.pages.cols - 1 },
|
|
&sels[1].?,
|
|
);
|
|
try testing.expectEqualSlices(
|
|
size.CellCountInt,
|
|
&.{ 0, 2 },
|
|
&sels[2].?,
|
|
);
|
|
}
|
|
|
|
test "linkCells" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var t = try Terminal.init(io, alloc, .{
|
|
.cols = 10,
|
|
.rows = 5,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
|
|
// Create a hyperlink
|
|
s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\");
|
|
try state.update(alloc, &t);
|
|
|
|
// Query link at 0,0
|
|
var cells = try state.linkCells(alloc, .{ .x = 0, .y = 0 });
|
|
defer cells.deinit(alloc);
|
|
|
|
try testing.expectEqual(4, cells.count());
|
|
try testing.expect(cells.contains(.{ .x = 0, .y = 0 }));
|
|
try testing.expect(cells.contains(.{ .x = 1, .y = 0 }));
|
|
try testing.expect(cells.contains(.{ .x = 2, .y = 0 }));
|
|
try testing.expect(cells.contains(.{ .x = 3, .y = 0 }));
|
|
|
|
// Query no link
|
|
var cells2 = try state.linkCells(alloc, .{ .x = 4, .y = 0 });
|
|
defer cells2.deinit(alloc);
|
|
try testing.expectEqual(0, cells2.count());
|
|
}
|
|
|
|
test "string" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var t = try Terminal.init(io, alloc, .{
|
|
.cols = 5,
|
|
.rows = 2,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
s.nextSlice("AB");
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
|
|
var w = std.Io.Writer.Allocating.init(alloc);
|
|
defer w.deinit();
|
|
|
|
try state.string(&w.writer, null);
|
|
|
|
const result = try w.toOwnedSlice();
|
|
defer alloc.free(result);
|
|
|
|
const expected = "AB\x00\x00\x00\n\x00\x00\x00\x00\x00\n";
|
|
try testing.expectEqualStrings(expected, result);
|
|
}
|
|
|
|
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(io, alloc, .{
|
|
.cols = page.std_capacity.cols,
|
|
.rows = viewport_rows,
|
|
.max_scrollback_bytes = 10_000,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
|
|
const pages = &t.screens.active.pages;
|
|
const first_page_cap = pages.pages.first.?.capacity().rows;
|
|
|
|
// Fill first page
|
|
for (0..first_page_cap - 1) |_| s.nextSlice("\r\n");
|
|
|
|
// Create second page with hyperlink
|
|
s.nextSlice("\r\n");
|
|
s.nextSlice("\x1b]8;;http://example.com\x1b\\LINK\x1b]8;;\x1b\\");
|
|
for (0..(tail_rows - 1)) |_| s.nextSlice("\r\n");
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
|
|
const expected_viewport_y: usize = viewport_rows - tail_rows;
|
|
// BUG: This crashes without the fix
|
|
var cells = try state.linkCells(alloc, .{
|
|
.x = 0,
|
|
.y = expected_viewport_y,
|
|
});
|
|
defer cells.deinit(alloc);
|
|
try testing.expectEqual(@as(usize, 4), cells.count());
|
|
}
|
|
|
|
test "linkCells with invalid viewport point" {
|
|
const testing = std.testing;
|
|
const alloc = testing.allocator;
|
|
const io = testing.io;
|
|
|
|
var t = try Terminal.init(io, alloc, .{
|
|
.cols = 10,
|
|
.rows = 5,
|
|
});
|
|
defer t.deinit(alloc);
|
|
|
|
var s = t.vtStream();
|
|
defer s.deinit();
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
|
|
// Row out of bound
|
|
{
|
|
var cells = try state.linkCells(
|
|
alloc,
|
|
.{ .x = 0, .y = t.rows + 10 },
|
|
);
|
|
defer cells.deinit(alloc);
|
|
try testing.expectEqual(0, cells.count());
|
|
}
|
|
|
|
// Col out of bound
|
|
{
|
|
var cells = try state.linkCells(
|
|
alloc,
|
|
.{ .x = t.cols + 10, .y = 0 },
|
|
);
|
|
defer cells.deinit(alloc);
|
|
try testing.expectEqual(0, cells.count());
|
|
}
|
|
}
|
|
|
|
test "flattened highlights require matching page serial" {
|
|
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);
|
|
|
|
// 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;
|
|
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("ABC");
|
|
|
|
var state: RenderState = .empty;
|
|
defer state.deinit(alloc);
|
|
try state.update(alloc, &t);
|
|
|
|
// Reset dirty state
|
|
state.dirty = .false;
|
|
{
|
|
const row_data = state.row_data.slice();
|
|
const dirty = row_data.items(.dirty);
|
|
@memset(dirty, false);
|
|
}
|
|
|
|
// Manually add a highlight to row 0
|
|
{
|
|
const row_data = state.row_data.slice();
|
|
const row_arenas = row_data.items(.arena);
|
|
const row_highlights = row_data.items(.highlights);
|
|
var arena = row_arenas[0].promote(alloc);
|
|
defer row_arenas[0] = arena.state;
|
|
try row_highlights[0].append(arena.allocator(), .{
|
|
.tag = 1,
|
|
.range = .{ 0, 2 },
|
|
});
|
|
}
|
|
|
|
// Verify we have a highlight
|
|
{
|
|
const row_data = state.row_data.slice();
|
|
const row_highlights = row_data.items(.highlights);
|
|
try testing.expectEqual(1, row_highlights[0].items.len);
|
|
}
|
|
|
|
// Write to row 0 to make it dirty
|
|
s.nextSlice("\x1b[H"); // Move to home
|
|
s.nextSlice("X");
|
|
try state.update(alloc, &t);
|
|
|
|
// Verify the highlight was reset on the dirty row
|
|
{
|
|
const row_data = state.row_data.slice();
|
|
const row_highlights = row_data.items(.highlights);
|
|
try testing.expectEqual(0, row_highlights[0].items.len);
|
|
}
|
|
}
|