aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/Terminal.zig
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/Terminal.zig
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/Terminal.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/Terminal.zig1647
1 files changed, 1431 insertions, 216 deletions
diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig
index d41e4750..08920127 100644
--- a/vendor/libghostty-vt/src/terminal/Terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/Terminal.zig
@@ -17,6 +17,7 @@ const modespkg = @import("modes.zig");
const charsets = @import("charsets.zig");
const csi = @import("csi.zig");
const hyperlink = @import("hyperlink.zig");
+const glyph = @import("apc/glyph.zig");
const kitty = @import("kitty.zig");
const osc = @import("osc.zig");
const point = @import("point.zig");
@@ -29,6 +30,7 @@ const Stream = @import("stream_terminal.zig").Stream;
const size = @import("size.zig");
const pagepkg = @import("page.zig");
const style = @import("style.zig");
+const PageList = @import("PageList.zig");
const Screen = @import("Screen.zig");
const ScreenSet = @import("ScreenSet.zig");
const Page = pagepkg.Page;
@@ -81,6 +83,9 @@ modes: modespkg.ModeState = .{},
/// The most recently set mouse shape for the terminal.
mouse_shape: mouse.Shape = .text,
+/// Per-session Glyph Protocol registrations.
+glyph_glossary: glyph.Glossary = .empty,
+
/// These are just a packed set of flags we may set on the terminal.
flags: packed struct {
// This supports a Kitty extension where programs using semantic
@@ -146,6 +151,49 @@ pub const Colors = struct {
};
};
+/// Returns the current color for an xterm OSC color target.
+///
+/// Unsupported dynamic and special colors return null. The cursor color
+/// follows xterm-style reporting and falls back to the foreground color when
+/// no explicit cursor color is set.
+pub fn colorForXterm(self: *const Terminal, target: osc.color.Target) ?color.RGB {
+ return switch (target) {
+ .palette => |i| self.colors.palette.current[i],
+ .dynamic => |dynamic| switch (dynamic) {
+ .foreground => self.colors.foreground.get(),
+ .background => self.colors.background.get(),
+ .cursor => self.colors.cursor.get() orelse
+ self.colors.foreground.get(),
+ .pointer_foreground,
+ .pointer_background,
+ .tektronix_foreground,
+ .tektronix_background,
+ .highlight_background,
+ .tektronix_cursor,
+ .highlight_foreground,
+ => null,
+ },
+ .special => null,
+ };
+}
+
+/// Returns the current color for a Kitty color protocol key.
+///
+/// Only palette, foreground, background, and cursor colors are backed by
+/// Terminal state. Unsupported keys, or supported dynamic colors without a
+/// value, return null.
+pub fn colorForKitty(self: *const Terminal, key: kitty.color.Kind) ?color.RGB {
+ return switch (key) {
+ .palette => |palette| self.colors.palette.current[palette],
+ .special => |special| switch (special) {
+ .foreground => self.colors.foreground.get(),
+ .background => self.colors.background.get(),
+ .cursor => self.colors.cursor.get(),
+ else => null,
+ },
+ };
+}
+
/// This is a set of dirty flags the renderer can use to determine
/// what parts of the screen need to be redrawn. It is up to the renderer
/// to clear these flags.
@@ -165,6 +213,11 @@ pub const Dirty = packed struct {
/// Set when the pre-edit is modified.
preedit: bool = false,
+
+ /// Set when Glyph Protocol registrations may have changed. Registered
+ /// glyphs can affect already-visible PUA cells, so this requires a full
+ /// render-state rebuild.
+ glyph_glossary: bool = false,
};
/// Scrolling region is the area of the screen designated where scrolling
@@ -256,6 +309,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void {
self.screens.deinit(alloc);
self.pwd.deinit(alloc);
self.title.deinit(alloc);
+ self.glyph_glossary.deinit(alloc);
self.* = undefined;
}
@@ -311,6 +365,520 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void {
}
}
+/// Print multiple codepoints to the terminal at once. This is
+/// semantically identical to calling `print` for each codepoint in
+/// order, but is much faster because it can batch cell writes and
+/// hoist per-codepoint checks out of the hot loop.
+///
+/// The codepoints must all be printable: it is illegal for any
+/// codepoint in this slice to be a C0 control character. Therefore,
+/// this should only be called as a result of a proper VT parser
+/// (like our own).
+///
+/// This is optimized for the common case: ASCII, soft-wrap, etc.
+/// Sequences of codepoints that require special handling (e.g. wide characters,
+/// grapheme clustering) are handled correctly but fall back to the
+/// slower per-codepoint path. They're less common and this is optimized
+/// for the aforementioned cases.
+pub fn printSlice(self: *Terminal, cps: []const u32) !void {
+ var i: usize = 0;
+ while (i < cps.len) {
+ // Try the fast-path print first. This will return the number of
+ // codepoints it consumed.
+ const consumed = try self.printSliceFast(cps[i..]);
+ if (consumed > 0) {
+ i += consumed;
+ continue;
+ }
+
+ // Consuming zero bytes means that the fast path can't handle
+ // the next codepoint or the terminal is in a state we can't
+ // fast-path. Fall back to the slow cp-by-cp print then try
+ // fast paths again.
+ try self.print(@intCast(cps[i]));
+ i += 1;
+ }
+}
+
+/// Attempt to print a prefix of `cps` using a batched fast path that
+/// writes cells directly. Returns the number of codepoints consumed.
+/// A return value of zero means the caller must print the first
+/// codepoint via the normal `print` path.
+///
+/// The fast path handles runs of narrow (width 1) and wide (width 2)
+/// codepoints being written to simple cells. Everything else (zero
+/// width codepoints, grapheme cluster continuations, insert mode,
+/// charset mapping, hyperlinks, complex cells, etc.) is rejected so
+/// `print` can handle it with full generality.
+fn printSliceFast(self: *Terminal, cps: []const u32) !usize {
+ // Only the main display is supported.
+ if (self.status_display != .main) return 0;
+
+ // Modes that require per-codepoint handling in print(). Wraparound
+ // is required (its the default) so that our row-fill logic below can
+ // assume soft-wrap semantics. Insert mode shifts cells per print.
+ if (self.modes.get(.insert)) return 0;
+ if (!self.modes.get(.wraparound)) return 0;
+
+ const screen: *Screen = self.screens.active;
+
+ // Charset must map ASCII as-is (true unless a DEC special charset
+ // is actively invoked, which is rare).
+ if (screen.charset.single_shift != null) return 0;
+ switch (screen.charset.charsets.get(screen.charset.gl)) {
+ .utf8, .ascii => {},
+ else => return 0,
+ }
+
+ // Hyperlinks require per-cell map bookkeeping.
+ if (screen.cursor.hyperlink_id != 0) return 0;
+
+ // Codepoints in [0x10, 0xFF] are always narrow (width 1, matching
+ // the c <= 0xFF fast path in print) and can never interact with
+ // grapheme clustering (which requires a codepoint > 0xFF).
+ //
+ // Codepoints above 0xFF are batchable if their width is 1 or 2
+ // (excluding zero-width characters such as combining marks, ZWJ,
+ // and variation selectors) and, when grapheme clustering (mode
+ // 2027) is enabled, if they are a grapheme break from the
+ // previously printed codepoint (so print would never attach them
+ // to the previous cell).
+ const grapheme_cluster = self.modes.get(.grapheme_cluster);
+
+ // When grapheme clustering is enabled and a left margin is set,
+ // print() consults the cell left of the margin after wrapping,
+ // which we can't reason about here. Restrict the fast path to
+ // the [0x10, 0xFF] range in that case (those never cluster).
+ const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0;
+
+ // Codepoints in [0x10, 0xFF] are always narrow: print()
+ // hardcodes width 1 for c <= 0xFF (no width table lookup).
+ // They also can never interact with grapheme clustering,
+ // which print() only performs for c > 0xFF, so they're
+ // immediately eligible for the narrow fill with no further
+ // checks.
+ const cp0 = cps[0];
+ if (cp0 <= 0xFF) {
+ // C0 control characters (0x00-0x0F) aren't printable. The
+ // stream never sends these (they're routed to execute), but
+ // printSlice is a public API so defer to print() for safety.
+ if (cp0 < 0x10) return 0;
+ return self.printSliceFill(
+ .narrow,
+ cps,
+ grapheme_cluster,
+ allow_unicode,
+ );
+ }
+
+ if (!allow_unicode) return 0;
+ if (comptime build_options.kitty_graphics) {
+ // The Kitty graphics placeholder requires row bookkeeping.
+ if (cp0 == kitty.graphics.unicode.placeholder) return 0;
+ }
+
+ // The first codepoint requires care when grapheme clustering is
+ // enabled: print() examines the previous *cell* which can hold
+ // state (grapheme data) that we can't cheaply reason about here.
+ // Note this includes the pending-wrap state: print() may attach
+ // to the pending cell *instead of wrapping*. We only take the
+ // first codepoint if the cursor is at column zero with no pending
+ // wrap, where print() skips clustering entirely.
+ if (grapheme_cluster) {
+ if (screen.cursor.pending_wrap or screen.cursor.x != 0) return 0;
+ }
+
+ // The width lookup is a runtime value while printSliceFill is
+ // specialized at comptime by width class, so this switch selects
+ // between the two instantiations rather than passing the width
+ // through as an argument.
+ return switch (unicode.table.get(@intCast(cp0)).width) {
+ 1 => self.printSliceFill(
+ .narrow,
+ cps,
+ grapheme_cluster,
+ allow_unicode,
+ ),
+ 2 => self.printSliceFill(
+ .wide,
+ cps,
+ grapheme_cluster,
+ allow_unicode,
+ ),
+ else => 0,
+ };
+}
+
+/// The width class of a printSlice batch. Each batch contains only
+/// codepoints of a single width class because they fill cells
+/// differently: wide codepoints occupy a (wide, spacer_tail) cell
+/// pair while narrow codepoints occupy a single cell.
+const PrintSliceWidth = enum(u1) {
+ narrow,
+ wide,
+
+ /// The number of cells each codepoint of this width class occupies.
+ fn cellsPerCp(comptime self: PrintSliceWidth) usize {
+ return switch (self) {
+ .narrow => 1,
+ .wide => 2,
+ };
+ }
+};
+
+/// Whether a codepoint above 0xFF is eligible for the batched print
+/// fast path with the given width class.
+inline fn printSliceEligible(cp: u32, comptime width: PrintSliceWidth) bool {
+ assert(cp > 0xFF);
+ if (comptime build_options.kitty_graphics) {
+ if (cp == kitty.graphics.unicode.placeholder) return false;
+ }
+
+ return unicode.table.get(@intCast(cp)).width == comptime @as(u2, switch (width) {
+ .narrow => 1,
+ .wide => 2,
+ });
+}
+
+/// The row-filling portion of the printSlice fast path, specialized by
+/// width class. The first codepoint must already be validated by the
+/// caller (printSliceFast).
+fn printSliceFill(
+ self: *Terminal,
+ comptime width: PrintSliceWidth,
+ cps: []const u32,
+ grapheme_cluster: bool,
+ allow_unicode: bool,
+) !usize {
+ const screen: *Screen = self.screens.active;
+
+ // Our fast path can only handle "simple" cells. A simple cell is
+ // a codepoint cell (no grapheme data or bg-color tag), narrow, and
+ // not a hyperlink. The mask covers every field that must match
+ // the expected value (see printSliceCheckExpected) exactly.
+ const SimpleMask = pagepkg.Mask(Cell, &.{
+ "content_tag",
+ "style_id",
+ "wide",
+ "hyperlink",
+ }, 4);
+
+ // The bit offset of the codepoint content within a Cell, used to
+ // construct cell values from a template without field assignments.
+ const cp_shift = @bitOffsetOf(Cell, "content");
+
+ // Determine the run of codepoints in the same width class that we
+ // can batch. For codepoints after the first, the previous codepoint
+ // in the run is always written as a fresh, single-codepoint cell,
+ // so the grapheme break check against it is exact.
+ const run_len: usize = run: {
+ var idx: usize = 1;
+
+ // Vectorized scan for the narrow class: codepoints in
+ // [0x10, 0xFF] are always eligible with no further checks
+ // and dominate real-world input, so scan for the first
+ // codepoint outside that range several lanes at a time.
+ // Anything else (including eligible unicode) proceeds via
+ // the scalar loop below.
+ if (comptime width == .narrow) {
+ const lanes = 8;
+ const V = @Vector(lanes, u32);
+ const lo: V = @splat(0x10);
+ const hi: V = @splat(0xFF);
+ while (idx + lanes <= cps.len) {
+ const v: V = cps[idx..][0..lanes].*;
+ const in_range = (v >= lo) & (v <= hi);
+ if (!@reduce(.And, in_range)) {
+ const bits: std.meta.Int(.unsigned, lanes) = @bitCast(in_range);
+ idx += @ctz(~bits);
+ break;
+ }
+ idx += lanes;
+ }
+ }
+
+ while (idx < cps.len) : (idx += 1) {
+ const cp = cps[idx];
+ if (comptime width == .narrow) {
+ if (cp >= 0x10 and cp <= 0xFF) continue;
+ }
+ if (cp > 0xFF and allow_unicode and printSliceEligible(cp, width)) {
+ if (!grapheme_cluster) continue;
+ var state: uucode.grapheme.BreakState = .default;
+ if (unicode.graphemeBreak(@intCast(cps[idx - 1]), @intCast(cp), &state)) continue;
+ }
+ break :run idx;
+ }
+ break :run cps.len;
+ };
+ assert(run_len > 0);
+
+ // After doing any printing, wrapping, scrolling, etc. we want to
+ // ensure that our screen remains in a consistent state.
+ defer screen.assertIntegrity();
+
+ // The number of cells each codepoint occupies.
+ const cells_per_cp: usize = comptime width.cellsPerCp();
+
+ var printed: usize = 0;
+ outer: while (printed < run_len) {
+ // If we're soft-wrapping, handle that first so that our cursor
+ // is in the row/column that will receive the next codepoint.
+ if (screen.cursor.pending_wrap) try self.printWrap();
+
+ // Our right margin depends on where our cursor is now,
+ // matching the logic in print().
+ const right_limit: usize = if (screen.cursor.x > self.scrolling_region.right)
+ self.cols
+ else
+ self.scrolling_region.right + 1;
+
+ // A degenerate 1-wide region can't hold a wide char; print()
+ // has special handling so fall back to it.
+ if (comptime width == .wide) {
+ if (right_limit - self.scrolling_region.left <= 1) break;
+ }
+
+ const cursor = &screen.cursor;
+ const avail: usize = right_limit - cursor.x;
+ assert(avail > 0);
+
+ // The cursor caches live row and cell pointers into this mapping, so
+ // its page cannot be compressed while this print path is active.
+ const page = cursor.page_pin.node.pageAssumeResident();
+ const cells: [*]Cell = @ptrCast(cursor.page_cell);
+ const style_id = cursor.style_id;
+ const template: Cell = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .style_id = style_id,
+ .wide = .narrow,
+ .protected = cursor.protected,
+ .semantic_content = cursor.semantic_content,
+ };
+ const template_bits: u64 = @bitCast(template);
+ const check_expected: u64 = printSliceCheckExpected(style_id);
+
+ if (comptime width == .wide) {
+ if (avail == 1) {
+ // Only one cell left in the row: print() writes a
+ // spacer head (or a blank narrow cell if we're inside
+ // a right margin) and wraps. We require a simple cell,
+ // otherwise fall back to print() for the cleanup.
+ if (!SimpleMask.eqlScalar(cells[0], check_expected)) break;
+
+ var spacer = template;
+ if (right_limit == self.cols) {
+ cursor.page_row.wrap = true;
+ spacer.wide = .spacer_head;
+ }
+ cursor.page_row.dirty = true;
+ if (style_id != style.default_id) cursor.page_row.styled = true;
+ cells[0] = spacer;
+ try self.printWrap();
+ continue :outer;
+ }
+ }
+
+ // Number of codepoints and cells we're writing to this row.
+ const count = @min(avail / cells_per_cp, run_len - printed);
+ assert(count > 0);
+ const cell_count = count * cells_per_cp;
+
+ // Wide cells always come in (wide, spacer_tail) pairs.
+ const spacer_bits: u64 = if (comptime width == .wide) spacer: {
+ var spacer = template;
+ spacer.wide = .spacer_tail;
+ break :spacer @bitCast(spacer);
+ } else undefined;
+ const wide_bits: u64 = if (comptime width == .wide) wb: {
+ var w = template;
+ w.wide = .wide;
+ break :wb @bitCast(w);
+ } else undefined;
+
+ var k: usize = 0; // cells written
+ fill: while (k < cell_count) {
+ // Find the run of simple cells so the store loop below is
+ // branch-free (and vectorizable). This is an early-exit
+ // search loop that LLVM won't auto-vectorize, and reused
+ // rows typically match the whole way through, so scan
+ // several cells at a time manually.
+ var simple = k;
+ simple: {
+ while (simple + SimpleMask.group_len <= cell_count) {
+ const p = SimpleMask.eqlPrefix(
+ cells[0..cell_count],
+ simple,
+ check_expected,
+ );
+ simple += p;
+ if (p != SimpleMask.group_len) break :simple;
+ }
+ while (simple < cell_count) : (simple += 1) {
+ if (!SimpleMask.eqlScalar(
+ cells[simple],
+ check_expected,
+ )) break;
+ }
+ }
+
+ if (comptime width == .wide) {
+ // We can only write whole (wide, spacer) pairs.
+ const pair_end = k + (simple - k) / 2 * 2;
+ var idx = k;
+ while (idx < pair_end) : (idx += 2) {
+ cells[idx] = @bitCast(
+ wide_bits | (@as(u64, cps[printed + idx / 2]) << cp_shift),
+ );
+ cells[idx + 1] = @bitCast(spacer_bits);
+ }
+ // If the simple run ended mid-pair we stop at the pair
+ // boundary and handle the offending cell below.
+ k = pair_end;
+ if (simple != pair_end) {
+ // The first cell of the next pair is simple but the
+ // second isn't; handle both via the general path.
+ simple = pair_end;
+ }
+ } else {
+ for (k..simple) |idx| {
+ cells[idx] = @bitCast(
+ template_bits | (@as(u64, cps[printed + idx]) << cp_shift),
+ );
+ }
+ k = simple;
+ }
+ if (k >= cell_count) break;
+
+ // Bulk path for runs of cells that differ from the
+ // expected simple cell only by their style: this is the
+ // common case when styled text overwrites previously
+ // styled (or default-styled) rows, e.g. TUI redraws.
+ // These runs are handled wholesale: one scan to find the
+ // run of identical old styles, two ref-count updates,
+ // and a branch-free fill.
+ if (comptime width == .narrow) bulk: {
+ const first = SimpleMask.pattern(cells[k]);
+
+ // The old cell must be a plain narrow codepoint cell
+ // with no hyperlink whose only difference is the
+ // style id (see printSliceCheckExpected: every other
+ // masked field must be zero).
+ const style_shift = @bitOffsetOf(Cell, "style_id");
+ const old_style: style.Id = @truncate(first >> style_shift);
+ if (first != printSliceCheckExpected(old_style)) break :bulk;
+ assert(old_style != style_id); // it failed the simple check
+
+ // Find the run of cells with identical masked bits.
+ var m = k + 1;
+ scan: {
+ while (m + SimpleMask.group_len <= cell_count) {
+ const p = SimpleMask.eqlPrefix(
+ cells[0..cell_count],
+ m,
+ first,
+ );
+ m += p;
+ if (p != SimpleMask.group_len) break :scan;
+ }
+ while (m < cell_count) : (m += 1) {
+ if (!SimpleMask.eqlScalar(cells[m], first)) break;
+ }
+ }
+
+ // Fix up the style ref counts for the whole run at
+ // once. Each of the old cells held a reference to
+ // old_style so the release is safe by construction.
+ const n = m - k;
+ if (old_style != style.default_id) {
+ page.styles.releaseMultiple(page.memory, old_style, @intCast(n));
+ }
+ if (style_id != style.default_id) {
+ page.styles.useMultiple(page.memory, style_id, @intCast(n));
+ }
+
+ for (k..m) |idx| {
+ cells[idx] = @bitCast(
+ template_bits | (@as(u64, cps[printed + idx]) << cp_shift),
+ );
+ }
+ k = m;
+ continue :fill;
+ }
+
+ // General path for cells that failed the masked check:
+ // style-only mismatches are handled inline; anything that
+ // needs cleanup (wide chars and their spacers, grapheme
+ // data, hyperlinks) falls back to print().
+ const general_count: usize = cells_per_cp;
+ for (0..general_count) |offset| {
+ const cell = &cells[k + offset];
+ if (cell.wide != .narrow or
+ cell.hasGrapheme() or
+ cell.hyperlink) break :fill;
+ }
+ for (0..general_count) |offset| {
+ const cell = &cells[k + offset];
+ if (cell.style_id != style_id) {
+ if (cell.style_id != style.default_id) {
+ page.styles.release(page.memory, cell.style_id);
+ }
+ if (style_id != style.default_id) {
+ page.styles.use(page.memory, style_id);
+ }
+ }
+ }
+ if (comptime width == .wide) {
+ cells[k] = @bitCast(
+ wide_bits | (@as(u64, cps[printed + k / 2]) << cp_shift),
+ );
+ cells[k + 1] = @bitCast(spacer_bits);
+ } else {
+ cells[k] = @bitCast(
+ template_bits | (@as(u64, cps[printed + k]) << cp_shift),
+ );
+ }
+ k += cells_per_cp;
+ }
+
+ if (k > 0) {
+ assert(k % cells_per_cp == 0);
+ cursor.page_row.dirty = true;
+ if (style_id != style.default_id) cursor.page_row.styled = true;
+ self.previous_char = @intCast(cps[printed + k / cells_per_cp - 1]);
+ printed += k / cells_per_cp;
+
+ // Advance the cursor. If we filled through the right limit
+ // then the cursor stays on the last cell with the pending
+ // wrap flag set, matching print().
+ if (cursor.x + k >= right_limit) {
+ assert(cursor.x + k == right_limit);
+ screen.cursorRight(@intCast(k - 1));
+ cursor.pending_wrap = true;
+ } else {
+ screen.cursorRight(@intCast(k));
+ }
+ }
+
+ // We hit a cell that requires the slow path. The cursor is
+ // exactly at that cell so return and let the caller print the
+ // next codepoint via print().
+ if (k < cell_count) break;
+ }
+
+ return printed;
+}
+
+/// The expected value of a simple cell (per SimpleMask in
+/// printSliceFill) that already has the given style (so no
+/// ref-counting is needed).
+inline fn printSliceCheckExpected(style_id: style.Id) u64 {
+ var e: Cell = @bitCast(@as(u64, 0));
+ e.style_id = style_id;
+ return @bitCast(e);
+}
+
pub fn print(self: *Terminal, c: u21) !void {
// log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x });
@@ -381,10 +949,16 @@ pub fn print(self: *Terminal, c: u21) !void {
const grapheme_break = brk: {
var state: uucode.grapheme.BreakState = .default;
if (prev.cell.hasGrapheme()) {
- const cps = self.screens.active.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?;
+ const cps = self.screens.active.cursor.page_pin.node.page().lookupGrapheme(prev.cell).?;
for (cps) |cp2| {
// log.debug("cp1={x} cp2={x}", .{ previous_codepoint, cp2 });
- assert(!unicode.graphemeBreak(previous_codepoint, cp2, &state));
+ // With mode 2027 disabled, zero-width codepoints are
+ // attached without applying grapheme boundary rules. If
+ // the mode is enabled later, an existing cell can
+ // therefore contain one or more breaks. Feed those breaks
+ // into the state machine so it can reset its context and
+ // determine the boundary for the new codepoint.
+ _ = unicode.graphemeBreak(previous_codepoint, cp2, &state);
previous_codepoint = cp2;
}
}
@@ -396,33 +970,8 @@ pub fn print(self: *Terminal, c: u21) !void {
// If we can NOT break, this means that "c" is part of a grapheme
// with the previous char.
if (!grapheme_break) {
- var desired_wide: enum { no_change, wide, narrow } = .no_change;
-
- // If this is an emoji variation selector then we need to modify
- // the cell width accordingly. VS16 makes the character wide and
- // VS15 makes it narrow.
- if (c == 0xFE0F or c == 0xFE0E) {
- const prev_props = unicode.table.get(previous_codepoint);
- // Check if it is a valid variation sequence in
- // emoji-variation-sequences.txt, and if not, ignore the char.
- if (!prev_props.emoji_vs_base) return;
-
- switch (c) {
- 0xFE0F => desired_wide = .wide,
- 0xFE0E => desired_wide = .narrow,
- else => unreachable,
- }
- } else if (!unicode.table.get(c).width_zero_in_grapheme) {
- // If we have a code point that contributes to the width of a
- // grapheme, it necessarily means that we're at least at width
- // 2, since the first code point must be at least width 1 to
- // start. (Note that Prepend code points could effectively mean
- // the first code point should be width 0, but we don't handle
- // that yet.)
- desired_wide = .wide;
- }
-
- switch (desired_wide) {
+ switch (unicode.graphemeWidthEffect(previous_codepoint, c)) {
+ .ignore => return,
.wide => wide: {
if (prev.cell.wide == .wide) break :wide;
@@ -463,19 +1012,19 @@ pub fn print(self: *Terminal, c: u21) !void {
const old_rac = old_pin.rowAndCell();
if (new_pin.node == old_pin.node) {
- new_pin.node.data.moveGrapheme(prev.cell, new_rac.cell);
+ new_pin.node.page().moveGrapheme(prev.cell, new_rac.cell);
prev.cell.content_tag = .codepoint;
new_rac.cell.content_tag = .codepoint_grapheme;
new_rac.row.grapheme = true;
} else {
- const cps = old_pin.node.data.lookupGrapheme(old_rac.cell).?;
+ const cps = old_pin.node.page().lookupGrapheme(old_rac.cell).?;
for (cps) |cp| {
try self.screens.active.appendGrapheme(new_rac.cell, cp);
}
- old_pin.node.data.clearGrapheme(old_rac.cell);
+ old_pin.node.page().clearGrapheme(old_rac.cell);
}
- old_pin.node.data.updateRowGraphemeFlag(old_rac.row);
+ old_pin.node.page().updateRowGraphemeFlag(old_rac.row);
}
// Point prev.cell to our new previous cell that
@@ -539,7 +1088,7 @@ pub fn print(self: *Terminal, c: u21) !void {
break :narrow;
},
- else => {},
+ .no_change => {},
}
log.debug("c={X} grapheme attach to left={} primary_cp={X}", .{
@@ -742,7 +1291,7 @@ fn printCell(
const spacer_cell = self.screens.active.cursorCellRight(1);
self.screens.active.clearCells(
- &self.screens.active.cursor.page_pin.node.data,
+ self.screens.active.cursor.page_pin.node.page(),
self.screens.active.cursor.page_row,
spacer_cell[0..1],
);
@@ -768,7 +1317,7 @@ fn printCell(
const wide_cell = self.screens.active.cursorCellLeft(1);
self.screens.active.clearCells(
- &self.screens.active.cursor.page_pin.node.data,
+ self.screens.active.cursor.page_pin.node.page(),
self.screens.active.cursor.page_row,
wide_cell[0..1],
);
@@ -791,7 +1340,7 @@ fn printCell(
// If the prior value had graphemes, clear those
if (cell.hasGrapheme()) {
- const page = &self.screens.active.cursor.page_pin.node.data;
+ const page = self.screens.active.cursor.page_pin.node.page();
page.clearGrapheme(cell);
page.updateRowGraphemeFlag(self.screens.active.cursor.page_row);
}
@@ -800,7 +1349,7 @@ fn printCell(
// cell's new style will be different after writing.
const style_changed = cell.style_id != self.screens.active.cursor.style_id;
if (style_changed) {
- var page = &self.screens.active.cursor.page_pin.node.data;
+ var page = self.screens.active.cursor.page_pin.node.page();
// Release the old style.
if (cell.style_id != style.default_id) {
@@ -823,7 +1372,7 @@ fn printCell(
};
if (style_changed) {
- var page = &self.screens.active.cursor.page_pin.node.data;
+ var page = self.screens.active.cursor.page_pin.node.page();
// Use the new style.
if (cell.style_id != style.default_id) {
@@ -852,7 +1401,7 @@ fn printCell(
};
} else if (had_hyperlink) {
// If the previous cell had a hyperlink then we need to clear it.
- var page = &self.screens.active.cursor.page_pin.node.data;
+ var page = self.screens.active.cursor.page_pin.node.page();
page.clearHyperlink(cell);
page.updateRowHyperlinkFlag(self.screens.active.cursor.page_row);
}
@@ -1216,10 +1765,8 @@ pub fn semanticPrompt(
// within a prompt area to SGR mouse events and defers to the
// shell to handle them.
if (cmd.readOption(.click_events)) |v| {
- if (v) {
- screen.semantic_prompt.click = .click_events;
- break :click;
- }
+ screen.semantic_prompt.click = .{ .click_events = v };
+ break :click;
}
// If click_events was not set or disabled, fallback to `cl`.
@@ -1479,10 +2026,19 @@ pub fn index(self: *Terminal) !void {
screen.kitty_images.dirty = true;
}
- // If our scrolling region is at the top, we create scrollback.
+ // If our scrolling region is at the top, we create scrollback,
+ // but only if our screen retains scrollback. If our screen
+ // doesn't retain scrollback (e.g. the alternate screen) then
+ // creating scrollback is pure overhead: the rows are never
+ // visible and are simply pruned later. In that case we use the
+ // in-place region scroll below, unless the region is a single
+ // row (a one row screen) which cursorScrollRegionUp can't
+ // handle (and cursorDownScroll special-cases).
if (self.scrolling_region.top == 0 and
self.scrolling_region.left == 0 and
- self.scrolling_region.right == self.cols - 1)
+ self.scrolling_region.right == self.cols - 1 and
+ (!screen.no_scrollback or
+ self.scrolling_region.bottom == 0))
{
try screen.cursorScrollAbove();
return;
@@ -1490,49 +2046,18 @@ pub fn index(self: *Terminal) !void {
// Slow path for left and right scrolling region margins.
if (self.scrolling_region.left != 0 or
- self.scrolling_region.right != self.cols - 1 or
-
- // PERF(mitchellh): If we have an SGR background set then
- // we need to preserve that background in our erased rows.
- // scrollUp does that but eraseRowBounded below does not.
- // However, scrollUp is WAY slower. We should optimize this
- // case to work in the eraseRowBounded codepath and remove
- // this check.
- !screen.blankCell().isZero())
+ self.scrolling_region.right != self.cols - 1)
{
try self.scrollUp(1);
return;
}
- // Otherwise use a fast path function from PageList to efficiently
- // scroll the contents of the scrolling region.
-
- // Preserve old cursor just for assertions
- const old_cursor = screen.cursor;
-
- try screen.pages.eraseRowBounded(
- .{ .active = .{ .y = self.scrolling_region.top } },
+ // Otherwise use a fast path function to efficiently scroll
+ // the contents of the scrolling region.
+ try screen.cursorScrollRegionUp(
self.scrolling_region.bottom - self.scrolling_region.top,
);
- // eraseRow and eraseRowBounded will end up moving the cursor pin
- // up by 1, so we need to move it back down. A `cursorReload`
- // would be better option but this is more efficient and this is
- // a super hot path so we do this instead.
- assert(screen.cursor.x == old_cursor.x);
- assert(screen.cursor.y == old_cursor.y);
- screen.cursor.y -= 1;
- screen.cursorDown(1);
-
- // The operations above can prune our cursor style so we need to
- // update. This should never fail because the above can only FREE
- // memory.
- screen.manualStyleUpdate() catch |err| {
- std.log.warn("deleteLines manualStyleUpdate err={}", .{err});
- screen.cursor.style = .{};
- screen.manualStyleUpdate() catch unreachable;
- };
-
return;
}
@@ -1601,8 +2126,8 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void {
// Calculate our new x/y
const row = if (row_req == 0) 1 else row_req;
const col = if (col_req == 0) 1 else col_req;
- const x = @min(params.x_max, col + params.x_offset) -| 1;
- const y = @min(params.y_max, row + params.y_offset) -| 1;
+ const x = @min(params.x_max, col +| params.x_offset) -| 1;
+ const y = @min(params.y_max, row +| params.y_offset) -| 1;
// If the y is unchanged then this is fast pointer math
if (y == self.screens.active.cursor.y) {
@@ -1691,9 +2216,17 @@ pub fn scrollUp(self: *Terminal, count: usize) !void {
// If our scroll region is at the top and we have no left/right
// margins then we move the scrolled out text into the scrollback.
+ //
+ // If our screen doesn't retain scrollback (e.g. the alternate
+ // screen) then creating scrollback is pure overhead, so we use the
+ // deleteLines path below instead, unless the region is the full
+ // screen where cursorScrollAbove has a specialized fast path
+ // (cursorDownScroll) for scrolling without scrollback.
if (self.scrolling_region.top == 0 and
self.scrolling_region.left == 0 and
- self.scrolling_region.right == self.cols - 1)
+ self.scrolling_region.right == self.cols - 1 and
+ (!self.screens.active.no_scrollback or
+ self.scrolling_region.bottom == self.rows - 1))
{
// Scrolling dirties the images because it updates their placements pins.
if (comptime build_options.kitty_graphics) {
@@ -1731,10 +2264,18 @@ pub const ScrollViewport = union(Tag) {
/// Scroll by some delta amount, up is negative.
delta: isize,
+ /// Scroll to the given absolute row offset from the top of the
+ /// scrollable area. A value of zero is the top row. The requested
+ /// row becomes the first visible row of the viewport, clamped so
+ /// the viewport never scrolls beyond the top of the active area.
+ /// This is the same row space as PageList.Scrollbar offset.
+ row: usize,
+
pub const Tag = lib.Enum(lib.target, &.{
"top",
"bottom",
"delta",
+ "row",
});
const c_union = lib.TaggedUnion(
@@ -1755,9 +2296,80 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void {
.top => .{ .top = {} },
.bottom => .{ .active = {} },
.delta => |delta| .{ .delta_row = delta },
+ .row => |row| .{ .row = row },
});
}
+/// Return the current compression activity value.
+///
+/// Callers should schedule a `compress` call whenever this value changes. The
+/// direction of the change has no meaning; this is an opaque change token
+/// rather than a monotonic sequence exposed by Terminal.
+///
+/// It is up to the terminal what it decides to compress, but currently
+/// we compress cold (non-viewed, non-editable) scrollback history on
+/// the primary screen.
+///
+/// Note that compression requires specific system features, namely
+/// the ability to retain virtual memory allocations while discarding their
+/// physical memory backings. Callers must still use `compress` to determine
+/// whether compression is supported on the current target.
+pub fn compressionActivity(self: *const Terminal) u64 {
+ const state = &self.screens.get(.primary).?.pages.page_compression;
+ // For now we don't use the extra 16 bits.
+ return @as(u64, state.activity_serial);
+}
+
+/// The amount of compression work performed by `compress` before returning.
+///
+/// The declaration order is part of the libghostty-vt C ABI. Removed values
+/// must leave a `null` hole so later values retain their integer values.
+pub const CompressionMode = lib.Enum(lib.target, &.{
+ "incremental",
+ "full",
+});
+
+/// The scheduling result of a `compress` call.
+///
+/// The declaration order is part of the libghostty-vt C ABI. Removed values
+/// must leave a `null` hole so later values retain their integer values.
+pub const CompressionResult = lib.Enum(lib.target, &.{
+ "unsupported",
+ "pending",
+ "complete",
+});
+
+/// Compress cold memory to save resident memory space.
+///
+/// Full compression does a full pass compressing everything it can before
+/// returning. This is not recommended for interactive terminals because
+/// compression is relatively slow and with large scrollbacks this can cause
+/// stalls.
+///
+/// Incremental compression bounds itself on how much data it can look
+/// up to compress and how much compression work it does before returning.
+/// It is stateful (we maintain the state) and the return value tells callers
+/// whether they should continue calling it in the future.
+///
+/// Callers should schedule compression when it doesn't impact user
+/// experience, for example during idle times.
+pub fn compress(
+ self: *Terminal,
+ mode: CompressionMode,
+) CompressionResult {
+ const pages = &self.screens.get(.primary).?.pages;
+ const result = switch (mode) {
+ .incremental => pages.compress(.incremental),
+ .full => pages.compress(.full),
+ };
+
+ return switch (result) {
+ .unsupported => .unsupported,
+ .pending => .pending,
+ .complete => .complete,
+ };
+}
+
/// To be called before shifting a row (as in insertLines and deleteLines)
///
/// Takes care of boundary conditions such as potentially split wide chars
@@ -1817,6 +2429,21 @@ fn rowWillBeShifted(
}
}
+/// Renew every live page generation in an inclusive range before a full-width
+/// line operation moves logical rows between their coordinates.
+fn invalidateFullWidthRowRange(
+ self: *Terminal,
+ first: *PageList.List.Node,
+ last: *PageList.List.Node,
+) void {
+ var node = first;
+ while (true) : (node = node.next.?) {
+ // Full-width line movement remaps cached row coordinates in this page.
+ self.screens.active.pages.invalidateNodeLayout(node);
+ if (node == last) break;
+ }
+}
+
// TODO(qwerasd): `insertLines` and `deleteLines` are 99% identical,
// the majority of their logic can (and should) be abstracted in to
// a single shared helper function, probably on `Screen` not here.
@@ -1892,6 +2519,12 @@ pub fn insertLines(self: *Terminal, count: usize) void {
};
defer self.screens.active.pages.untrackPin(cur_p);
+ // Partial-width margins edit cells in stable rows; full-width moves rows.
+ if (!left_right) self.invalidateFullWidthRowRange(
+ self.screens.active.cursor.page_pin.node,
+ cur_p.node,
+ );
+
// Our current y position relative to the cursor
var y: usize = rem;
@@ -1906,8 +2539,8 @@ pub fn insertLines(self: *Terminal, count: usize) void {
const off_rac = off_p.rowAndCell();
const off_row: *Row = off_rac.row;
- self.rowWillBeShifted(&cur_p.node.data, cur_row);
- self.rowWillBeShifted(&off_p.node.data, off_row);
+ self.rowWillBeShifted(cur_p.node.page(), cur_row);
+ self.rowWillBeShifted(off_p.node.page(), off_row);
// If our scrolling region is full width, then we unset wrap.
if (!left_right) {
@@ -1925,8 +2558,8 @@ pub fn insertLines(self: *Terminal, count: usize) void {
// If our page doesn't match, then we need to do a copy from
// one page to another. This is the slow path.
if (src_p.node != dst_p.node) {
- dst_p.node.data.clonePartialRowFrom(
- &src_p.node.data,
+ dst_p.node.page().clonePartialRowFrom(
+ src_p.node.page(),
dst_row,
src_row,
self.scrolling_region.left,
@@ -1989,11 +2622,11 @@ pub fn insertLines(self: *Terminal, count: usize) void {
src_row.* = dst;
// Ensure what we did didn't corrupt the page
- cur_p.node.data.assertIntegrity();
+ cur_p.node.page().assertIntegrity();
} else {
// Left/right scroll margins we have to
// copy cells, which is much slower...
- const page = &cur_p.node.data;
+ const page = cur_p.node.page();
page.moveCells(
src_row,
self.scrolling_region.left,
@@ -2005,8 +2638,8 @@ pub fn insertLines(self: *Terminal, count: usize) void {
}
} else {
// Clear the cells for this row, it has been shifted.
- self.rowWillBeShifted(&cur_p.node.data, cur_row);
- const page = &cur_p.node.data;
+ self.rowWillBeShifted(cur_p.node.page(), cur_row);
+ const page = cur_p.node.page();
const cells = page.getCells(cur_row);
self.screens.active.clearCells(
page,
@@ -2087,6 +2720,12 @@ pub fn deleteLines(self: *Terminal, count: usize) void {
};
defer self.screens.active.pages.untrackPin(cur_p);
+ // Partial-width margins edit cells in stable rows; full-width moves rows.
+ if (!left_right) self.invalidateFullWidthRowRange(
+ cur_p.node,
+ cur_p.down(rem - 1).?.node,
+ );
+
// Our current y position relative to the cursor
var y: usize = 0;
@@ -2101,8 +2740,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void {
const off_rac = off_p.rowAndCell();
const off_row: *Row = off_rac.row;
- self.rowWillBeShifted(&cur_p.node.data, cur_row);
- self.rowWillBeShifted(&off_p.node.data, off_row);
+ self.rowWillBeShifted(cur_p.node.page(), cur_row);
+ self.rowWillBeShifted(off_p.node.page(), off_row);
// If our scrolling region is full width, then we unset wrap.
if (!left_right) {
@@ -2120,8 +2759,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void {
// If our page doesn't match, then we need to do a copy from
// one page to another. This is the slow path.
if (src_p.node != dst_p.node) {
- dst_p.node.data.clonePartialRowFrom(
- &src_p.node.data,
+ dst_p.node.page().clonePartialRowFrom(
+ src_p.node.page(),
dst_row,
src_row,
self.scrolling_region.left,
@@ -2177,11 +2816,11 @@ pub fn deleteLines(self: *Terminal, count: usize) void {
src_row.* = dst;
// Ensure what we did didn't corrupt the page
- cur_p.node.data.assertIntegrity();
+ cur_p.node.page().assertIntegrity();
} else {
// Left/right scroll margins we have to
// copy cells, which is much slower...
- const page = &cur_p.node.data;
+ const page = cur_p.node.page();
page.moveCells(
src_row,
self.scrolling_region.left,
@@ -2193,8 +2832,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void {
}
} else {
// Clear the cells for this row, it's from out of bounds.
- self.rowWillBeShifted(&cur_p.node.data, cur_row);
- const page = &cur_p.node.data;
+ self.rowWillBeShifted(cur_p.node.page(), cur_row);
+ const page = cur_p.node.page();
const cells = page.getCells(cur_row);
self.screens.active.clearCells(
page,
@@ -2248,7 +2887,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void {
// left is just the cursor position but as a multi-pointer
const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell);
- var page = &self.screens.active.cursor.page_pin.node.data;
+ var page = self.screens.active.cursor.page_pin.node.page();
// If our X is a wide spacer tail then we need to erase the
// previous cell too so we don't split a multi-cell character.
@@ -2331,7 +2970,7 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void {
// left is just the cursor position but as a multi-pointer
const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell);
- var page = &self.screens.active.cursor.page_pin.node.data;
+ var page = self.screens.active.cursor.page_pin.node.page();
// Remaining cols from our cursor to the right margin.
const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1;
@@ -2408,7 +3047,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
// mode was not ISO we also always ignore protection attributes.
if (self.screens.active.protected_mode != .iso) {
self.screens.active.clearCells(
- &self.screens.active.cursor.page_pin.node.data,
+ self.screens.active.cursor.page_pin.node.page(),
self.screens.active.cursor.page_row,
cells[0..count],
);
@@ -2416,7 +3055,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void {
}
self.screens.active.clearUnprotectedCells(
- &self.screens.active.cursor.page_pin.node.data,
+ self.screens.active.cursor.page_pin.node.page(),
self.screens.active.cursor.page_row,
cells[0..count],
);
@@ -2488,7 +3127,7 @@ pub fn eraseLine(
// to fill the entire line.
if (!protected) {
self.screens.active.clearCells(
- &self.screens.active.cursor.page_pin.node.data,
+ self.screens.active.cursor.page_pin.node.page(),
self.screens.active.cursor.page_row,
cells[start..end],
);
@@ -2496,7 +3135,7 @@ pub fn eraseLine(
}
self.screens.active.clearUnprotectedCells(
- &self.screens.active.cursor.page_pin.node.data,
+ self.screens.active.cursor.page_pin.node.page(),
self.screens.active.cursor.page_row,
cells[start..end],
);
@@ -2670,7 +3309,7 @@ pub fn decaln(self: *Terminal) !void {
// Fill with Es by moving the cursor but reset it after.
while (true) {
- const page = &self.screens.active.cursor.page_pin.node.data;
+ const page = self.screens.active.cursor.page_pin.node.page();
const row = self.screens.active.cursor.page_row;
const cells_multi: [*]Cell = row.cells.ptr(page.memory);
const cells = cells_multi[0..page.size.cols];
@@ -2720,6 +3359,22 @@ pub fn kittyGraphics(
return kitty.graphics.execute(alloc, self, cmd);
}
+/// Execute a Glyph Protocol APC command against this terminal's per-session
+/// glossary. The returned response, if any, should be sent back to the pty as
+/// a complete APC sequence via `Response.formatWire`.
+pub fn glyphProtocol(
+ self: *Terminal,
+ alloc: Allocator,
+ req: *const glyph.Request,
+) ?glyph.Response {
+ const resp = glyph.execute(alloc, &self.glyph_glossary, req);
+ switch (req.*) {
+ .register, .clear => self.flags.dirty.glyph_glossary = true,
+ .support, .query => {},
+ }
+ return resp;
+}
+
/// Set the storage size limit for Kitty graphics across all screens.
pub fn setKittyGraphicsSizeLimit(
self: *Terminal,
@@ -2890,8 +3545,13 @@ pub fn resize(
// Resize our tabstops
if (self.cols != cols) {
+ const tabstops: Tabstops = try .init(
+ alloc,
+ cols,
+ TABSTOP_INTERVAL,
+ );
self.tabstops.deinit(alloc);
- self.tabstops = try .init(alloc, cols, 8);
+ self.tabstops = tabstops;
}
// Resize primary screen, which supports reflow
@@ -2926,13 +3586,33 @@ pub fn resize(
};
}
+test "Terminal: resize preserves tabstops on allocation failure" {
+ var failing = testing.FailingAllocator.init(testing.allocator, .{});
+ const alloc = failing.allocator();
+ var t = try init(alloc, .{ .cols = 10, .rows = 1 });
+ defer t.deinit(alloc);
+
+ failing.fail_index = failing.alloc_index;
+ try testing.expectError(error.OutOfMemory, t.resize(alloc, 513, 1));
+
+ try testing.expectEqual(@as(size.CellCountInt, 10), t.cols);
+ try testing.expect(t.tabstops.get(8));
+}
+
/// Set the pwd for the terminal.
pub fn setPwd(self: *Terminal, pwd: []const u8) !void {
- self.pwd.clearRetainingCapacity();
- if (pwd.len > 0) {
- try self.pwd.appendSlice(self.gpa(), pwd);
- try self.pwd.append(self.gpa(), 0);
+ if (pwd.len == 0) {
+ self.pwd.clearRetainingCapacity();
+ return;
}
+
+ const capacity = std.math.add(usize, pwd.len, 1) catch
+ return error.OutOfMemory;
+ try self.pwd.ensureTotalCapacity(self.gpa(), capacity);
+
+ self.pwd.items.len = capacity;
+ std.mem.copyForwards(u8, self.pwd.items[0..pwd.len], pwd);
+ self.pwd.items[pwd.len] = 0;
}
/// Returns the pwd for the terminal, if any. The memory is owned by the
@@ -2942,13 +3622,41 @@ pub fn getPwd(self: *const Terminal) ?[:0]const u8 {
return self.pwd.items[0 .. self.pwd.items.len - 1 :0];
}
+test "Terminal: setPwd preserves a sentinel on allocation failure" {
+ var failing = testing.FailingAllocator.init(testing.allocator, .{});
+ const alloc = failing.allocator();
+ var t = try init(alloc, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(alloc);
+
+ try t.pwd.ensureTotalCapacityPrecise(alloc, 3);
+ failing.fail_index = failing.alloc_index;
+ try testing.expectError(error.OutOfMemory, t.setPwd("pwd"));
+ try testing.expect(t.getPwd() == null);
+}
+
+test "Terminal: setPwd accepts its current value" {
+ var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(testing.allocator);
+
+ try t.setPwd("file:///tmp");
+ try t.setPwd(t.getPwd().?);
+ try testing.expectEqualStrings("file:///tmp", t.getPwd().?);
+}
+
/// Set the title for the terminal, as set by escape sequences (e.g. OSC 0/2).
pub fn setTitle(self: *Terminal, t: []const u8) !void {
- self.title.clearRetainingCapacity();
- if (t.len > 0) {
- try self.title.appendSlice(self.gpa(), t);
- try self.title.append(self.gpa(), 0);
+ if (t.len == 0) {
+ self.title.clearRetainingCapacity();
+ return;
}
+
+ const capacity = std.math.add(usize, t.len, 1) catch
+ return error.OutOfMemory;
+ try self.title.ensureTotalCapacity(self.gpa(), capacity);
+
+ self.title.items.len = capacity;
+ std.mem.copyForwards(u8, self.title.items[0..t.len], t);
+ self.title.items[t.len] = 0;
}
/// Returns the title for the terminal, if any. The memory is owned by the
@@ -2958,6 +3666,27 @@ pub fn getTitle(self: *const Terminal) ?[:0]const u8 {
return self.title.items[0 .. self.title.items.len - 1 :0];
}
+test "Terminal: setTitle preserves a sentinel on allocation failure" {
+ var failing = testing.FailingAllocator.init(testing.allocator, .{});
+ const alloc = failing.allocator();
+ var t = try init(alloc, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(alloc);
+
+ try t.title.ensureTotalCapacityPrecise(alloc, 5);
+ failing.fail_index = failing.alloc_index;
+ try testing.expectError(error.OutOfMemory, t.setTitle("title"));
+ try testing.expect(t.getTitle() == null);
+}
+
+test "Terminal: setTitle accepts its current value" {
+ var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(testing.allocator);
+
+ try t.setTitle("Ghostty");
+ try t.setTitle(t.getTitle().?);
+ try testing.expectEqualStrings("Ghostty", t.getTitle().?);
+}
+
/// Switch to the given screen type (alternate or primary).
///
/// This does NOT handle behaviors such as clearing the screen,
@@ -3171,6 +3900,7 @@ pub fn fullReset(self: *Terminal) void {
self.previous_char = null;
self.pwd.clearRetainingCapacity();
self.title.clearRetainingCapacity();
+ self.glyph_glossary.clearAndFree(self.gpa());
self.status_display = .main;
self.scrolling_region = .{
.top = 0,
@@ -3193,6 +3923,24 @@ fn clearDirty(t: *Terminal) void {
t.screens.active.pages.clearDirty();
}
+test "Terminal: setCursorPos saturates overflowing origin offsets" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(alloc);
+
+ t.scrolling_region = .{
+ .top = 2,
+ .bottom = 7,
+ .left = 3,
+ .right = 8,
+ };
+ t.modes.set(.origin, true);
+
+ t.setCursorPos(std.math.maxInt(usize), std.math.maxInt(usize));
+ try testing.expectEqual(@as(size.CellCountInt, 8), t.screens.active.cursor.x);
+ try testing.expectEqual(@as(size.CellCountInt, 7), t.screens.active.cursor.y);
+}
+
test "Terminal: input with no control characters" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 40, .rows = 40 });
@@ -3288,19 +4036,19 @@ test "Terminal: input glitch text" {
// Get our initial grapheme capacity.
const grapheme_cap = cap: {
const page = t.screens.active.pages.pages.first.?;
- break :cap page.data.capacity.grapheme_bytes;
+ break :cap page.capacity().grapheme_bytes;
};
// Print glitch text until our capacity changes
while (true) {
const page = t.screens.active.pages.pages.first.?;
- if (page.data.capacity.grapheme_bytes != grapheme_cap) break;
+ if (page.capacity().grapheme_bytes != grapheme_cap) break;
try t.printString(glitch);
}
// We're testing to make sure that grapheme capacity gets increased.
const page = t.screens.active.pages.pages.first.?;
- try testing.expect(page.data.capacity.grapheme_bytes > grapheme_cap);
+ try testing.expect(page.capacity().grapheme_bytes > grapheme_cap);
}
test "Terminal: zero-width character at start" {
@@ -3536,7 +4284,7 @@ test "Terminal: print over wide char with bold" {
try t.print(0x1F600); // Smiley face
// verify we have styles in our style map
{
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
}
@@ -3547,7 +4295,7 @@ test "Terminal: print over wide char with bold" {
// verify our style is gone
{
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 0), page.styles.count());
}
@@ -3566,7 +4314,7 @@ test "Terminal: print over wide char with bg color" {
try t.print(0x1F600); // Smiley face
// verify we have styles in our style map
{
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
}
@@ -3577,7 +4325,7 @@ test "Terminal: print over wide char with bg color" {
// verify our style is gone
{
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 0), page.styles.count());
}
@@ -3608,7 +4356,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" {
try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.wide, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
{
@@ -3617,7 +4365,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" {
try testing.expectEqual(@as(u21, 0), cell.content.codepoint);
try testing.expect(!cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
- try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null);
+ try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null);
}
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?;
@@ -3625,7 +4373,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" {
try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.wide, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
{
@@ -3634,7 +4382,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" {
try testing.expectEqual(@as(u21, 0), cell.content.codepoint);
try testing.expect(!cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
- try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null);
+ try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null);
}
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?;
@@ -3642,7 +4390,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" {
try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint);
try testing.expect(!cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.wide, cell.wide);
- try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null);
+ try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null);
}
{
const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?;
@@ -3650,12 +4398,66 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" {
try testing.expectEqual(@as(u21, 0), cell.content.codepoint);
try testing.expect(!cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide);
- try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null);
+ try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null);
}
try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } }));
}
+test "Terminal: enabling grapheme mode handles stored breaks" {
+ var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(testing.allocator);
+
+ t.modes.set(.grapheme_cluster, false);
+ try t.print('a');
+ try t.print(0x200B); // Zero width space is stored on the prior cell.
+
+ t.modes.set(.grapheme_cluster, true);
+ try t.print(0x0301);
+
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("a\xE2\x80\x8B\xCC\x81", str);
+}
+
+// Terminal.print receives one codepoint at a time, so it can't use
+// unicode.graphemeWidth directly; that API requires a complete buffered
+// cluster or string end. This keeps the streaming printer's cursor advance
+// in sync with the buffered measurement API for representative clusters.
+fn expectGraphemeWidthParity(cps: []const u21) !void {
+ var t = try init(testing.allocator, .{ .cols = 80, .rows = 5 });
+ defer t.deinit(testing.allocator);
+
+ t.modes.set(.grapheme_cluster, true);
+
+ var expected: usize = 0;
+ var i: usize = 0;
+ while (i < cps.len) {
+ const result = unicode.graphemeWidth(u21, cps[i..]);
+ try testing.expect(result.len > 0);
+ i += result.len;
+ expected += result.width;
+ }
+
+ for (cps) |cp| try t.print(cp);
+ try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
+ try testing.expectEqual(expected, t.screens.active.cursor.x);
+}
+
+test "Terminal: graphemeWidth parity" {
+ try expectGraphemeWidthParity(&.{ 0x2764, 0xFE0F });
+ try expectGraphemeWidthParity(&.{ 'x', 0xFE0F, 0xFE0F });
+ try expectGraphemeWidthParity(&.{ 0x231A, 0xFE0E, 0xFE0F });
+ try expectGraphemeWidthParity(&.{ 0x1F3F4, 0x200D, 0x2620, 0xFE0F });
+ try expectGraphemeWidthParity(&.{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 });
+ try expectGraphemeWidthParity(&.{ 0x23, 0xFE0F, 0x20E3 });
+ try expectGraphemeWidthParity(&.{ '1', 0x20E3 });
+ try expectGraphemeWidthParity(&.{ 0x1F44B, 0x1F3FF });
+ try expectGraphemeWidthParity(&.{ 0x1F1E6, 0x1F1E7, 0x1F1E8 });
+ try expectGraphemeWidthParity(&.{ 'a', 'b' });
+ try expectGraphemeWidthParity(&.{ 0x0301, 0x0302 });
+}
+
test "Terminal: VS16 doesn't make character with 2027 disabled" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 });
defer t.deinit(testing.allocator);
@@ -3678,7 +4480,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" {
try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
}
@@ -3759,7 +4561,7 @@ test "Terminal: variation selectors apply to preceding codepoint" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x1F3F4), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.page().lookupGrapheme(cell).?);
}
test "Terminal: print multicodepoint grapheme, mode 2027" {
@@ -3792,7 +4594,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" {
try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.wide, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 4), cps.len);
}
{
@@ -4000,7 +4802,7 @@ test "Terminal: VS15 to make narrow character" {
try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
}
@@ -4035,7 +4837,7 @@ test "Terminal: VS15 on already narrow emoji" {
try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
}
@@ -4094,7 +4896,7 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, '\u{1F469}'), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
@@ -4146,7 +4948,7 @@ test "Terminal: VS15 to make narrow character with pending wrap" {
try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
@@ -4197,7 +4999,7 @@ test "Terminal: VS16 to make wide character on next line" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, '#'), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
@@ -4248,7 +5050,7 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, '#'), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
try testing.expect(cell.hyperlink);
}
@@ -4286,7 +5088,7 @@ test "Terminal: VS16 to make wide character with pending wrap" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, '#'), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
@@ -4325,7 +5127,7 @@ test "Terminal: VS16 to make wide character with mode 2027" {
try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.wide, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
}
@@ -4356,7 +5158,7 @@ test "Terminal: VS16 repeated with mode 2027" {
try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.wide, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
{
@@ -4365,7 +5167,7 @@ test "Terminal: VS16 repeated with mode 2027" {
try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
try testing.expectEqual(Cell.Wide.wide, cell.wide);
- const cps = list_cell.node.data.lookupGrapheme(cell).?;
+ const cps = list_cell.node.page().lookupGrapheme(cell).?;
try testing.expectEqual(@as(usize, 1), cps.len);
}
}
@@ -4458,7 +5260,7 @@ test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
}
}
@@ -4487,7 +5289,7 @@ test "Terminal: print Devanagari grapheme should be wide" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
@@ -4535,7 +5337,7 @@ test "Terminal: print Devanagari grapheme should be wide on next line" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
@@ -4556,7 +5358,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" {
t.cursorDown(rows - 1);
- for (rows..t.screens.active.pages.pages.first.?.data.capacity.rows) |_| {
+ for (rows..t.screens.active.pages.pages.first.?.capacity().rows) |_| {
try t.index();
}
@@ -4594,7 +5396,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.wide, cell.wide);
}
{
@@ -4627,7 +5429,7 @@ test "Terminal: print invalid VS16 with second char (combining)" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 'n'), cell.content.codepoint);
try testing.expect(cell.hasGrapheme());
- try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.data.lookupGrapheme(cell).?);
+ try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
}
{
@@ -4689,7 +5491,7 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" {
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
// We should have one cell with graphemes
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.graphemeCount());
// Move back and overwrite wide
@@ -4729,7 +5531,7 @@ test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" {
try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
// We should have one cell with graphemes
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.graphemeCount());
// Move back and overwrite wide
@@ -4780,7 +5582,7 @@ test "Terminal: print breaks valid grapheme cluster with Prepend + ASCII for spe
try testing.expect(!cell.hasGrapheme());
// This is what we'd expect if we did break correctly:
//try testing.expect(cell.hasGrapheme());
- //try testing.expectEqualSlices(u21, &.{'1'}, list_cell.node.data.lookupGrapheme(cell).?);
+ //try testing.expectEqualSlices(u21, &.{'1'}, list_cell.node.page().lookupGrapheme(cell).?);
try testing.expectEqual(Cell.Wide.narrow, cell.wide);
}
{
@@ -5228,7 +6030,7 @@ test "Terminal: print with hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
}
@@ -5255,7 +6057,7 @@ test "Terminal: print over cell with same hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
}
@@ -5282,7 +6084,7 @@ test "Terminal: print and end hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
}
for (3..6) |x| {
@@ -5317,7 +6119,7 @@ test "Terminal: print and change hyperlink" {
} }).?;
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
}
for (3..6) |x| {
@@ -5327,7 +6129,7 @@ test "Terminal: print and change hyperlink" {
} }).?;
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 2), id);
}
@@ -5351,7 +6153,7 @@ test "Terminal: overwrite hyperlink" {
.x = @intCast(x),
.y = 0,
} }).?;
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
const row = list_cell.row;
try testing.expect(!row.hyperlink);
const cell = list_cell.cell;
@@ -6092,8 +6894,11 @@ test "Terminal: insertLines simple" {
try t.printString("GHI");
t.setCursorPos(2, 2);
+ const node = t.screens.active.cursor.page_pin.node;
+ const serial = node.serial;
t.clearDirty();
t.insertLines(1);
+ try testing.expect(!t.screens.active.pages.nodeIsValid(node, serial));
try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
@@ -6166,7 +6971,7 @@ test "Terminal: insertLines handles style refs" {
try t.setAttribute(.{ .unset = {} });
// verify we have styles in our style map
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
t.setCursorPos(2, 2);
@@ -6250,7 +7055,7 @@ test "Terminal: insertLines across page boundary marks all shifted rows dirty" {
defer t.deinit(alloc);
const first_page = t.screens.active.pages.pages.first.?;
- const first_page_nrows = first_page.data.capacity.rows;
+ const first_page_nrows = first_page.capacity().rows;
// Fill up the first page minus 3 rows
for (0..first_page_nrows - 3) |_| try t.linefeed();
@@ -6271,11 +7076,15 @@ test "Terminal: insertLines across page boundary marks all shifted rows dirty" {
try t.printString("5EEEE");
// Verify we now have a second page
- try testing.expect(first_page.next != null);
+ const second_page = first_page.next.?;
+ const first_serial = first_page.serial;
+ const second_serial = second_page.serial;
t.setCursorPos(1, 1);
t.clearDirty();
t.insertLines(1);
+ try testing.expect(!t.screens.active.pages.nodeIsValid(first_page, first_serial));
+ try testing.expect(!t.screens.active.pages.nodeIsValid(second_page, second_serial));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
@@ -6582,9 +7391,9 @@ test "Terminal: scrollUp moves hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
for (0..3) |x| {
@@ -6596,7 +7405,7 @@ test "Terminal: scrollUp moves hyperlink" {
try testing.expect(!row.hyperlink);
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
}
@@ -6633,7 +7442,7 @@ test "Terminal: scrollUp clears hyperlink" {
try testing.expect(!row.hyperlink);
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
}
@@ -6735,7 +7544,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" {
} }).?;
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
for (1..4) |x| {
@@ -6747,9 +7556,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
for (4..6) |x| {
@@ -6759,7 +7568,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" {
} }).?;
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
}
@@ -6775,9 +7584,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
for (1..4) |x| {
@@ -6787,7 +7596,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" {
} }).?;
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
for (4..6) |x| {
@@ -6799,9 +7608,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
}
@@ -7082,9 +7891,9 @@ test "Terminal: scrollDown hyperlink moves" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
for (0..3) |x| {
@@ -7096,7 +7905,7 @@ test "Terminal: scrollDown hyperlink moves" {
try testing.expect(!row.hyperlink);
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
}
@@ -7206,9 +8015,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
for (1..4) |x| {
@@ -7218,7 +8027,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" {
} }).?;
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
for (4..6) |x| {
@@ -7230,9 +8039,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
}
@@ -7246,7 +8055,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" {
} }).?;
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
for (1..4) |x| {
@@ -7258,9 +8067,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
try testing.expectEqual(1, page.hyperlink_set.count());
}
for (4..6) |x| {
@@ -7270,7 +8079,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" {
} }).?;
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
}
@@ -7504,7 +8313,7 @@ test "Terminal: eraseChars handles refcounted styles" {
try t.print('C');
// verify we have styles in our style map
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
t.setCursorPos(1, 1);
@@ -7581,7 +8390,7 @@ test "Terminal: eraseChars wide char boundary conditions" {
t.setCursorPos(1, 2);
t.eraseChars(3);
- t.screens.active.cursor.page_pin.node.data.assertIntegrity();
+ t.screens.active.cursor.page_pin.node.page().assertIntegrity();
{
const str = try t.plainString(alloc);
@@ -7611,7 +8420,7 @@ test "Terminal: eraseChars wide char splits proper cell boundaries" {
t.setCursorPos(1, 6); // At: て
t.eraseChars(4); // Delete: て下
- t.screens.active.cursor.page_pin.node.data.assertIntegrity();
+ t.screens.active.cursor.page_pin.node.page().assertIntegrity();
{
const str = try t.plainString(alloc);
@@ -7638,7 +8447,7 @@ test "Terminal: eraseChars wide char wrap boundary conditions" {
t.setCursorPos(2, 2);
t.eraseChars(3);
- t.screens.active.cursor.page_pin.node.data.assertIntegrity();
+ t.screens.active.cursor.page_pin.node.page().assertIntegrity();
{
const str = try t.plainString(alloc);
@@ -7941,7 +8750,7 @@ test "Terminal: index scrolling with hyperlink" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
}
{
@@ -7953,7 +8762,7 @@ test "Terminal: index scrolling with hyperlink" {
try testing.expect(!row.hyperlink);
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
}
@@ -8115,7 +8924,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
}
{
@@ -8127,7 +8936,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" {
try testing.expect(!row.hyperlink);
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
}
@@ -8164,9 +8973,9 @@ test "Terminal: index bottom of scroll region clear hyperlinks" {
try testing.expect(!row.hyperlink);
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
- const page = &list_cell.node.data;
+ const page = list_cell.node.page();
try testing.expectEqual(0, page.hyperlink_set.count());
}
}
@@ -8381,6 +9190,128 @@ test "Terminal: index bottom of scroll region blank line preserves SGR" {
}
}
+test "Terminal: index bottom of scroll region with top margin and background SGR" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+
+ try t.printString("1\n2\n3\n4\n5");
+ t.setTopAndBottomMargin(2, 4);
+ t.setCursorPos(4, 1);
+ try t.setAttribute(.{ .direct_color_bg = .{
+ .r = 0xFF,
+ .g = 0,
+ .b = 0,
+ } });
+ try t.index();
+
+ // The region (rows 2-4) scrolled up, rows outside are unchanged.
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1\n3\n4\n\n5", str);
+ }
+
+ // The cursor is on the new blank row.
+ try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.y);
+
+ // The new blank row must be filled with our background color.
+ for (0..t.cols) |x| {
+ const list_cell = t.screens.active.pages.getCell(.{ .active = .{
+ .x = @intCast(x),
+ .y = 3,
+ } }).?;
+ try testing.expect(list_cell.cell.content_tag == .bg_color_rgb);
+ try testing.expectEqual(Cell.RGB{
+ .r = 0xFF,
+ .g = 0,
+ .b = 0,
+ }, list_cell.cell.content.color_rgb);
+ }
+}
+
+test "Terminal: index bottom of alt screen full region" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 3, .cols = 5 });
+ defer t.deinit(alloc);
+
+ try t.switchScreenMode(.@"1049", true);
+ try t.printString("A\nB\nC");
+ try t.index();
+ t.carriageReturn();
+ try t.print('D');
+
+ // Content scrolled up and the scrolled-out row is discarded, NOT
+ // moved into scrollback (the alt screen has none).
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("B\nC\nD", str);
+ }
+ {
+ const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("B\nC\nD", str);
+ }
+
+ // Primary screen is untouched.
+ try t.switchScreenMode(.@"1049", false);
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("", str);
+ }
+}
+
+test "Terminal: index bottom of alt screen top region" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+
+ try t.switchScreenMode(.@"1049", true);
+ try t.printString("1\n2\n3\n4\n5");
+
+ // Region at the top of the screen, excluding the last row. On the
+ // alt screen this must NOT create scrollback.
+ t.setTopAndBottomMargin(1, 4);
+ t.setCursorPos(4, 1);
+ try t.index();
+ try t.print('X');
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("2\n3\n4\nX\n5", str);
+ }
+ {
+ const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("2\n3\n4\nX\n5", str);
+ }
+}
+
+test "Terminal: scrollUp top region no scrollback" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
+ defer t.deinit(alloc);
+
+ try t.printString("A\nB\nC\nD\nE");
+ t.setTopAndBottomMargin(1, 3);
+ try t.scrollUp(1);
+
+ // The region scrolled and the scrolled-out row is discarded.
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("B\nC\n\nD\nE", str);
+ }
+ {
+ const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("B\nC\n\nD\nE", str);
+ }
+}
+
test "Terminal: cursorUp basic" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
@@ -8857,8 +9788,11 @@ test "Terminal: deleteLines simple" {
try t.printString("GHI");
t.setCursorPos(2, 2);
+ const node = t.screens.active.cursor.page_pin.node;
+ const serial = node.serial;
t.clearDirty();
t.deleteLines(1);
+ try testing.expect(!t.screens.active.pages.nodeIsValid(node, serial));
try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
@@ -8919,7 +9853,7 @@ test "Terminal: deleteLines across page boundary marks all shifted rows dirty" {
defer t.deinit(alloc);
const first_page = t.screens.active.pages.pages.first.?;
- const first_page_nrows = first_page.data.capacity.rows;
+ const first_page_nrows = first_page.capacity().rows;
// Fill up the first page minus 3 rows
for (0..first_page_nrows - 3) |_| try t.linefeed();
@@ -8940,11 +9874,15 @@ test "Terminal: deleteLines across page boundary marks all shifted rows dirty" {
try t.printString("5EEEE");
// Verify we now have a second page
- try testing.expect(first_page.next != null);
+ const second_page = first_page.next.?;
+ const first_serial = first_page.serial;
+ const second_serial = second_page.serial;
t.setCursorPos(1, 1);
t.clearDirty();
t.deleteLines(1);
+ try testing.expect(!t.screens.active.pages.nodeIsValid(first_page, first_serial));
+ try testing.expect(!t.screens.active.pages.nodeIsValid(second_page, second_serial));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
@@ -9534,7 +10472,7 @@ test "Terminal: bold style" {
const cell = list_cell.cell;
try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint);
try testing.expect(cell.style_id != 0);
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 1);
}
}
@@ -9558,7 +10496,7 @@ test "Terminal: garbage collect overwritten" {
}
// verify we have no styles in our style map
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 0), page.styles.count());
}
@@ -9580,7 +10518,7 @@ test "Terminal: do not garbage collect old styles in use" {
}
// verify we have no styles in our style map
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
}
@@ -9971,7 +10909,7 @@ test "Terminal: insertBlanks deleting graphemes" {
try t.print(0x1F467);
// We should have one cell with graphemes
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.graphemeCount());
t.setCursorPos(1, 1);
@@ -10007,7 +10945,7 @@ test "Terminal: insertBlanks shift graphemes" {
try t.print(0x1F467);
// We should have one cell with graphemes
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.graphemeCount());
t.setCursorPos(1, 1);
@@ -10075,7 +11013,7 @@ test "Terminal: insertBlanks shifts hyperlinks" {
try testing.expect(row.hyperlink);
const cell = list_cell.cell;
try testing.expect(cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell).?;
+ const id = list_cell.node.page().lookupHyperlink(cell).?;
try testing.expectEqual(@as(hyperlink.Id, 1), id);
}
for (0..2) |x| {
@@ -10085,7 +11023,7 @@ test "Terminal: insertBlanks shifts hyperlinks" {
} }).?;
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
}
@@ -10115,7 +11053,7 @@ test "Terminal: insertBlanks pushes hyperlink off end completely" {
try testing.expect(!row.hyperlink);
const cell = list_cell.cell;
try testing.expect(!cell.hyperlink);
- const id = list_cell.node.data.lookupHyperlink(cell);
+ const id = list_cell.node.page().lookupHyperlink(cell);
try testing.expect(id == null);
}
}
@@ -10688,7 +11626,7 @@ test "Terminal: deleteChars wide char boundary conditions" {
t.setCursorPos(1, 2);
t.deleteChars(3);
- t.screens.active.cursor.page_pin.node.data.assertIntegrity();
+ t.screens.active.cursor.page_pin.node.page().assertIntegrity();
{
const str = try t.plainString(alloc);
@@ -10740,7 +11678,7 @@ test "Terminal: deleteChars wide char wrap boundary conditions" {
t.setCursorPos(2, 2);
t.deleteChars(3);
- t.screens.active.cursor.page_pin.node.data.assertIntegrity();
+ t.screens.active.cursor.page_pin.node.page().assertIntegrity();
{
const str = try t.plainString(alloc);
@@ -10779,7 +11717,7 @@ test "Terminal: deleteChars wide char across right margin" {
t.setCursorPos(1, 2);
t.deleteChars(1);
- t.screens.active.cursor.page_pin.node.data.assertIntegrity();
+ t.screens.active.cursor.page_pin.node.page().assertIntegrity();
// NOTE: This behavior is slightly inconsistent with xterm. xterm
// _visually_ splits the wide character (half the wide character shows
@@ -10940,14 +11878,14 @@ test "Terminal: restoreCursor uses default style on OutOfSpace" {
// Fill the style map to max capacity
const max_styles = std.math.maxInt(size.CellCountInt);
- while (t.screens.active.cursor.page_pin.node.data.capacity.styles < max_styles) {
+ while (t.screens.active.cursor.page_pin.node.capacity().styles < max_styles) {
_ = t.screens.active.increaseCapacity(
t.screens.active.cursor.page_pin.node,
.styles,
) catch break;
}
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(max_styles, page.capacity.styles);
// Fill all style slots using the StyleSet's layout capacity which accounts
@@ -11545,6 +12483,234 @@ test "Terminal: printRepeat no previous character" {
}
}
+test "Terminal: printSlice simple ascii" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 10, .rows = 3 });
+ defer t.deinit(alloc);
+
+ try t.printSlice(&.{ 'h', 'e', 'l', 'l', 'o' });
+ try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x);
+ try testing.expectEqual(@as(u21, 'o'), t.previous_char.?);
+ try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("hello", str);
+ }
+}
+
+test "Terminal: printSlice wraps and scrolls" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 5, .rows = 2 });
+ defer t.deinit(alloc);
+
+ // 12 chars: fills row 1 (5), row 2 (5), wraps+scrolls, 2 more.
+ try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l' });
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("fghij\nkl", str);
+ }
+ try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
+ try testing.expect(!t.screens.active.cursor.pending_wrap);
+}
+
+test "Terminal: printSlice pending wrap state" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 5, .rows = 2 });
+ defer t.deinit(alloc);
+
+ try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e' });
+ try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x);
+ try testing.expect(t.screens.active.cursor.pending_wrap);
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("abcde", str);
+ }
+}
+
+/// Differential testing helper: applies the same logical print
+/// operations to two terminals, one using per-codepoint print() and
+/// the other using printSlice() with random chunking, verifying that
+/// the results are identical.
+fn testPrintSliceDifferential(
+ alloc: Allocator,
+ rand: std.Random,
+ ops: usize,
+ cols: size.CellCountInt,
+ rows: size.CellCountInt,
+) !void {
+ var t1 = try init(alloc, .{
+ .cols = cols,
+ .rows = rows,
+ });
+ defer t1.deinit(alloc);
+ var t2 = try init(alloc, .{
+ .cols = cols,
+ .rows = rows,
+ });
+ defer t2.deinit(alloc);
+
+ // Alphabet of interesting codepoints: ascii, latin-1, combining
+ // marks, CJK (wide), emoji (wide), ZWJ, variation selectors.
+ const alphabet = [_]u21{
+ 'a', 'b', 'Z', '0', ' ', 0x10, 0x1F, 0x7F,
+ 'é', 0xFF, 0x301, 0x4E00, 0x4E01, 0x1F600, 0x200D, 0xFE0F,
+ 'x', 'y', 0x1F9D1, 0x0308, 0xAD, 0x3042, 0xAC00, 'q',
+ 'r', 's', 't', 'u', 'v', 'w', '1', '2',
+ 0x1F1E6, 0x1F1E7, 0x1100, 0x1161, 0x11A8, 0x200C, 0x0430, 0x03B1,
+ };
+
+ var cps_buf: [64]u32 = undefined;
+ var last_n: usize = 0;
+
+ for (0..ops) |_| {
+ switch (rand.intRangeAtMost(u8, 0, 20)) {
+ // Print a run of codepoints (most common op).
+ 0...9 => {
+ const n = rand.intRangeAtMost(usize, 1, cps_buf.len);
+ last_n = n;
+ for (cps_buf[0..n]) |*cp| {
+ cp.* = alphabet[rand.intRangeLessThan(usize, 0, alphabet.len)];
+ }
+
+ // t1: per-codepoint print
+ for (cps_buf[0..n]) |cp| try t1.print(@intCast(cp));
+
+ // t2: printSlice with random chunking
+ var i: usize = 0;
+ while (i < n) {
+ const chunk = rand.intRangeAtMost(usize, 1, n - i);
+ try t2.printSlice(cps_buf[i..][0..chunk]);
+ i += chunk;
+ }
+ },
+ 10 => {
+ t1.carriageReturn();
+ t2.carriageReturn();
+ try t1.linefeed();
+ try t2.linefeed();
+ },
+ 11 => {
+ const row = rand.intRangeAtMost(usize, 1, rows);
+ const col = rand.intRangeAtMost(usize, 1, cols);
+ t1.setCursorPos(row, col);
+ t2.setCursorPos(row, col);
+ },
+ 12 => {
+ const attr: sgr.Attribute = switch (rand.intRangeAtMost(u8, 0, 3)) {
+ 0 => .{ .unset = {} },
+ 1 => .{ .bold = {} },
+ 2 => .{ .direct_color_fg = .{
+ .r = rand.int(u8),
+ .g = rand.int(u8),
+ .b = rand.int(u8),
+ } },
+ 3 => .{ .@"8_fg" = .red },
+ else => unreachable,
+ };
+ try t1.setAttribute(attr);
+ try t2.setAttribute(attr);
+ },
+ 13 => {
+ const v = rand.boolean();
+ t1.modes.set(.insert, v);
+ t2.modes.set(.insert, v);
+ },
+ 14 => {
+ const v = rand.boolean();
+ t1.modes.set(.wraparound, v);
+ t2.modes.set(.wraparound, v);
+ },
+ 15 => {
+ const v = rand.boolean();
+ t1.modes.set(.grapheme_cluster, v);
+ t2.modes.set(.grapheme_cluster, v);
+ },
+ 16 => {
+ // Margins.
+ t1.modes.set(.enable_left_and_right_margin, true);
+ t2.modes.set(.enable_left_and_right_margin, true);
+ const left = rand.intRangeAtMost(usize, 1, cols / 2);
+ const right = rand.intRangeAtMost(usize, cols / 2, cols);
+ t1.setLeftAndRightMargin(left, right);
+ t2.setLeftAndRightMargin(left, right);
+ },
+ 17 => {
+ t1.setLeftAndRightMargin(0, 0);
+ t2.setLeftAndRightMargin(0, 0);
+ },
+ 18 => {
+ try t1.screens.active.startHyperlink("http://example.com", null);
+ try t2.screens.active.startHyperlink("http://example.com", null);
+ },
+ 19 => {
+ t1.screens.active.endHyperlink();
+ t2.screens.active.endHyperlink();
+ },
+ 20 => {
+ const set: charsets.Charset = if (rand.boolean())
+ .dec_special
+ else
+ .utf8;
+ t1.configureCharset(.G0, set);
+ t2.configureCharset(.G0, set);
+ },
+ else => unreachable,
+ }
+
+ // Cursor state must match exactly after every op.
+ try testing.expectEqual(t1.screens.active.cursor.x, t2.screens.active.cursor.x);
+ try testing.expectEqual(t1.screens.active.cursor.y, t2.screens.active.cursor.y);
+ try testing.expectEqual(
+ t1.screens.active.cursor.pending_wrap,
+ t2.screens.active.cursor.pending_wrap,
+ );
+
+ // Full screen contents must match after every op. On failure,
+ // dump diagnostics that make the failure reproducible.
+ {
+ const str1 = try t1.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(str1);
+ const str2 = try t2.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(str2);
+ testing.expectEqualStrings(str1, str2) catch |err| {
+ std.debug.print("last print cps: {any}\n", .{cps_buf[0..last_n]});
+ std.debug.print("modes: 2027={} insert={} wrap={} sr.left={} sr.right={} cols={}\n", .{
+ t1.modes.get(.grapheme_cluster),
+ t1.modes.get(.insert),
+ t1.modes.get(.wraparound),
+ t1.scrolling_region.left,
+ t1.scrolling_region.right,
+ cols,
+ });
+ return err;
+ };
+ }
+ }
+
+ // Page integrity (styles refcounts, grapheme maps, etc.) must hold.
+ try t1.screens.active.cursor.page_pin.node.page().verifyIntegrity(alloc);
+ try t2.screens.active.cursor.page_pin.node.page().verifyIntegrity(alloc);
+}
+
+test "Terminal: printSlice differential fuzz vs print" {
+ const alloc = testing.allocator;
+
+ // Multiple seeds and terminal sizes for coverage, including a
+ // tiny terminal to stress wrap/scroll edge cases.
+ var prng = std.Random.DefaultPrng.init(0xC0FFEE);
+ const rand = prng.random();
+ try testPrintSliceDifferential(alloc, rand, 500, 80, 24);
+ try testPrintSliceDifferential(alloc, rand, 500, 10, 4);
+ try testPrintSliceDifferential(alloc, rand, 500, 5, 2);
+ try testPrintSliceDifferential(alloc, rand, 200, 2, 2);
+}
+
test "Terminal: printAttributes" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
@@ -12394,7 +13560,24 @@ test "Terminal: OSC133A click_events=1 sets click to click_events" {
.options_unvalidated = "click_events=1",
});
- try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click);
+ try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .absolute }, t.screens.active.semantic_prompt.click);
+}
+
+test "Terminal: OSC133A click_events=2 sets click to click_events (relative)" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 10, .rows = 5 });
+ defer t.deinit(alloc);
+
+ // Verify default state is none
+ try testing.expectEqual(.none, t.screens.active.semantic_prompt.click);
+
+ // OSC 133;A with click_events=2
+ try t.semanticPrompt(.{
+ .action = .fresh_line_new_prompt,
+ .options_unvalidated = "click_events=2",
+ });
+
+ try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .relative }, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A click_events=0 does not set click_events" {
@@ -12451,7 +13634,7 @@ test "Terminal: OSC133A click_events=1 takes priority over cl" {
});
// click_events should take priority
- try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click);
+ try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .absolute }, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A click_events=0 falls back to cl" {
@@ -12988,7 +14171,7 @@ test "Terminal: mode 47 copies cursor both directions" {
// Verify that our style is set
{
try testing.expect(t.screens.active.cursor.style_id != style.default_id);
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0);
}
@@ -13003,7 +14186,7 @@ test "Terminal: mode 47 copies cursor both directions" {
// Verify that our style is still set
{
try testing.expect(t.screens.active.cursor.style_id != style.default_id);
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0);
}
@@ -13075,7 +14258,7 @@ test "Terminal: mode 1047 copies cursor both directions" {
// Verify that our style is set
{
try testing.expect(t.screens.active.cursor.style_id != style.default_id);
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0);
}
@@ -13090,7 +14273,7 @@ test "Terminal: mode 1047 copies cursor both directions" {
// Verify that our style is still set
{
try testing.expect(t.screens.active.cursor.style_id != style.default_id);
- const page = &t.screens.active.cursor.page_pin.node.data;
+ const page = t.screens.active.cursor.page_pin.node.page();
try testing.expectEqual(@as(usize, 1), page.styles.count());
try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0);
}
@@ -13183,3 +14366,35 @@ test "Terminal: deleteLines wide char at right margin with full clear" {
// violation in clearCells.
try t.scrollUp(t.rows);
}
+
+test "Terminal: glyph APC stores session glossary entries" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(alloc);
+
+ var register_parser = glyph.CommandParser.init(alloc, 1024 * 1024);
+ defer register_parser.deinit();
+ for ("r;cp=e0a0;AAAAAAAAAAAAAA==") |byte| try register_parser.feed(byte);
+ var register_req = try register_parser.complete(alloc);
+ defer register_req.deinit(alloc);
+
+ try testing.expectEqual(glyph.Response{
+ .register = .{ .cp = 0xE0A0 },
+ }, t.glyphProtocol(alloc, &register_req).?);
+ try testing.expect(t.glyph_glossary.contains(0xE0A0));
+ try testing.expect(t.flags.dirty.glyph_glossary);
+
+ var query_parser = glyph.CommandParser.init(alloc, 1024 * 1024);
+ defer query_parser.deinit();
+ for ("q;cp=e0a0") |byte| try query_parser.feed(byte);
+ var query_req = try query_parser.complete(alloc);
+ defer query_req.deinit(alloc);
+
+ try testing.expectEqual(glyph.Response{ .query = .{
+ .cp = 0xE0A0,
+ .status = .{ .glossary = true },
+ } }, t.glyphProtocol(alloc, &query_req).?);
+
+ t.fullReset();
+ try testing.expect(!t.glyph_glossary.contains(0xE0A0));
+}