diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/Terminal.zig | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (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.zig | 1647 |
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, ®ister_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)); +} |
