diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/Terminal.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/Terminal.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/Terminal.zig | 3630 |
1 files changed, 2868 insertions, 762 deletions
diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig index 08920127..e9cc3b8e 100644 --- a/vendor/libghostty-vt/src/terminal/Terminal.zig +++ b/vendor/libghostty-vt/src/terminal/Terminal.zig @@ -7,8 +7,10 @@ const std = @import("std"); const build_options = @import("terminal_options"); const lib = @import("lib.zig"); const assert = @import("../quirks.zig").inlineAssert; +const tripwire = @import("../tripwire.zig"); const testing = std.testing; const Allocator = std.mem.Allocator; +const simd = @import("../simd/main.zig"); const unicode = @import("../unicode/main.zig"); const uucode = @import("uucode"); @@ -80,12 +82,21 @@ previous_char: ?u21 = null, /// The modes that this terminal currently has active. modes: modespkg.ModeState = .{}, +/// Terminal-level cursor state. +cursor: Cursor = .{}, + /// The most recently set mouse shape for the terminal. mouse_shape: mouse.Shape = .text, /// Per-session Glyph Protocol registrations. glyph_glossary: glyph.Glossary = .empty, +/// Kitty drag and drop protocol (OSC 72) state. Allocated when a client +/// registers to accept drops (t=a) and freed when it unregisters (t=A), +/// so a terminal that never runs a drag and drop aware program pays +/// nothing for it. Non-null means a client currently accepts drops. +kitty_dnd: ?*kitty.dnd.State = null, + /// 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 @@ -112,6 +123,10 @@ flags: packed struct { /// True if the window is focused. focused: bool = true, + /// True if the terminal view may be visible. Unknown visibility is + /// represented as visible so callers behave conservatively. + visible: bool = true, + /// True if the terminal is in a password entry mode. This is set /// to true based on termios state. This is set /// to true based on termios state. @@ -235,16 +250,42 @@ pub const ScrollingRegion = struct { right: size.CellCountInt, }; +/// Terminal-level cursor state shared by all screens. +pub const Cursor = struct { + /// Whether the current cursor appearance follows the configured defaults. + is_default: bool = true, + + /// Configured style restored by DECSCUSR default and RIS. + default_style: Screen.CursorStyle = .block, + + /// Configured blink restored by DECSCUSR default and RIS. Null selects + /// the terminal emulator default, which is blinking. + default_blink: ?bool = false, +}; + pub const Options = struct { cols: size.CellCountInt, rows: size.CellCountInt, - max_scrollback: usize = 10_000, + + /// The maximum size of scrollback in bytes. Null is unlimited and zero + /// disables scrollback. + max_scrollback_bytes: ?usize = 10_000, + + /// The maximum number of physical scrollback rows, excluding the active + /// area. Null is unlimited. The effective limit permits at least one + /// standard page and only complete historical pages are pruned. + max_scrollback_lines: ?usize = null, + colors: Colors = .default, /// The default mode state. When the terminal gets a reset, it /// will revert back to this state. default_modes: modespkg.ModePacked = .{}, + /// Cursor state restored by DECSCUSR default and RIS. + default_cursor_style: Screen.CursorStyle = .block, + default_cursor_blink: ?bool = false, + /// The total storage limit for Kitty images in bytes. Has no effect /// if kitty images are disabled at build-time. kitty_image_storage_limit: usize = switch (build_options.artifact) { @@ -268,22 +309,24 @@ pub const Options = struct { /// Initialize a new terminal. pub fn init( + io_impl: std.Io, alloc: Allocator, opts: Options, ) !Terminal { const cols = opts.cols; const rows = opts.rows; - var screen_set: ScreenSet = try .init(alloc, .{ + var screen_set: ScreenSet = try .init(io_impl, alloc, .{ .cols = cols, .rows = rows, - .max_scrollback = opts.max_scrollback, + .max_scrollback_bytes = opts.max_scrollback_bytes, + .max_scrollback_lines = opts.max_scrollback_lines, .kitty_image_storage_limit = opts.kitty_image_storage_limit, .kitty_image_loading_limits = opts.kitty_image_loading_limits, }); errdefer screen_set.deinit(alloc); - return .{ + var result: Terminal = .{ .cols = cols, .rows = rows, .screens = screen_set, @@ -301,15 +344,23 @@ pub fn init( .values = opts.default_modes, .default = opts.default_modes, }, + .cursor = .{ + .default_style = opts.default_cursor_style, + .default_blink = opts.default_cursor_blink, + }, }; + result.setCursorStyle(.default); + return result; } pub fn deinit(self: *Terminal, alloc: Allocator) void { self.tabstops.deinit(alloc); self.screens.deinit(alloc); + self.colors.palette.deinit(alloc); self.pwd.deinit(alloc); self.title.deinit(alloc); self.glyph_glossary.deinit(alloc); + if (self.kitty_dnd) |dnd| dnd.destroy(alloc); self.* = undefined; } @@ -328,7 +379,10 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void { /// for handling escape sequences split across write boundaries), you /// must store and reuse the returned stream. pub fn vtStream(self: *Terminal) Stream { - return .initAlloc(self.gpa(), self.vtHandler()); + return Stream.init(.{ + .allocator = self.gpa(), + .handler = self.vtHandler(), + }); } /// This is the handler-side only for vtStream. @@ -336,11 +390,96 @@ pub fn vtHandler(self: *Terminal) Stream.Handler { return .init(self); } +/// Change the cursor's current shape and blink behavior. +/// +/// The terminal parser uses this for DECSCUSR (`CSI Ps SP q`), but the behavior +/// is general: `.default` selects the configured defaults, while any other +/// value selects a concrete appearance until it is changed again or reset. +pub fn setCursorStyle(self: *Terminal, value: ansi.CursorStyle) void { + // Remember whether future configuration changes should update the visible + // cursor. An explicit appearance must remain in effect until the program + // selects the default again. + self.cursor.is_default = value == .default; + + // Convert the request into the concrete values used by the renderer and + // terminal mode state. A null default blink means the emulator default. + self.modes.set(.cursor_blinking, switch (value) { + .default => self.cursor.default_blink orelse true, + .steady_block, .steady_bar, .steady_underline => false, + .blinking_block, .blinking_bar, .blinking_underline => true, + }); + self.screens.active.cursor.cursor_style = switch (value) { + .default => self.cursor.default_style, + .blinking_block, .steady_block => .block, + .blinking_bar, .steady_bar => .bar, + .blinking_underline, .steady_underline => .underline, + }; +} + +/// Change the default cursor shape. +/// +/// If the cursor currently follows its defaults, the visible shape changes +/// immediately. Otherwise the new shape is saved for the next reset or default +/// selection, such as DECSCUSR `CSI 0 SP q`. +pub fn setDefaultCursorStyle( + self: *Terminal, + configured_style: Screen.CursorStyle, +) void { + // Always retain the new default, even while an explicit appearance is + // active, so a later reset or default request can restore it. + self.cursor.default_style = configured_style; + + // Do not overwrite an appearance explicitly selected by the program. + if (self.cursor.is_default) self.setCursorStyle(.default); +} + +/// Change the default cursor blink behavior. +/// +/// Null selects the terminal emulator default (blinking). Like the default +/// shape, this is applied immediately only when the cursor currently follows +/// its defaults; otherwise it is saved for the next reset or default selection. +pub fn setDefaultCursorBlink(self: *Terminal, blink: ?bool) void { + // Keep the configured value separate from the currently resolved mode so + // null can continue to mean "use the emulator default." + self.cursor.default_blink = blink; + + // Do not overwrite blink behavior explicitly selected by the program. + if (self.cursor.is_default) self.setCursorStyle(.default); +} + +/// The I/O implementation we should use for this terminal. +pub fn io(self: *Terminal) std.Io { + return self.screens.active.io; +} + /// The general allocator we should use for this terminal. pub fn gpa(self: *Terminal) Allocator { return self.screens.active.alloc; } +/// Change the primary screen's maximum scrollback allocation in bytes. +/// +/// Null removes the byte limit and zero disables scrollback. Disabling +/// scrollback also immediately erases retained history and changes future +/// scrolling to use the no-scrollback path. The alternate screen is +/// intentionally unaffected because it never retains scrollback. +pub fn setScrollbackMaxBytes(self: *Terminal, max: ?usize) void { + const primary = self.screens.get(.primary).?; + primary.pages.setMaxBytes(max); + primary.no_scrollback = max == 0; + + if (primary.no_scrollback) primary.eraseHistory(null); +} + +/// Change the primary screen's maximum number of physical scrollback lines. +/// +/// Null removes the line limit. The alternate screen is intentionally +/// unaffected because it never retains scrollback. +pub fn setScrollbackMaxLines(self: *Terminal, max: ?usize) void { + const primary = self.screens.get(.primary).?; + primary.pages.setMaxLines(max); +} + /// Print UTF-8 encoded string to the terminal. pub fn printString(self: *Terminal, str: []const u8) !void { const view = try std.unicode.Utf8View.init(str); @@ -359,9 +498,26 @@ pub fn printString(self: *Terminal, str: []const u8) !void { /// Print the previous printed character a repeated amount of times. pub fn printRepeat(self: *Terminal, count_req: usize) !void { - if (self.previous_char) |c| { - const count = @max(count_req, 1); - for (0..count) |_| try self.print(c); + const c = self.previous_char orelse return; + var remaining = @max(count_req, 1); + + // Print the repeated codepoint in slices so that eligible runs + // take the batched printSlice fast path. printSlice is semantically + // identical to calling print per codepoint: ineligible characters + // or terminal states (insert mode, grapheme clustering, hyperlinks, + // etc.) fall back to the per-codepoint print() path internally. + // + // The buffer is filled with a runtime-bounded loop rather than + // `= @splat(c)`: a comptime-known 4096-element splat gets fully + // unrolled into ~33KB of consecutive stores (LLVM won't re-roll + // or vectorize it, see quirks_memset.zig), and it would fill the + // whole buffer even for the typical small repeat counts. + var buf: [4096]u32 = undefined; + for (buf[0..@min(remaining, buf.len)]) |*cp| cp.* = c; + while (remaining > 0) { + const n = @min(remaining, buf.len); + try self.printSlice(buf[0..n]); + remaining -= n; } } @@ -381,11 +537,55 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void { /// 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 { + // Check if we can do the fast path up front. If we can't + // we need to go back to scalar `print`. + const fast = fast: { + // Only the main display is supported. + if (self.status_display != .main) break :fast false; + + // 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)) break :fast false; + if (!self.modes.get(.wraparound)) break :fast false; + + // Charset must map ASCII as-is (true unless a DEC special + // charset is actively invoked, which is rare). + const screen: *Screen = self.screens.active; + if (screen.charset.single_shift != null) break :fast false; + switch (screen.charset.charsets.get(screen.charset.gl)) { + .utf8, .ascii => {}, + else => break :fast false, + } + + // Hyperlinks require per-cell map bookkeeping. + if (screen.cursor.hyperlink_id != 0) break :fast false; + + break :fast true; + }; + if (!fast) { + for (cps) |cp| try self.print(@intCast(cp)); + return; + } + + 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; + 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..]); + const consumed = try self.printSliceFast( + cps[i..], + grapheme_cluster, + allow_unicode, + ); if (consumed > 0) { i += consumed; continue; @@ -407,32 +607,16 @@ pub fn printSlice(self: *Terminal, cps: []const u32) !void { /// /// 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; - +/// width codepoints, grapheme cluster continuations, complex cells, +/// etc.) is rejected so `print` can handle it with full generality. +fn printSliceFast( + self: *Terminal, + cps: []const u32, + grapheme_cluster: bool, + allow_unicode: bool, +) !usize { 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). @@ -443,13 +627,6 @@ fn printSliceFast(self: *Terminal, cps: []const u32) !usize { // 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). @@ -478,14 +655,46 @@ fn printSliceFast(self: *Terminal, cps: []const u32) !usize { } // 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; + // enabled: print() may attach it to the previous *cell* instead + // of writing a new one. Take the first codepoint only when we can + // determine — computing exactly what print() would — that it + // starts a new cluster. At column zero with no pending wrap, + // print() skips clustering entirely. Otherwise resolve the + // previous cell the way print() does and check for a break. + // + // Note the pending-wrap rejection: print() may attach to the + // pending cell *instead of wrapping*, which we can't model here. + if (grapheme_cluster and screen.cursor.x != 0) gate: { + if (screen.cursor.pending_wrap) return 0; + + // Resolve the content cell to our left exactly like print(): + // if the immediate left cell is a wide spacer tail, the + // content lives one further left. (A spacer tail can never + // be at column zero — its wide half would have to be in the + // previous row — so the second cursorCellLeft is in bounds.) + const immediate = screen.cursorCellLeft(1); + const prev: *Cell = switch (immediate.wide) { + .spacer_tail => screen.cursorCellLeft(2), + else => immediate, + }; + + // An empty previous cell is necessarily a grapheme break. + if (prev.codepoint() == 0) break :gate; + + // Grapheme data on the previous cell requires the full + // cluster state machine replay; only print() can do that. + if (prev.hasGrapheme()) return 0; + + // A simple single-codepoint previous cell: print() would run + // exactly this break check from the default state. + var state: uucode.grapheme.BreakState = .default; + if (!unicode.graphemeBreak( + prev.content.codepoint.data, + @intCast(cp0), + &state, + )) return 0; + } else if (grapheme_cluster) { + if (screen.cursor.pending_wrap) return 0; } // The width lookup is a runtime value while printSliceFill is @@ -540,6 +749,85 @@ inline fn printSliceEligible(cp: u32, comptime width: PrintSliceWidth) bool { }); } +/// Store a run of narrow codepoint cells built from a bit template: +/// for each `idx` in `[from, to)`, `cells[idx]` is assigned the bits +/// `template_bits | (cps[idx] << cp_shift)`. +/// +/// The template is a complete Cell (content tag, style, wide state, +/// etc. already baked in by the caller) whose codepoint content bits +/// are zero. Since Cell is a packed struct(u64), OR-ing a codepoint +/// into the content field's bit position yields a finished cell as a +/// single integer, keeping the loop pure data movement: no per-cell +/// field assignments and no branches. +/// +/// This loop is manually vectorized: Zig 0.16 (LLVM 21) no longer +/// auto-vectorizes the scalar form the way Zig 0.15 (LLVM 20) did. +inline fn printSliceStoreRun( + cells: [*]Cell, + cps: [*]const u32, + from: usize, + to: usize, + template_bits: u64, +) void { + // The bit position of the `content` field within the packed + // Cell. A codepoint occupies the low bits of `content`, so + // shifting a codepoint left by this amount places it exactly + // where `.content = .{ .codepoint = .{ .data = cp } }` would. + const cp_shift = @bitOffsetOf(Cell, "content"); + + // Since codepoints are OR'd into the content field rather than + // assigned, any nonzero content bits in the template would + // corrupt the stored codepoints. + const content_mask: u64 = comptime mask: { + const bits = @bitSizeOf(@FieldType(Cell, "content")); + break :mask ((1 << bits) - 1) << cp_shift; + }; + assert(template_bits & content_mask == 0); + + var idx = from; + + // Vectorized bulk of the run. + if (simd.lanes(u64)) |lanes| { + // u64 due to backing integer of Cell + const V = @Vector(lanes, u64); + + // The template and shift amount are loop-invariant, so + // broadcast them to every lane once up front. + const template: V = @splat(template_bits); + const shift: @Vector( + lanes, + std.math.Log2Int(u64), + ) = @splat(cp_shift); + + while (idx + lanes <= to) : (idx += lanes) { + // Load `lanes` decoded codepoints... + const narrow: @Vector(lanes, u32) = cps[idx..][0..lanes].*; + + // ...widen each u32 lane to the u64 cell size... + const wide: V = @intCast(narrow); + + // ...shift each codepoint into the content field's bit + // position and merge with the template, producing + // `lanes` finished cells (coerced from vector to array + // so they can be bitcast for the store below)... + const bits: [lanes]u64 = template | (wide << shift); + + // ...and store them contiguously. Cell is a packed + // struct(u64) so an array of u64 bit patterns has + // identical layout to an array of cells. + cells[idx..][0..lanes].* = @bitCast(bits); + } + } + + // Scalar tail: the final `< lanes` cells of the run, or the + // entire run on targets without SIMD. Note `idx` carries over + // from the vector loop above. This is the same computation as + // the vector body, one cell at a time. + while (idx < to) : (idx += 1) { + cells[idx] = @bitCast(template_bits | (@as(u64, cps[idx]) << cp_shift)); + } +} + /// The row-filling portion of the printSlice fast path, specialized by /// width class. The first codepoint must already be validated by the /// caller (printSliceFast). @@ -581,19 +869,20 @@ fn printSliceFill( // 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; + if (simd.lanes(u32)) |lanes| { + 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; } - idx += lanes; } } @@ -650,7 +939,7 @@ fn printSliceFill( const style_id = cursor.style_id; const template: Cell = .{ .content_tag = .codepoint, - .content = .{ .codepoint = 0 }, + .content = .{ .codepoint = .{ .data = 0 } }, .style_id = style_id, .wide = .narrow, .protected = cursor.protected, @@ -727,12 +1016,38 @@ fn printSliceFill( // We can only write whole (wide, spacer) pairs. const pair_end = k + (simple - k) / 2 * 2; var idx = k; + + // Manually vectorized for the same reason as + // printSliceStoreRun: build a vector of wide cells, + // interleave with spacer tails, and store (wide, + // spacer) pairs several at a time. + if (simd.lanes(u64)) |lanes| { + const pair_lanes = lanes / 2; + const Vp = @Vector(pair_lanes, u64); + const wide_v: Vp = @splat(wide_bits); + const spacer_v: Vp = @splat(spacer_bits); + const shift_v: @Vector( + pair_lanes, + std.math.Log2Int(u64), + ) = @splat(cp_shift); + while (idx + 2 * pair_lanes <= pair_end) : (idx += 2 * pair_lanes) { + const narrow: @Vector(pair_lanes, u32) = + cps[printed + idx / 2 ..][0..pair_lanes].*; + const wides: Vp = wide_v | (@as(Vp, @intCast(narrow)) << shift_v); + const inter: [2 * pair_lanes]u64 = std.simd.interlace(.{ + wides, + spacer_v, + }); + cells[idx..][0 .. 2 * pair_lanes].* = @bitCast(inter); + } + } 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; @@ -742,11 +1057,13 @@ fn printSliceFill( simple = pair_end; } } else { - for (k..simple) |idx| { - cells[idx] = @bitCast( - template_bits | (@as(u64, cps[printed + idx]) << cp_shift), - ); - } + printSliceStoreRun( + cells, + cps.ptr + printed, + k, + simple, + template_bits, + ); k = simple; } if (k >= cell_count) break; @@ -798,11 +1115,13 @@ fn printSliceFill( 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), - ); - } + printSliceStoreRun( + cells, + cps.ptr + printed, + k, + m, + template_bits, + ); k = m; continue :fill; } @@ -945,7 +1264,7 @@ pub fn print(self: *Terminal, c: u21) !void { // necessarily a grapheme break. if (prev.cell.codepoint() == 0) break :grapheme; - var previous_codepoint: u21 = prev.cell.content.codepoint; + var previous_codepoint: u21 = prev.cell.content.codepoint.data; const grapheme_break = brk: { var state: uucode.grapheme.BreakState = .default; if (prev.cell.hasGrapheme()) { @@ -992,13 +1311,13 @@ pub fn print(self: *Terminal, c: u21) !void { const row_wrap = right_limit == self.cols; if (row_wrap) self.screens.active.cursor.page_row.wrap = true; - const prev_cp = prev.cell.content.codepoint; + const prev_cp = prev.cell.content.codepoint.data; if (prev.cell.hasGrapheme()) { // This is like printCell but without clearing the // grapheme data from the cell, so we can move it // later. prev.cell.wide = if (row_wrap) .spacer_head else .narrow; - prev.cell.content.codepoint = 0; + prev.cell.content.codepoint.data = 0; try self.printWrap(); self.printCell(prev_cp, .wide); @@ -1012,14 +1331,21 @@ pub fn print(self: *Terminal, c: u21) !void { const old_rac = old_pin.rowAndCell(); if (new_pin.node == old_pin.node) { - new_pin.node.page().moveGrapheme(prev.cell, new_rac.cell); - prev.cell.content_tag = .codepoint; + new_pin.node.page().moveGrapheme(old_rac.cell, new_rac.cell); + old_rac.cell.content_tag = .codepoint; new_rac.cell.content_tag = .codepoint_grapheme; new_rac.row.grapheme = true; } else { const cps = old_pin.node.page().lookupGrapheme(old_rac.cell).?; for (cps) |cp| { - try self.screens.active.appendGrapheme(new_rac.cell, cp); + // appendGrapheme can grow the cursor + // page, so read the destination from + // the cursor each time rather than + // holding a pointer across the call. + try self.screens.active.appendGrapheme( + self.screens.active.cursor.page_cell, + cp, + ); } old_pin.node.page().clearGrapheme(old_rac.cell); } @@ -1029,7 +1355,7 @@ pub fn print(self: *Terminal, c: u21) !void { // Point prev.cell to our new previous cell that // we'll be appending graphemes to - prev.cell = new_rac.cell; + prev.cell = self.screens.active.cursor.page_cell; } else { self.printCell( 0, @@ -1048,8 +1374,30 @@ pub fn print(self: *Terminal, c: u21) !void { // Write our spacer, since prev.cell is now wide self.screens.active.cursorRight(1); + + // Writing the spacer can grow the page to make room for + // the cursor hyperlink. Growing replaces the page, which + // invalidates `prev.cell`. Record the page identity first + // so the common case where nothing grows stays free. + // + // A pointer comparison alone isn't enough: pages are + // pooled, so a replacement can reuse the same address. + // The serial makes the pair a unique identity. + const spacer_node = self.screens.active.cursor.page_pin.node; + const spacer_serial = spacer_node.serial; + self.printCell(0, .spacer_tail); + if (self.screens.active.cursor.page_pin.node != spacer_node or + self.screens.active.cursor.page_pin.node.serial != spacer_serial) + { + @branchHint(.unlikely); + + // The cursor is on the spacer tail we just wrote, so + // the wide cell we append to is the one to its left. + prev.cell = self.screens.active.cursorCellLeft(1); + } + // Move the cursor again so we're beyond our spacer if (self.screens.active.cursor.x == right_limit - 1) { self.screens.active.cursor.pending_wrap = true; @@ -1063,28 +1411,24 @@ pub fn print(self: *Terminal, c: u21) !void { if (prev.cell.wide != .wide) break :narrow; prev.cell.wide = .narrow; - // Remove the wide spacer tail - const cell = self.screens.active.cursorCellLeft(prev.left - 1); - cell.wide = .narrow; - - // Back track the cursor so that we don't end up with - // an extra space after the character. Since xterm is - // not VS aware, it cannot be used as a reference for - // this behavior; but it does follow the principle of - // least surprise, and also matches the behavior that - // can be observed in Kitty, which is one of the only - // other VS aware terminals. - if (self.screens.active.cursor.x == right_limit - 1) { - // If we're already at the right edge, we stay - // here and set the pending wrap to false since - // when we pend a wrap, we only move our cursor once - // even for wide chars (tests verify). - self.screens.active.cursor.pending_wrap = false; - } else { - // Otherwise, move back. - self.screens.active.cursorLeft(1); + // Remove the wide spacer tail. The previous cell may be + // under the cursor, so locate the tail from the wide base + // rather than by subtracting from the cursor distance. + const prev_x = self.screens.active.cursor.x - prev.left; + if (prev_x < self.cols - 1) { + const cells: [*]Cell = @ptrCast(prev.cell); + cells[1].wide = .narrow; } + // Place the cursor one cell after the now-narrow base, + // clamped to the right edge. Usually this moves the cursor + // back from after the old tail, but saved cursor state or + // changed margins can leave it directly on the base. + self.screens.active.cursor.pending_wrap = false; + self.screens.active.cursorHorizontalAbsolute( + @min(prev_x + 1, right_limit - 1), + ); + break :narrow; }, @@ -1151,7 +1495,7 @@ pub fn print(self: *Terminal, c: u21) !void { // If this is a emoji variation selector, prev must be an emoji if (c == 0xFE0F or c == 0xFE0E) { - const prev_props = unicode.table.get(prev.content.codepoint); + const prev_props = unicode.table.get(prev.content.codepoint.data); const emoji = prev_props.grapheme_break == .extended_pictographic; if (!emoji) return; } @@ -1311,7 +1655,7 @@ fn printCell( // So integrity checks pass. We fix this up later so we don't // need to do this without safety checks. - if (comptime std.debug.runtime_safety) { + if (comptime build_options.slow_runtime_safety) { cell.wide = .narrow; } @@ -1364,7 +1708,7 @@ fn printCell( // Write cell.* = .{ .content_tag = .codepoint, - .content = .{ .codepoint = c }, + .content = .{ .codepoint = .{ .data = c } }, .style_id = self.screens.active.cursor.style_id, .wide = wide, .protected = self.screens.active.cursor.protected, @@ -1397,7 +1741,11 @@ fn printCell( self.screens.active.cursorSetHyperlink() catch |err| { @branchHint(.unlikely); log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err}); - assert(!cell.hyperlink); + + // A partially successful grow can replace the page even when the + // call fails, so `cell` may be stale here. The cursor pointers are + // always reloaded, so read the cell through the cursor. + assert(!self.screens.active.cursor.page_cell.hyperlink); }; } else if (had_hyperlink) { // If the previous cell had a hyperlink then we need to clear it. @@ -2040,11 +2388,25 @@ pub fn index(self: *Terminal) !void { (!screen.no_scrollback or self.scrolling_region.bottom == 0)) { + // If a bottom margin is set, kitty image placements may + // need adjusting around the scroll. The rare placements- + // present case is handled out of line so this hot path + // only pays a count check (a load from a cache line we + // already write, above). + if (comptime build_options.kitty_graphics) { + if (screen.kitty_images.placements.count() != 0) { + @branchHint(.unlikely); + try self.indexScrollWithImages(.window_shift); + return; + } + } + try screen.cursorScrollAbove(); return; } // Slow path for left and right scrolling region margins. + // scrollUp handles the kitty image adjustment itself. if (self.scrolling_region.left != 0 or self.scrolling_region.right != self.cols - 1) { @@ -2052,6 +2414,16 @@ pub fn index(self: *Terminal) !void { return; } + // Kitty image placements may need adjusting around the scroll; + // handled out of line like the scrollback path above. + if (comptime build_options.kitty_graphics) { + if (screen.kitty_images.placements.count() != 0) { + @branchHint(.unlikely); + try self.indexScrollWithImages(.in_place); + return; + } + } + // Otherwise use a fast path function to efficiently scroll // the contents of the scrolling region. try screen.cursorScrollRegionUp( @@ -2067,6 +2439,69 @@ pub fn index(self: *Terminal) !void { } } +/// The operation when we have Kitty image placements during index. +/// Split out of index() so its hot paths don't carry the adjustment +/// state in their stack frame, which measurably slows the scroll +/// hot path. +fn indexScrollWithImages( + self: *Terminal, + comptime op: kitty.graphics.ImageStorage.ScrollOp, +) !void { + var kitty_scroll = self.kittyScrollMarginsBegin(-1, op); + defer if (kitty_scroll) |*state| state.end(); + switch (op) { + .window_shift => try self.screens.active.cursorScrollAbove(), + .in_place => try self.screens.active.cursorScrollRegionUp( + self.scrolling_region.bottom - self.scrolling_region.top, + ), + } +} + +// Handle when Kitty graphics is disabled. +const KittyScrollMargins = if (build_options.kitty_graphics) + kitty.graphics.ImageStorage.ScrollMargins +else + struct { + pub inline fn end(self: *@This()) void { + _ = self; + } + }; + +/// Begin adjusting kitty image placements for a scroll of the +/// scrolling region by delta rows (negative moves content up). If +/// adjustment is needed this returns state whose end() must be called +/// after the scroll's row operations complete (see +/// ImageStorage.scrollMarginsBegin for why this is two phases). This +/// returns null when the scrolling region is the full screen, because +/// placements then follow their anchored rows via pin tracking which +/// matches kitty's marginless behavior. +/// +/// Callers must comptime-gate on build_options.kitty_graphics and +/// check that placements exist before calling, which keeps the cost +/// on the hot scroll paths cheap. +fn kittyScrollMarginsBegin( + self: *Terminal, + delta: isize, + op: kitty.graphics.ImageStorage.ScrollOp, +) ?kitty.graphics.ImageStorage.ScrollMargins { + @branchHint(.cold); + + // Full-screen scrolls need no adjustment: placements follow their + // anchored rows (possibly into the scrollback) via pin tracking. + if (self.scrolling_region.top == 0 and + self.scrolling_region.bottom == self.rows - 1 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) return null; + + const screen: *Screen = self.screens.active; + return screen.kitty_images.scrollMarginsBegin( + self.io(), + self, + delta, + op, + ); +} + /// Move the cursor to the previous line in the scrolling region, possibly /// scrolling. /// @@ -2192,6 +2627,24 @@ pub fn scrollDown(self: *Terminal, count: usize) void { self.screens.active.cursor.pending_wrap = old_wrap; } + // If margins are set and kitty image placements exist, they need + // adjusting around the scroll. Note this wraps scrollDown and NOT + // insertLines: kitty scrolls images for SD/RI but leaves them + // alone for IL/DL. + var kitty_scroll: ?KittyScrollMargins = null; + defer if (kitty_scroll) |*state| state.end(); + if (comptime build_options.kitty_graphics) { + if (self.screens.active.kitty_images.placements.count() != 0) { + @branchHint(.unlikely); + const region_height: usize = + @as(usize, self.scrolling_region.bottom - self.scrolling_region.top) + 1; + kitty_scroll = self.kittyScrollMarginsBegin( + @intCast(@min(count, region_height)), + .in_place, + ); + } + } + // Move to the top of the scroll region self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); self.insertLines(count); @@ -2214,6 +2667,35 @@ pub fn scrollUp(self: *Terminal, count: usize) !void { self.screens.active.cursor.pending_wrap = old_wrap; } + // If margins are set and kitty image placements exist, they need + // adjusting around the scroll. Note this wraps scrollUp and NOT + // deleteLines: kitty scrolls images for SU/IND but leaves them + // alone for IL/DL. + var kitty_scroll: ?KittyScrollMargins = null; + defer if (kitty_scroll) |*state| state.end(); + if (comptime build_options.kitty_graphics) { + if (self.screens.active.kitty_images.placements.count() != 0) { + @branchHint(.unlikely); + + // The op must mirror the branch below: the scrollback path + // shifts the active window while the deleteLines path + // moves rows in place. + const region_height: usize = + @as(usize, self.scrolling_region.bottom - self.scrolling_region.top) + 1; + kitty_scroll = self.kittyScrollMarginsBegin( + -@as(isize, @intCast(@min(count, region_height))), + if (self.scrolling_region.top == 0 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1 and + (!self.screens.active.no_scrollback or + self.scrolling_region.bottom == self.rows - 1)) + .window_shift + else + .in_place, + ); + } + } + // 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. // @@ -2283,7 +2765,7 @@ pub const ScrollViewport = union(Tag) { @This(), // Padding: largest variant is isize (8 bytes on 64-bit). // Use [2]u64 (16 bytes) for future expansion. - [2]u64, + .{ .padding = [2]u64 }, ); pub const C = c_union.C; pub const CValue = c_union.CValue; @@ -2412,7 +2894,7 @@ fn rowWillBeShifted( page.clearGrapheme(wide_cell); page.updateRowGraphemeFlag(row); } - wide_cell.content.codepoint = 0; + wide_cell.content.codepoint = .{ .data = 0 }; wide_cell.wide = .narrow; left_cell.wide = .narrow; } @@ -2423,7 +2905,7 @@ fn rowWillBeShifted( page.clearGrapheme(right_cell); page.updateRowGraphemeFlag(row); } - right_cell.content.codepoint = 0; + right_cell.content.codepoint.data = 0; right_cell.wide = .narrow; tail_cell.wide = .narrow; } @@ -2558,60 +3040,19 @@ 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.page().clonePartialRowFrom( + // The copy may replace the destination node in order + // to increase its capacity. Our pins are tracked so + // they update automatically; we can discard the + // replacement because the remainder of this iteration + // only accesses rows through the pins. + _ = self.screens.active.clonePartialRowGrowCapacity( + dst_p.node, + dst_p.y, src_p.node.page(), - dst_row, src_row, self.scrolling_region.left, self.scrolling_region.right + 1, - ) catch |err| { - // Adjust our page capacity to make - // room for we didn't have space for - _ = self.screens.active.increaseCapacity( - dst_p.node, - switch (err) { - // Rehash the sets - error.StyleSetNeedsRehash, - error.HyperlinkSetNeedsRehash, - => null, - - // Increase style memory - error.StyleSetOutOfMemory, - => .styles, - - // Increase string memory - error.StringAllocOutOfMemory, - => .string_bytes, - - // Increase hyperlink memory - error.HyperlinkSetOutOfMemory, - error.HyperlinkMapOutOfMemory, - => .hyperlink_bytes, - - // Increase grapheme memory - error.GraphemeMapOutOfMemory, - error.GraphemeAllocOutOfMemory, - => .grapheme_bytes, - }, - ) catch |e| switch (e) { - // System OOM. We have no way to recover from this - // currently. We should probably change insertLines - // to raise an error here. - error.OutOfMemory, - => @panic("increaseCapacity system allocator OOM"), - - // The page can't accommodate the managed memory required - // for this operation. We previously just corrupted - // memory here so a crash is better. The right long - // term solution is to allocate a new page here - // move this row to the new page, and start over. - error.OutOfSpace, - => @panic("increaseCapacity OutOfSpace"), - }; - - // Continue the loop to try handling this row again. - continue; - }; + ); } else { if (!left_right) { // Swap the src/dst cells. This ensures that our dst gets the @@ -2646,6 +3087,14 @@ pub fn insertLines(self: *Terminal, count: usize) void { cur_row, cells[self.scrolling_region.left .. self.scrolling_region.right + 1], ); + + // With a full-width scroll region the entire row is a + // fresh blank row: reset the metadata so nothing (wrap + // state, semantic prompt) is retained from the row whose + // storage it recycles. With left/right margins the row + // keeps content outside the margins so the metadata is + // preserved, matching the shift case above. + if (!left_right) cur_row.reset(); } // Mark the row as dirty @@ -2759,53 +3208,19 @@ 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.page().clonePartialRowFrom( + // The copy may replace the destination node in order + // to increase its capacity. Our pins are tracked so + // they update automatically; we can discard the + // replacement because the remainder of this iteration + // only accesses rows through the pins. + _ = self.screens.active.clonePartialRowGrowCapacity( + dst_p.node, + dst_p.y, src_p.node.page(), - dst_row, src_row, self.scrolling_region.left, self.scrolling_region.right + 1, - ) catch |err| { - // Adjust our page capacity to make - // room for we didn't have space for - _ = self.screens.active.increaseCapacity( - dst_p.node, - switch (err) { - // Rehash the sets - error.StyleSetNeedsRehash, - error.HyperlinkSetNeedsRehash, - => null, - - // Increase style memory - error.StyleSetOutOfMemory, - => .styles, - - // Increase string memory - error.StringAllocOutOfMemory, - => .string_bytes, - - // Increase hyperlink memory - error.HyperlinkSetOutOfMemory, - error.HyperlinkMapOutOfMemory, - => .hyperlink_bytes, - - // Increase grapheme memory - error.GraphemeMapOutOfMemory, - error.GraphemeAllocOutOfMemory, - => .grapheme_bytes, - }, - ) catch |e| switch (e) { - // See insertLines - error.OutOfMemory, - => @panic("increaseCapacity system allocator OOM"), - - error.OutOfSpace, - => @panic("increaseCapacity OutOfSpace"), - }; - - // Continue the loop to try handling this row again. - continue; - }; + ); } else { if (!left_right) { // Swap the src/dst cells. This ensures that our dst gets the @@ -2840,6 +3255,14 @@ pub fn deleteLines(self: *Terminal, count: usize) void { cur_row, cells[self.scrolling_region.left .. self.scrolling_region.right + 1], ); + + // With a full-width scroll region the entire row is a + // fresh blank row: reset the metadata so nothing (wrap + // state, semantic prompt) is retained from the row whose + // storage it recycles. With left/right margins the row + // keeps content outside the margins so the metadata is + // preserved, matching the shift case above. + if (!left_right) cur_row.reset(); } // Mark the row as dirty @@ -3095,9 +3518,14 @@ pub fn eraseLine( break :left .{ 0, x + 1 }; }, - // Note that it seems like complete should reset the soft-wrap - // state of the line but in xterm it does not. - .complete => .{ 0, self.cols }, + .complete => complete: { + // Xterm preserves this flag for EL2, but it also doesn't reflow + // rows when resizing. Since we do, the erased row must no longer + // continue onto the next row. + self.screens.active.cursorResetWrap(); + + break :complete .{ 0, self.cols }; + }, else => { log.err("unimplemented erase line mode: {}", .{mode}); @@ -3164,11 +3592,12 @@ pub fn eraseDisplay( self.screens.active.cursor.pending_wrap = false; if (comptime build_options.kitty_graphics) { - // Clear all Kitty graphics state for this screen - self.screens.active.kitty_images.delete( + // Clear only placements still visible after moving the active + // area into scrollback. + self.screens.active.kitty_images.clearScreen( + self.io(), self.screens.active.alloc, self, - .{ .all = true }, ); } }, @@ -3221,11 +3650,12 @@ pub fn eraseDisplay( self.screens.active.cursor.pending_wrap = false; if (comptime build_options.kitty_graphics) { - // Clear all Kitty graphics state for this screen - self.screens.active.kitty_images.delete( + // ED2 clears visible placements but preserves graphics that + // are wholly in scrollback. + self.screens.active.kitty_images.clearScreen( + self.io(), self.screens.active.alloc, self, - .{ .all = true }, ); } @@ -3315,7 +3745,7 @@ pub fn decaln(self: *Terminal) !void { const cells = cells_multi[0..page.size.cols]; @memset(cells, .{ .content_tag = .codepoint, - .content = .{ .codepoint = 'E' }, + .content = .{ .codepoint = .{ .data = 'E' } }, .style_id = self.screens.active.cursor.style_id, // DECALN does not respect protected state. Verified with xterm. @@ -3353,10 +3783,11 @@ pub fn decaln(self: *Terminal) !void { /// undefined. pub fn kittyGraphics( self: *Terminal, + io_impl: std.Io, alloc: Allocator, cmd: *kitty.graphics.Command, ) ?kitty.graphics.Response { - return kitty.graphics.execute(alloc, self, cmd); + return kitty.graphics.execute(io_impl, alloc, self, cmd); } /// Execute a Glyph Protocol APC command against this terminal's per-session @@ -3380,12 +3811,12 @@ pub fn setKittyGraphicsSizeLimit( self: *Terminal, alloc: Allocator, limit: usize, -) !void { +) void { if (comptime !build_options.kitty_graphics) return; var it = self.screens.all.iterator(); while (it.next()) |entry| { const screen: *Screen = entry.value.*; - try screen.kitty_images.setLimit(alloc, screen, limit); + screen.kitty_images.setLimit(self.io(), alloc, screen, limit); } } @@ -3414,58 +3845,69 @@ pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { /// Boolean attributes are printed first, followed by foreground color, then /// background color. Each attribute is separated by a semicolon. pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { - var stream = std.io.fixedBufferStream(buf); - const writer = stream.writer(); + var writer: std.Io.Writer = .fixed(buf); // The SGR response always starts with a 0. See https://vt100.net/docs/vt510-rm/DECRPSS try writer.writeByte('0'); const pen = self.screens.active.cursor.style; - var attrs: [8]u8 = @splat(0); + var attrs: [9]u8 = @splat(0); var i: usize = 0; if (pen.flags.bold) { - attrs[i] = '1'; + attrs[i] = 1; i += 1; } if (pen.flags.faint) { - attrs[i] = '2'; + attrs[i] = 2; i += 1; } if (pen.flags.italic) { - attrs[i] = '3'; + attrs[i] = 3; i += 1; } if (pen.flags.underline != .none) { - attrs[i] = '4'; + attrs[i] = 4; + i += 1; + } + + if (pen.flags.overline) { + attrs[i] = 53; i += 1; } if (pen.flags.blink) { - attrs[i] = '5'; + attrs[i] = 5; i += 1; } if (pen.flags.inverse) { - attrs[i] = '7'; + attrs[i] = 7; i += 1; } if (pen.flags.invisible) { - attrs[i] = '8'; + attrs[i] = 8; i += 1; } if (pen.flags.strikethrough) { - attrs[i] = '9'; + attrs[i] = 9; i += 1; } - for (attrs[0..i]) |c| { - try writer.print(";{c}", .{c}); + for (attrs[0..i]) |attr| { + // Preserve underline styles. Kind of a hack to special case 4 + // here but its easier than changing how we do all attributes. + if (attr == 4 and pen.flags.underline != .single) { + try writer.print(";4:{}", .{@intFromEnum(pen.flags.underline)}); + continue; + } + + try writer.print(";{}", .{attr}); } switch (pen.fg_color) { @@ -3490,7 +3932,7 @@ pub fn printAttributes(self: *Terminal, buf: []u8) ![]const u8 { .rgb => |rgb| try writer.print(";48:2::{[r]}:{[g]}:{[b]}", rgb), } - return stream.getWritten(); + return writer.buffered(); } /// The modes for DECCOLM. @@ -3519,86 +3961,666 @@ pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { self.modes.set(.@"132_column", mode == .@"132_cols"); // Resize to the requested size - try self.resize( - alloc, - switch (mode) { + try self.resize(alloc, .{ + .cols = switch (mode) { .@"132_cols" => 132, .@"80_cols" => 80, }, - self.rows, - ); + .rows = self.rows, + }); // Erase our display and move our cursor. self.eraseDisplay(.complete, false); self.setCursorPos(1, 1); } +/// A terminal resize expressed in cells with optional per-cell pixel +/// geometry. It is highly recommended that callers supply cell geometry +/// but a terminal can technically function without it (but some reports +/// like certain mouse reporting modes and Kitty image protocol will +/// not be functional). +/// +/// Cell pixel dimensions must already be scaled for the current display DPI. +pub const Resize = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + cell_size_px: ?struct { + width: u32, + height: u32, + } = null, +}; + +pub const ResizeError = error{ + /// Resize requires allocation + OutOfMemory, + + /// Input value was invalid, such as a 0-sized dimension. + InvalidValue, +}; + +const resize_tw = tripwire.module(enum { + tabstops, + primary_screen, + alternate_screen, + alternate_screen_init, +}, resize); + /// Resize the underlying terminal. +/// +/// This has follow-on impacts: +/// +/// - If the column count changes, tabstops are reset. +/// - The scroll region is always reset +/// - Synchronized output mode is reset for every successful resize. +/// +/// This handles errors gracefully and recovers the terminal back to +/// a clean usable state. +/// +/// The only error handling edge case is in the highly exceptional scenario +/// where the primary screen can be resized but the alternate screen cannot. +/// In this scenario, we attempt to clear the alt screen at the desired +/// new size. If that fails, we unconditionally deallocate the alt screen +/// and move to primary screen. This can break terminal programs but it +/// requires a really particular scenario where memory exists for one +/// but not the other and we do our best. pub fn resize( self: *Terminal, alloc: Allocator, - cols: size.CellCountInt, - rows: size.CellCountInt, -) !void { - // If our cols/rows didn't change then we're done - if (self.cols == cols and self.rows == rows) return; - - // Resize our tabstops - if (self.cols != cols) { - const tabstops: Tabstops = try .init( + opts: Resize, +) ResizeError!void { + const tw = resize_tw; + + // Screen and scrolling-region invariants require non-zero dimensions. + // Validate before changing any terminal state. + if (opts.cols == 0 or opts.rows == 0) return error.InvalidValue; + + // Pixel geometry and synchronized output are updated on every valid + // resize attempt, including one that doesn't change the grid dimensions. + // Save their old values so later allocation failures can roll them back. + const old_width_px = self.width_px; + const old_height_px = self.height_px; + const old_synchronized_output = self.modes.get(.synchronized_output); + errdefer { + self.width_px = old_width_px; + self.height_px = old_height_px; + self.modes.set(.synchronized_output, old_synchronized_output); + } + + // If our pixel geometry was set, then we set it even if our rows/cols + // didn't change. + if (opts.cell_size_px) |cell_size| { + self.width_px = std.math.mul( + u32, + opts.cols, + cell_size.width, + ) catch std.math.maxInt(u32); + self.height_px = std.math.mul( + u32, + opts.rows, + cell_size.height, + ) catch std.math.maxInt(u32); + } + + self.modes.set(.synchronized_output, false); + + // If our cols/rows didn't change, skip grid work but still apply pixels. + if (self.cols == opts.cols and self.rows == opts.rows) return; + + // Build replacement tabstops without touching the current table. Keep + // ownership here until every fallible resize operation has succeeded. + var new_tabstops: ?Tabstops = null; + errdefer if (new_tabstops) |*v| v.deinit(alloc); + if (self.cols != opts.cols) { + try tw.check(.tabstops); + new_tabstops = try .init( alloc, - cols, + opts.cols, TABSTOP_INTERVAL, ); - self.tabstops.deinit(alloc); - self.tabstops = tabstops; } - // Resize primary screen, which supports reflow + // Resize primary screen, which supports reflow. We do this first + // because the cleanup situation is a lot better if this succeeds + // and alt fails than the reverse. + try tw.check(.primary_screen); const primary = self.screens.get(.primary).?; try primary.resize(.{ - .cols = cols, - .rows = rows, + .cols = opts.cols, + .rows = opts.rows, .reflow = self.modes.get(.wraparound), .prompt_redraw = self.flags.shell_redraws_prompt, }); - // Alternate screen, if it exists, doesn't reflow - if (self.screens.get(.alternate)) |alt| try alt.resize(.{ - .cols = cols, - .rows = rows, - .reflow = false, - }); + // Alternate screen, if it exists, doesn't reflow. The primary resize + // above can't be losslessly undone, so if the alternate resize fails we + // replace it with an empty screen at the requested size. If that + // also fails, we fall back to the primary screen. + if (self.screens.get(.alternate)) |alt| alt: { + const err: ResizeError = resize: { + tw.check(.alternate_screen) catch |err| break :resize err; + alt.resize(.{ + .cols = opts.cols, + .rows = opts.rows, + .reflow = false, + }) catch |err| break :resize err; + + // Resize succeeded. + break :alt; + }; + + log.warn("alternate screen resize failed, replacing it err={}", .{err}); + + // If the alternate screen isn't active, then we just free it + // and move on. It'll be reallocated when it gets reinitialized lazily. + // In this case, we just lose the prior data if the terminal program + // expected it to be saved. + if (self.screens.active_key != .alternate) { + self.screens.remove(alloc, .alternate); + break :alt; + } + + // The alt screen is active, so we temporarily switch to primary + // so we can safely remove the alt and recreate it blank. This loses + // the data but hopefully keeps us on the alt screen. + const charset = alt.charset; + self.screens.switchTo(.primary); + self.screens.remove(alloc, .alternate); + + // Replace the alt screen with an empty version. If this fails + // we just go back to the primary screen. Not great, but best + // we can do. + tw.check(.alternate_screen_init) catch break :alt; + const replacement = self.screens.getInit( + self.io(), + alloc, + .alternate, + .{ + .cols = opts.cols, + .rows = opts.rows, + .max_scrollback_bytes = 0, + .kitty_image_storage_limit = if (comptime build_options.kitty_graphics) + primary.kitty_images.total_limit + else + 0, + .kitty_image_loading_limits = if (comptime build_options.kitty_graphics) + primary.kitty_images.image_limits + else {}, + }, + ) catch |init_err| { + log.warn( + "alternate screen replacement failed, falling back to primary err={}", + .{init_err}, + ); + break :alt; + }; + + replacement.charset = charset; + self.screens.switchTo(.alternate); + } + + // No more failures are allowed after this point because the screens have + // committed their new sizes and the remaining Terminal state must follow. + errdefer comptime unreachable; + + // All fallible work is complete. Replace the old tabstop table only now. + if (new_tabstops) |v| { + self.tabstops.deinit(alloc); + self.tabstops = v; + new_tabstops = null; + } // Whenever we resize we just mark it as a screen clear self.flags.dirty.clear = true; // Set our size - self.cols = cols; - self.rows = rows; + self.cols = opts.cols; + self.rows = opts.rows; // Reset the scrolling region self.scrolling_region = .{ .top = 0, - .bottom = rows - 1, + .bottom = opts.rows - 1, .left = 0, - .right = cols - 1, + .right = opts.cols - 1, }; } +test "Terminal forwards optional scrollback limits" { + const max_lines: usize = 123; + var t = try init(testing.io, testing.allocator, .{ + .cols = 80, + .rows = 24, + .max_scrollback_bytes = null, + .max_scrollback_lines = max_lines, + }); + defer t.deinit(testing.allocator); + + try testing.expectEqual( + std.math.maxInt(usize), + t.screens.active.pages.limits.bytes.explicit, + ); + try testing.expectEqual( + max_lines, + t.screens.active.pages.limits.lines.explicit, + ); +} + +test "Terminal setScrollbackMaxBytes" { + var t = try init(testing.io, testing.allocator, .{ + .cols = 80, + .rows = 3, + .max_scrollback_bytes = null, + }); + defer t.deinit(testing.allocator); + + const primary = t.screens.get(.primary).?; + const page_rows: usize = primary.pages.pages.first.?.capacity().rows; + + // Build several complete pages of history so lowering the byte limit has + // existing allocations to prune immediately. + for (0..4 * page_rows) |_| try t.linefeed(); + const old_page_size = primary.pages.page_size; + t.setScrollbackMaxBytes(1); + try testing.expectEqual(@as(usize, 1), primary.pages.limits.bytes.explicit); + try testing.expect(primary.pages.page_size < old_page_size); + try testing.expect( + primary.pages.page_size <= primary.pages.limits.max(.bytes), + ); + try testing.expect(!primary.no_scrollback); + + // Zero switches Screen behavior as well as PageList accounting, discards + // all retained history, and prevents future linefeeds from recreating it. + t.setScrollbackMaxBytes(0); + try testing.expectEqual(@as(usize, 0), primary.pages.limits.bytes.explicit); + try testing.expect(primary.no_scrollback); + try testing.expectEqual( + @as(usize, primary.pages.rows), + primary.pages.total_rows, + ); + try testing.expect(primary.pages.viewport == .active); + + for (0..page_rows) |_| try t.linefeed(); + try testing.expectEqual( + @as(usize, primary.pages.rows), + primary.pages.total_rows, + ); + + // Re-enabling unlimited scrollback affects subsequent output. + t.setScrollbackMaxBytes(null); + try testing.expectEqual( + std.math.maxInt(usize), + primary.pages.limits.bytes.explicit, + ); + try testing.expect(!primary.no_scrollback); + for (0..page_rows) |_| try t.linefeed(); + try testing.expect(primary.pages.total_rows > primary.pages.rows); +} + +test "Terminal setScrollbackMaxLines" { + var t = try init(testing.io, testing.allocator, .{ + .cols = 80, + .rows = 3, + .max_scrollback_bytes = null, + .max_scrollback_lines = null, + }); + defer t.deinit(testing.allocator); + + const primary = t.screens.get(.primary).?; + const page_rows: usize = primary.pages.pages.first.?.capacity().rows; + + for (0..4 * page_rows) |_| try t.linefeed(); + const old_total_rows = primary.pages.total_rows; + t.setScrollbackMaxLines(page_rows); + try testing.expectEqual( + page_rows, + primary.pages.limits.lines.explicit, + ); + try testing.expect(primary.pages.total_rows < old_total_rows); + try testing.expect( + !primary.pages.limits.exceeded(&primary.pages, .lines), + ); + + const limited_total_rows = primary.pages.total_rows; + t.setScrollbackMaxLines(null); + try testing.expectEqual( + std.math.maxInt(usize), + primary.pages.limits.lines.explicit, + ); + try testing.expectEqual(limited_total_rows, primary.pages.total_rows); + for (0..3 * page_rows) |_| try t.linefeed(); + try testing.expect( + primary.pages.total_rows - primary.pages.rows > page_rows, + ); +} + +test "Terminal setScrollback only affects primary screen" { + var t = try init(testing.io, testing.allocator, .{ + .cols = 80, + .rows = 3, + }); + defer t.deinit(testing.allocator); + + _ = try t.switchScreen(.alternate); + const primary = t.screens.get(.primary).?; + const alternate = t.screens.get(.alternate).?; + + t.setScrollbackMaxBytes(123); + t.setScrollbackMaxLines(456); + + try testing.expectEqual( + @as(usize, 123), + primary.pages.limits.bytes.explicit, + ); + try testing.expectEqual( + @as(usize, 456), + primary.pages.limits.lines.explicit, + ); + try testing.expect(!primary.no_scrollback); + + try testing.expectEqual( + @as(usize, 0), + alternate.pages.limits.bytes.explicit, + ); + try testing.expectEqual( + std.math.maxInt(usize), + alternate.pages.limits.lines.explicit, + ); + try testing.expect(alternate.no_scrollback); + try testing.expectEqual(alternate, t.screens.active); +} + +test "Terminal: resize resets synchronized output" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.modes.set(.synchronized_output, true); + try t.resize(alloc, .{ .cols = 10, .rows = 5 }); + try testing.expect(!t.modes.get(.synchronized_output)); +} + +test "Terminal: resize rejects zero dimensions before mutation" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.width_px = 100; + t.height_px = 100; + t.flags.dirty.clear = false; + + try testing.expectError(error.InvalidValue, t.resize(alloc, .{ + .cols = 0, + .rows = 5, + .cell_size_px = .{ .width = 9, .height = 18 }, + })); + try testing.expectError(error.InvalidValue, t.resize(alloc, .{ + .cols = 10, + .rows = 0, + .cell_size_px = .{ .width = 9, .height = 18 }, + })); + + try testing.expectEqual(@as(size.CellCountInt, 10), t.cols); + try testing.expectEqual(@as(size.CellCountInt, 5), t.rows); + try testing.expectEqual(@as(u32, 100), t.width_px); + try testing.expectEqual(@as(u32, 100), t.height_px); + try testing.expect(!t.flags.dirty.clear); +} + +test "Terminal: resize preserves pixel dimensions when omitted" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.width_px = 90; + t.height_px = 90; + try t.resize(alloc, .{ .cols = 20, .rows = 10 }); + + try testing.expectEqual(@as(u32, 90), t.width_px); + try testing.expectEqual(@as(u32, 90), t.height_px); +} + +test "Terminal: resize updates pixels without changing cell dimensions" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + try t.resize(alloc, .{ + .cols = 10, + .rows = 5, + .cell_size_px = .{ .width = 9, .height = 18 }, + }); + + try testing.expectEqual(@as(u32, 90), t.width_px); + try testing.expectEqual(@as(u32, 90), t.height_px); +} + +test "Terminal: resize pixel dimensions saturate" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 3 }); + defer t.deinit(alloc); + + try t.resize(alloc, .{ + .cols = 2, + .rows = 3, + .cell_size_px = .{ + .width = std.math.maxInt(u32), + .height = std.math.maxInt(u32), + }, + }); + + try testing.expectEqual(std.math.maxInt(u32), t.width_px); + try testing.expectEqual(std.math.maxInt(u32), t.height_px); +} + 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 }); + const io_impl = testing.io; + var t = try init(io_impl, 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.expectError(error.OutOfMemory, t.resize(alloc, .{ + .cols = 513, + .rows = 1, + })); try testing.expectEqual(@as(size.CellCountInt, 10), t.cols); try testing.expect(t.tabstops.get(8)); } +test "Terminal: resize failure paths preserve consistent state" { + const alloc = testing.allocator; + const io_impl = testing.io; + + for ([_]resize_tw.FailPoint{ + .tabstops, + .primary_screen, + .alternate_screen, + }) |tag| { + const tw = resize_tw; + defer tw.end(.reset) catch unreachable; + + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + try t.printString("primary"); + _ = try t.switchScreen(.alternate); + try t.printString("alternate"); + _ = try t.switchScreen(.primary); + + t.width_px = 100; + t.height_px = 50; + t.modes.set(.synchronized_output, true); + t.flags.dirty.clear = false; + t.scrolling_region = .{ + .top = 1, + .bottom = 2, + .left = 1, + .right = 8, + }; + t.tabstops.unset(8); + t.tabstops.set(3); + + const primary = t.screens.get(.primary).?; + const alternate = t.screens.get(.alternate).?; + const alternate_generation = t.screens.generation(.alternate); + try testing.expectEqual(primary.pages.pages.first, primary.pages.pages.last); + try testing.expectEqual(alternate.pages.pages.first, alternate.pages.pages.last); + + const before = t; + const before_primary = primary.*; + const before_alternate = alternate.*; + const before_primary_page = try alloc.dupe( + u8, + primary.pages.pages.first.?.page().memory, + ); + defer alloc.free(before_primary_page); + const before_alternate_page = try alloc.dupe( + u8, + alternate.pages.pages.first.?.page().memory, + ); + defer alloc.free(before_alternate_page); + tw.errorAlways(tag, error.OutOfMemory); + + // A failure after the primary screen has resized is recovered by + // dropping the inactive alternate and completing the resize. This + // also proves the alternate tripwire is after the primary resize + // rather than acting as a preflight check. + if (tag == .alternate_screen) { + try t.resize(alloc, .{ + .cols = 513, + .rows = 4, + .cell_size_px = .{ .width = 9, .height = 18 }, + }); + + try testing.expectEqual(@as(size.CellCountInt, 513), t.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), t.rows); + try testing.expectEqual(@as(u32, 4617), t.width_px); + try testing.expectEqual(@as(u32, 72), t.height_px); + try testing.expect(!t.modes.get(.synchronized_output)); + try testing.expect(t.flags.dirty.clear); + try testing.expectEqual(@as(size.CellCountInt, 513), primary.pages.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), primary.pages.rows); + try testing.expectEqual(@as(?*Screen, null), t.screens.get(.alternate)); + try testing.expectEqual( + alternate_generation +% 1, + t.screens.generation(.alternate), + ); + try testing.expectEqual(.primary, t.screens.active_key); + try testing.expectEqual(primary, t.screens.active); + try testing.expect(t.tabstops.get(8)); + try testing.expect(!t.tabstops.get(3)); + + // The alternate is recreated lazily at the terminal's new size. + _ = try t.switchScreen(.alternate); + const replacement = t.screens.get(.alternate).?; + try testing.expectEqual(@as(size.CellCountInt, 513), replacement.pages.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), replacement.pages.rows); + try testing.expect(replacement.pages.getCell(.{ .active = .{} }).?.cell.isEmpty()); + continue; + } + + try testing.expectError(error.OutOfMemory, t.resize(alloc, .{ + .cols = 513, + .rows = 4, + .cell_size_px = .{ .width = 9, .height = 18 }, + })); + + try testing.expectEqual(before.width_px, t.width_px); + try testing.expectEqual(before.height_px, t.height_px); + try testing.expect(std.meta.eql(before.modes, t.modes)); + try testing.expectEqual(before.cols, t.cols); + try testing.expectEqual(before.rows, t.rows); + try testing.expectEqual(before.scrolling_region, t.scrolling_region); + try testing.expectEqual(before.flags, t.flags); + try testing.expect(std.meta.eql(before.tabstops, t.tabstops)); + try testing.expectEqual(before.screens.active_key, t.screens.active_key); + try testing.expectEqual(before.screens.active, t.screens.active); + + try testing.expectEqual(before_primary.pages.cols, primary.pages.cols); + try testing.expectEqual(before_primary.pages.rows, primary.pages.rows); + try testing.expectEqual( + before_primary.pages.total_rows, + primary.pages.total_rows, + ); + try testing.expect(std.meta.eql(before_primary.cursor, primary.cursor)); + try testing.expectEqualSlices( + u8, + before_primary_page, + primary.pages.pages.first.?.page().memory, + ); + + try testing.expectEqual(before_alternate.pages.cols, alternate.pages.cols); + try testing.expectEqual(before_alternate.pages.rows, alternate.pages.rows); + try testing.expectEqual( + before_alternate.pages.total_rows, + alternate.pages.total_rows, + ); + try testing.expect(std.meta.eql(before_alternate.cursor, alternate.cursor)); + try testing.expectEqualSlices( + u8, + before_alternate_page, + alternate.pages.pages.first.?.page().memory, + ); + } +} + +test "Terminal: alternate resize failure replaces active alternate screen" { + const alloc = testing.allocator; + const io_impl = testing.io; + const tw = resize_tw; + defer tw.end(.reset) catch unreachable; + + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + _ = try t.switchScreen(.alternate); + try testing.expectEqual(.alternate, t.screens.active_key); + t.screens.active.charset.gl = .G1; + try t.printString("alternate"); + const generation = t.screens.generation(.alternate); + + tw.errorAlways(.alternate_screen, error.OutOfMemory); + try t.resize(alloc, .{ .cols = 20, .rows = 4 }); + + const alternate = t.screens.get(.alternate).?; + try testing.expectEqual(.alternate, t.screens.active_key); + try testing.expectEqual(alternate, t.screens.active); + try testing.expectEqual(@as(size.CellCountInt, 20), alternate.pages.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), alternate.pages.rows); + try testing.expect(alternate.pages.getCell(.{ .active = .{} }).?.cell.isEmpty()); + try testing.expectEqual(.G1, alternate.charset.gl); + try testing.expectEqual(generation +% 1, t.screens.generation(.alternate)); +} + +test "Terminal: alternate resize replacement failure falls back to primary" { + const alloc = testing.allocator; + const io_impl = testing.io; + const tw = resize_tw; + defer tw.end(.reset) catch unreachable; + + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + _ = try t.switchScreen(.alternate); + tw.errorAlways(.alternate_screen, error.OutOfMemory); + tw.errorAlways(.alternate_screen_init, error.OutOfMemory); + try t.resize(alloc, .{ .cols = 20, .rows = 4 }); + + const primary = t.screens.get(.primary).?; + try testing.expectEqual(@as(?*Screen, null), t.screens.get(.alternate)); + try testing.expectEqual(.primary, t.screens.active_key); + try testing.expectEqual(primary, t.screens.active); + try testing.expectEqual(@as(size.CellCountInt, 20), primary.pages.cols); + try testing.expectEqual(@as(size.CellCountInt, 4), primary.pages.rows); +} + /// Set the pwd for the terminal. pub fn setPwd(self: *Terminal, pwd: []const u8) !void { if (pwd.len == 0) { @@ -3625,7 +4647,8 @@ pub fn getPwd(self: *const Terminal) ?[:0]const u8 { 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 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 1 }); defer t.deinit(alloc); try t.pwd.ensureTotalCapacityPrecise(alloc, 3); @@ -3635,7 +4658,7 @@ test "Terminal: setPwd preserves a sentinel on allocation failure" { } test "Terminal: setPwd accepts its current value" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 1 }); defer t.deinit(testing.allocator); try t.setPwd("file:///tmp"); @@ -3669,7 +4692,8 @@ pub fn getTitle(self: *const Terminal) ?[:0]const u8 { 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 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 1 }); defer t.deinit(alloc); try t.title.ensureTotalCapacityPrecise(alloc, 5); @@ -3679,7 +4703,7 @@ test "Terminal: setTitle preserves a sentinel on allocation failure" { } test "Terminal: setTitle accepts its current value" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 1 }); defer t.deinit(testing.allocator); try t.setTitle("Ghostty"); @@ -3715,13 +4739,14 @@ pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen { const new = self.screens.get(key) orelse new: { const primary = self.screens.get(.primary).?; break :new try self.screens.getInit( + old.io, old.alloc, key, .{ .cols = self.cols, .rows = self.rows, - .max_scrollback = switch (key) { - .primary => primary.pages.explicit_max_size, + .max_scrollback_bytes = switch (key) { + .primary => primary.pages.limits.bytes.explicit, .alternate => 0, }, @@ -3894,13 +4919,21 @@ pub fn fullReset(self: *Terminal) void { self.screens.active.reset(); // Rest our basic state + const visible = self.flags.visible; self.modes.reset(); - self.flags = .{}; + self.flags = .{ + // Visibility belongs to the view rather than terminal state, so a + // terminal reset must not make a hidden view potentially visible. + .visible = visible, + }; self.tabstops.reset(TABSTOP_INTERVAL); self.previous_char = null; self.pwd.clearRetainingCapacity(); self.title.clearRetainingCapacity(); self.glyph_glossary.clearAndFree(self.gpa()); + // A reset only interrupts an in-progress chunked OSC 72 command; + // drag and drop registration survives, matching kitty. + if (self.kitty_dnd) |dnd| dnd.chunking = .{}; self.status_display = .main; self.scrolling_region = .{ .top = 0, @@ -3908,6 +4941,7 @@ pub fn fullReset(self: *Terminal) void { .left = 0, .right = self.cols - 1, }; + self.setCursorStyle(.default); // Always mark dirty so we redraw everything self.flags.dirty.clear = true; @@ -3925,7 +4959,8 @@ fn clearDirty(t: *Terminal) void { test "Terminal: setCursorPos saturates overflowing origin offsets" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); t.scrolling_region = .{ @@ -3943,7 +4978,8 @@ test "Terminal: setCursorPos saturates overflowing origin offsets" { test "Terminal: input with no control characters" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 40, .rows = 40 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 40, .rows = 40 }); defer t.deinit(alloc); // Basic grid writing @@ -3963,7 +4999,8 @@ test "Terminal: input with no control characters" { test "Terminal: input with basic wraparound" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 40 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 40 }); defer t.deinit(alloc); // Basic grid writing @@ -3980,7 +5017,8 @@ test "Terminal: input with basic wraparound" { test "Terminal: input with basic wraparound dirty" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 40 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 40 }); defer t.deinit(alloc); for ("hello") |c| try t.print(c); @@ -3995,7 +5033,8 @@ test "Terminal: input with basic wraparound dirty" { test "Terminal: input that forces scroll" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 1, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 1, .rows = 5 }); defer t.deinit(alloc); // Basic grid writing @@ -4011,7 +5050,8 @@ test "Terminal: input that forces scroll" { test "Terminal: input unique style per cell" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); for (0..t.rows) |y| { @@ -4030,7 +5070,8 @@ test "Terminal: input unique style per cell" { test "Terminal: input glitch text" { const glitch = @embedFile("res/glitch.txt"); const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); // Get our initial grapheme capacity. @@ -4052,7 +5093,7 @@ test "Terminal: input glitch text" { } test "Terminal: zero-width character at start" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // This used to crash the terminal. This is not allowed so we should @@ -4068,7 +5109,7 @@ test "Terminal: zero-width character at start" { // https://github.com/ghostty-org/ghostty/pull/12581 test "Terminal: zero-width character attaches to pending wrap cell" { - var t = try init(testing.allocator, .{ .cols = 2, .rows = 2 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 2, .rows = 2 }); defer t.deinit(testing.allocator); // Disable grapheme clustering to exercise the fallback path. @@ -4083,9 +5124,32 @@ test "Terminal: zero-width character attaches to pending wrap cell" { try testing.expectEqualStrings("xå̲", str); } +test "Terminal: caps zero-width codepoints attached to one cell" { + var t = try init(testing.io, testing.allocator, .{ .cols = 2, .rows = 2 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, false); + try t.print('A'); + + const initial_capacity = t.screens.active.cursor.page_pin.node.capacity().grapheme_bytes; + for (0..pagepkg.grapheme_max_len * 4) |_| try t.print(0x0301); + + const list_cell = t.screens.active.pages.getCell(.{ + .screen = .{ .x = 0, .y = 0 }, + }).?; + try testing.expectEqual( + @as(usize, pagepkg.grapheme_max_len), + list_cell.node.page().lookupGrapheme(list_cell.cell).?.len, + ); + try testing.expectEqual( + initial_capacity, + list_cell.node.capacity().grapheme_bytes, + ); +} + // https://github.com/mitchellh/ghostty/issues/1400 test "Terminal: print single very long line" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // This would crash for issue 1400. So the assertion here is @@ -4094,7 +5158,7 @@ test "Terminal: print single very long line" { } test "Terminal: print wide char" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face @@ -4104,7 +5168,7 @@ test "Terminal: print wide char" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4117,7 +5181,7 @@ test "Terminal: print wide char" { } test "Terminal: print wide char at edge creates spacer head" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); t.setCursorPos(1, 10); @@ -4133,7 +5197,7 @@ test "Terminal: print wide char at edge creates spacer head" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4152,7 +5216,8 @@ test "Terminal: print wide char at edge creates spacer head" { test "Terminal: print wide char with 1-column width" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 1, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 1, .rows = 2 }); defer t.deinit(alloc); try t.print('😀'); // 0x1F600 @@ -4162,7 +5227,7 @@ test "Terminal: print wide char with 1-column width" { } test "Terminal: print wide char in single-width terminal" { - var t = try init(testing.allocator, .{ .cols = 1, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 1, .rows = 80 }); defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face @@ -4173,7 +5238,7 @@ test "Terminal: print wide char in single-width terminal" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } @@ -4181,7 +5246,7 @@ test "Terminal: print wide char in single-width terminal" { } test "Terminal: print over wide char at 0,0" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.print(0x1F600); // Smiley face @@ -4194,13 +5259,13 @@ test "Terminal: print over wide char at 0,0" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } @@ -4216,7 +5281,8 @@ test "Terminal: print over wide char at col 0 corrupts previous row" { // when that cell is a .spacer_tail rather than a .spacer_head. This // orphans the .wide cell at cols-2. const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 3 }); defer t.deinit(alloc); // Fill rows 0 and 1 with wide chars (5 per row on a 10-col terminal). @@ -4247,7 +5313,7 @@ test "Terminal: print over wide char at col 0 corrupts previous row" { } test "Terminal: print over wide spacer tail" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); try t.print('橋'); @@ -4257,13 +5323,13 @@ test "Terminal: print over wide spacer tail" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'X'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'X'), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } @@ -4277,7 +5343,7 @@ test "Terminal: print over wide spacer tail" { } test "Terminal: print over wide char with bold" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.setAttribute(.{ .bold = {} }); @@ -4303,7 +5369,7 @@ test "Terminal: print over wide char with bold" { } test "Terminal: print over wide char with bg color" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.setAttribute(.{ .direct_color_bg = .{ @@ -4333,7 +5399,7 @@ test "Terminal: print over wide char with bg color" { } test "Terminal: print multicodepoint grapheme, disabled mode 2027" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // https://github.com/mitchellh/ghostty/issues/289 @@ -4353,7 +5419,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -4362,7 +5428,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); @@ -4370,7 +5436,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -4379,7 +5445,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); @@ -4387,7 +5453,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); @@ -4395,7 +5461,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); @@ -4405,7 +5471,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { } test "Terminal: enabling grapheme mode handles stored breaks" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 1 }); defer t.deinit(testing.allocator); t.modes.set(.grapheme_cluster, false); @@ -4425,7 +5491,7 @@ test "Terminal: enabling grapheme mode handles stored breaks" { // 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 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 5 }); defer t.deinit(testing.allocator); t.modes.set(.grapheme_cluster, true); @@ -4459,7 +5525,7 @@ test "Terminal: graphemeWidth parity" { } test "Terminal: VS16 doesn't make character with 2027 disabled" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Disable grapheme clustering @@ -4477,7 +5543,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -4486,7 +5552,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { } test "Terminal: ignored VS16 doesn't mark dirty" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Disable grapheme clustering @@ -4501,7 +5567,7 @@ test "Terminal: ignored VS16 doesn't mark dirty" { } test "Terminal: print invalid VS16 non-grapheme" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // https://github.com/mitchellh/ghostty/issues/1482 @@ -4517,19 +5583,19 @@ test "Terminal: print invalid VS16 non-grapheme" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); } } test "Terminal: invalid VS16 doesn't mark dirty" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Disable grapheme clustering @@ -4545,7 +5611,7 @@ test "Terminal: invalid VS16 doesn't mark dirty" { // https://github.com/ghostty-org/ghostty/pull/12596 test "Terminal: variation selectors apply to preceding codepoint" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 5 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4559,13 +5625,13 @@ test "Terminal: variation selectors apply to preceding codepoint" { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F3F4), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F3F4), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.page().lookupGrapheme(cell).?); } test "Terminal: print multicodepoint grapheme, mode 2027" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4591,7 +5657,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -4600,14 +5666,14 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } test "Terminal: keypad sequence VS15" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4629,14 +5695,14 @@ test "Terminal: keypad sequence VS15" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: keypad sequence VS16" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4658,14 +5724,14 @@ test "Terminal: keypad sequence VS16" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x23), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); } } test "Terminal: Fitzpatrick skin tone next valid base" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4687,14 +5753,14 @@ test "Terminal: Fitzpatrick skin tone next valid base" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F44B), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F44B), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); } } test "Terminal: Fitzpatrick skin tone next to non-base" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4718,28 +5784,28 @@ test "Terminal: Fitzpatrick skin tone next to non-base" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F3FF), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F3FF), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x22), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: multicodepoint grapheme marks dirty on every codepoint" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4768,7 +5834,7 @@ test "Terminal: multicodepoint grapheme marks dirty on every codepoint" { } test "Terminal: VS15 to make narrow character" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4799,7 +5865,7 @@ test "Terminal: VS15 to make narrow character" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -4808,7 +5874,7 @@ test "Terminal: VS15 to make narrow character" { } test "Terminal: VS15 on already narrow emoji" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4834,7 +5900,7 @@ test "Terminal: VS15 on already narrow emoji" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -4843,7 +5909,7 @@ test "Terminal: VS15 on already narrow emoji" { } test "Terminal: print invalid VS15 following emoji is wide" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4861,20 +5927,20 @@ test "Terminal: print invalid VS15 following emoji is wide" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '\u{1F9E0}'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '\u{1F9E0}'), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } test "Terminal: print invalid VS15 in emoji ZWJ sequence" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4894,7 +5960,7 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '\u{1F469}'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '\u{1F469}'), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); @@ -4902,13 +5968,13 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } test "Terminal: VS15 to make narrow character with pending wrap" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 4 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 4 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4945,7 +6011,7 @@ test "Terminal: VS15 to make narrow character with pending wrap" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -4955,16 +6021,78 @@ test "Terminal: VS15 to make narrow character with pending wrap" { // VS15 should not affect the previous grapheme { const lemon_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?.cell; - try testing.expectEqual(@as(u21, 0x1F34B), lemon_cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F34B), lemon_cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.wide, lemon_cell.wide); const spacer_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?.cell; - try testing.expectEqual(@as(u21, 0), spacer_cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), spacer_cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.spacer_tail, spacer_cell.wide); } } +test "Terminal: VS15 narrows wide cell under cursor with wraparound disabled" { + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.modes.set(.wraparound, false); + + // First create a wide cell spanning columns 4 and 5. + t.setCursorPos(1, 4); + try t.print(0x2614); + + // Make column 4 the right margin and put the cursor on the wide base. + // With wraparound disabled, grapheme lookup selects the cell under the + // cursor when it has content. + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(1, 4); + t.setCursorPos(1, 4); + try t.print(0xFE0E); + + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + const base = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?.cell; + try testing.expectEqual(Cell.Wide.narrow, base.wide); + try testing.expect(base.hasGrapheme()); + const tail = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?.cell; + try testing.expectEqual(Cell.Wide.narrow, tail.wide); +} + +test "Terminal: VS15 narrows wide cell under restored pending cursor" { + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(1, 4); + + // Save a pending-wrap cursor at column 4. + t.setCursorPos(1, 4); + try t.print('X'); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.saveCursor(); + + // Widen the margin and replace that cell with a wide character. + t.setLeftAndRightMargin(1, 5); + t.setCursorPos(1, 4); + try t.print(0x2614); + + // Restoring also restores pending_wrap, so grapheme lookup selects the + // wide base under the cursor rather than its spacer tail. + t.restoreCursor(); + try testing.expect(t.screens.active.cursor.pending_wrap); + try t.print(0xFE0E); + + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + const base = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?.cell; + try testing.expectEqual(Cell.Wide.narrow, base.wide); + try testing.expect(base.hasGrapheme()); + const tail = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?.cell; + try testing.expectEqual(Cell.Wide.narrow, tail.wide); +} + test "Terminal: VS16 to make wide character on next line" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -4989,7 +6117,7 @@ test "Terminal: VS16 to make wide character on next line" { // Previous cell turns into spacer_head const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); } @@ -4997,7 +6125,7 @@ test "Terminal: VS16 to make wide character on next line" { // '#' cell is wide const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); @@ -5006,7 +6134,7 @@ test "Terminal: VS16 to make wide character on next line" { // spacer_tail const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } @@ -5015,7 +6143,7 @@ test "Terminal: VS16 to make wide character on next line" { test "Terminal: VS16 to make wide character on next line with hyperlink" { // Regression test for the crash fixed in print's grapheme `.wide` path: // writing a spacer_head at the screen edge before row.wrap was set. - var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); defer t.deinit(testing.allocator); // Enable grapheme clustering and activate a hyperlink so printCell @@ -5039,7 +6167,7 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" { // Previous cell turns into spacer_head and remains hyperlinked. const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); try testing.expect(cell.hyperlink); try testing.expect(list_cell.row.wrap); @@ -5048,7 +6176,7 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" { // '#' cell is now wide and still hyperlinked. const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); @@ -5058,14 +6186,114 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" { // spacer_tail inherits hyperlink as part of the same grapheme cell. const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); try testing.expect(cell.hyperlink); } } +test "Terminal: VS16 widening when the spacer tail grows the page" { + // Regression test for a stale cell pointer in print's grapheme `.wide` + // path: writing the spacer tail can grow the page to fit the hyperlink, + // which replaces the page and invalidates the pointer to the wide cell. + var t = try init(testing.io, testing.allocator, .{ .rows = 10, .cols = 20 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + try t.screens.active.startHyperlink("http://example.com", null); + + // Fill the page hyperlink map until a single slot is left. The '#' below + // takes that slot so the spacer tail is what forces the page to grow. + while (true) { + const page = t.screens.active.cursor.page_pin.node.page(); + const map = page.hyperlink_map.map(page.memory); + if (map.maxLoad() - map.count() == 1) break; + try t.print('x'); + } + + const x = t.screens.active.cursor.x; + const y = t.screens.active.cursor.y; + try t.print('#'); + + // Without the fix this crashed appending to a freed page. + try t.print(0xFE0F); + + { + // '#' is wide and carries the VS16 grapheme. + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = x, + .y = y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint.data); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expect(cell.hasGrapheme()); + try testing.expectEqualSlices( + u21, + &.{0xFE0F}, + list_cell.node.page().lookupGrapheme(cell).?, + ); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = x + 1, + .y = y, + } }).?; + try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide); + } +} + +test "Terminal: grapheme transfer when widening wraps to the next line" { + // Covers print's grapheme `.wide` path where the previous cell already + // holds grapheme data and has to be moved to the wrapped row. + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.cursorRight(2); + + // A narrow emoji, then ZWJ, then a second emoji. The ZWJ attaches + // without changing the width, so the cell has grapheme data by the time + // the second emoji widens it. + try t.print(0x263A); + try t.print(0x200D); + try t.print(0x2764); + + { + // The old cell becomes a spacer head on the wrapped row. + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = 2, + .y = 0, + } }).?; + try testing.expectEqual(Cell.Wide.spacer_head, list_cell.cell.wide); + try testing.expect(list_cell.row.wrap); + } + { + // The grapheme moved with the base codepoint. + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = 0, + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u21, 0x263A), cell.content.codepoint.data); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expectEqualSlices( + u21, + &.{ 0x200D, 0x2764 }, + list_cell.node.page().lookupGrapheme(cell).?, + ); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = 1, + .y = 1, + } }).?; + try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide); + } +} + test "Terminal: VS16 to make wide character with pending wrap" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5086,7 +6314,7 @@ test "Terminal: VS16 to make wide character with pending wrap" { // '#' cell is wide const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '#'), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); @@ -5095,14 +6323,14 @@ test "Terminal: VS16 to make wide character with pending wrap" { // spacer_tail const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } test "Terminal: VS16 to make wide character with mode 2027" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5124,7 +6352,7 @@ test "Terminal: VS16 to make wide character with mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -5133,7 +6361,7 @@ test "Terminal: VS16 to make wide character with mode 2027" { } test "Terminal: VS16 repeated with mode 2027" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5155,7 +6383,7 @@ test "Terminal: VS16 repeated with mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -5164,7 +6392,7 @@ test "Terminal: VS16 repeated with mode 2027" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); const cps = list_cell.node.page().lookupGrapheme(cell).?; @@ -5173,7 +6401,7 @@ test "Terminal: VS16 repeated with mode 2027" { } test "Terminal: print invalid VS16 grapheme" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5192,20 +6420,20 @@ test "Terminal: print invalid VS16 grapheme" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: print invalid VS16 with second char" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5225,7 +6453,7 @@ test "Terminal: print invalid VS16 with second char" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'x'), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } @@ -5233,14 +6461,14 @@ test "Terminal: print invalid VS16 with second char" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'y'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'y'), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5258,7 +6486,7 @@ test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -5266,7 +6494,7 @@ test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" { } test "Terminal: print Devanagari grapheme should be wide" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5287,7 +6515,7 @@ test "Terminal: print Devanagari grapheme should be wide" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); @@ -5300,7 +6528,7 @@ test "Terminal: print Devanagari grapheme should be wide" { } test "Terminal: print Devanagari grapheme should be wide on next line" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 3 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5327,7 +6555,7 @@ test "Terminal: print Devanagari grapheme should be wide on next line" { // Previous cell turns into spacer_head const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); } @@ -5335,7 +6563,7 @@ test "Terminal: print Devanagari grapheme should be wide on next line" { // Devanagari grapheme is wide const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); @@ -5350,7 +6578,7 @@ test "Terminal: print Devanagari grapheme should be wide on next line" { test "Terminal: print Devanagari grapheme should be wide on next page" { const rows = pagepkg.std_capacity.rows; const cols = pagepkg.std_capacity.cols; - var t = try init(testing.allocator, .{ .rows = rows, .cols = cols }); + var t = try init(testing.io, testing.allocator, .{ .rows = rows, .cols = cols }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5386,7 +6614,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" { // Previous cell turns into spacer_head const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = cols - 1, .y = rows - 2 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); } @@ -5394,7 +6622,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" { // Devanagari grapheme is wide const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = rows - 1 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); @@ -5407,7 +6635,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" { } test "Terminal: print invalid VS16 with second char (combining)" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5427,7 +6655,7 @@ test "Terminal: print invalid VS16 with second char (combining)" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'n'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'n'), cell.content.codepoint.data); try testing.expect(cell.hasGrapheme()); try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); @@ -5435,13 +6663,13 @@ test "Terminal: print invalid VS16 with second char (combining)" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: overwrite grapheme should clear grapheme data" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5465,14 +6693,14 @@ test "Terminal: overwrite grapheme should clear grapheme data" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5512,7 +6740,7 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { } test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); // Enable grapheme clustering @@ -5551,7 +6779,8 @@ test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { test "Terminal: print breaks valid grapheme cluster with Prepend + ASCII for speed" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.grapheme_cluster, true); @@ -5578,7 +6807,7 @@ test "Terminal: print breaks valid grapheme cluster with Prepend + ASCII for spe { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x0600), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x0600), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); // This is what we'd expect if we did break correctly: //try testing.expect(cell.hasGrapheme()); @@ -5588,16 +6817,16 @@ test "Terminal: print breaks valid grapheme cluster with Prepend + ASCII for spe { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '1'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '1'), cell.content.codepoint.data); // This is what we'd expect if we did break correctly: - //try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + //try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: print writes to bottom if scrolled" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 2 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 2 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -5639,7 +6868,7 @@ test "Terminal: print writes to bottom if scrolled" { } test "Terminal: print charset" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // G1 should have no effect @@ -5668,7 +6897,7 @@ test "Terminal: print charset" { } test "Terminal: print charset outside of ASCII" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // G1 should have no effect @@ -5693,7 +6922,7 @@ test "Terminal: print charset outside of ASCII" { } test "Terminal: print invoke charset" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); t.configureCharset(.G1, .dec_special); @@ -5717,7 +6946,7 @@ test "Terminal: print invoke charset" { } test "Terminal: print invoke charset single" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); t.configureCharset(.G1, .dec_special); @@ -5737,7 +6966,7 @@ test "Terminal: print invoke charset single" { test "Terminal: print kitty unicode placeholder" { if (comptime !build_options.kitty_graphics) return error.SkipZigTest; - var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); try t.print(kitty.graphics.unicode.placeholder); @@ -5747,7 +6976,7 @@ test "Terminal: print kitty unicode placeholder" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, kitty.graphics.unicode.placeholder), cell.content.codepoint); + try testing.expectEqual(@as(u21, kitty.graphics.unicode.placeholder), cell.content.codepoint.data); try testing.expect(list_cell.row.kitty_virtual_placeholder); } @@ -5755,7 +6984,7 @@ test "Terminal: print kitty unicode placeholder" { } test "Terminal: soft wrap" { - var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 3, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -5770,7 +6999,7 @@ test "Terminal: soft wrap" { } test "Terminal: soft wrap with semantic prompt" { - var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 3, .rows = 80 }); defer t.deinit(testing.allocator); // Mark our prompt. @@ -5791,7 +7020,7 @@ test "Terminal: soft wrap with semantic prompt" { } test "Terminal: disabled wraparound with wide char and one space" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); t.modes.set(.wraparound, false); @@ -5814,7 +7043,7 @@ test "Terminal: disabled wraparound with wide char and one space" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } @@ -5823,7 +7052,7 @@ test "Terminal: disabled wraparound with wide char and one space" { } test "Terminal: disabled wraparound with wide char and no space" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); t.modes.set(.wraparound, false); @@ -5845,7 +7074,7 @@ test "Terminal: disabled wraparound with wide char and no space" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } @@ -5854,7 +7083,7 @@ test "Terminal: disabled wraparound with wide char and no space" { } test "Terminal: disabled wraparound with wide grapheme and half space" { - var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + var t = try init(testing.io, testing.allocator, .{ .rows = 5, .cols = 5 }); defer t.deinit(testing.allocator); t.modes.set(.grapheme_cluster, true); @@ -5878,7 +7107,7 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '❤'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '❤'), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } @@ -5887,7 +7116,7 @@ test "Terminal: disabled wraparound with wide grapheme and half space" { } test "Terminal: print right margin wrap" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -5910,7 +7139,7 @@ test "Terminal: print right margin wrap" { } test "Terminal: print right margin wrap dirty tracking" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -5939,7 +7168,7 @@ test "Terminal: print right margin wrap dirty tracking" { } test "Terminal: print right margin outside" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -5959,7 +7188,7 @@ test "Terminal: print right margin outside" { } test "Terminal: print right margin outside wrap" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.printString("123456789"); @@ -5976,7 +7205,7 @@ test "Terminal: print right margin outside wrap" { } test "Terminal: print wide char at right margin does not create spacer head" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); t.modes.set(.enable_left_and_right_margin, true); @@ -5993,7 +7222,7 @@ test "Terminal: print wide char at right margin does not create spacer head" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); const row = list_cell.row; @@ -6002,7 +7231,7 @@ test "Terminal: print wide char at right margin does not create spacer head" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 1 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F600), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -6013,7 +7242,7 @@ test "Terminal: print wide char at right margin does not create spacer head" { } test "Terminal: print with hyperlink" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Setup our hyperlink and print @@ -6038,7 +7267,7 @@ test "Terminal: print with hyperlink" { } test "Terminal: print over cell with same hyperlink" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Setup our hyperlink and print @@ -6065,7 +7294,7 @@ test "Terminal: print over cell with same hyperlink" { } test "Terminal: print and end hyperlink" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Setup our hyperlink and print @@ -6102,7 +7331,7 @@ test "Terminal: print and end hyperlink" { } test "Terminal: print and change hyperlink" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Setup our hyperlink and print @@ -6137,7 +7366,7 @@ test "Terminal: print and change hyperlink" { } test "Terminal: overwrite hyperlink" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Setup our hyperlink and print @@ -6170,7 +7399,7 @@ test "Terminal: overwrite hyperlink" { // flag. The integrity check inside setHyperlink (or increaseCapacity) // sees the unwrapped spacer head and panics. Found via fuzzing. test "Terminal: print wide char at right edge with hyperlink" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 5 }); defer t.deinit(testing.allocator); try t.screens.active.startHyperlink("http://example.com", null); @@ -6197,7 +7426,7 @@ test "Terminal: print wide char at right edge with hyperlink" { // Row 1, col 0: the wide char with hyperlink { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; - try testing.expectEqual(@as(u21, 0x4E2D), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x4E2D), list_cell.cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.wide, list_cell.cell.wide); try testing.expect(list_cell.cell.hyperlink); } @@ -6209,8 +7438,87 @@ test "Terminal: print wide char at right edge with hyperlink" { } } +// A cursor style or hyperlink ID is an index into a set stored in the +// page memory of the page the cursor pin points at. When scrollClear +// (here via ED 22, kitty's scroll_complete) pushes the active area onto +// a later page while the cursor pin is still on an earlier one, +// cursorReload must migrate the cursor's style and hyperlink references +// to the destination page. It previously replaced the pin directly, +// leaving the cursor holding an ID that was dead or aliased an unrelated +// entry on the new page, and the next print attached a live cell to it. +// Found via fuzzing. +test "Terminal: scrollClear across pages keeps cursor hyperlink refs page-local" { + const alloc = testing.allocator; + + // Minimized from a 774-byte AFL fuzz input. Reading it: + // + // A print, so REP has something to repeat + // ESC [ 48111 b REP, filling the page and spilling onto a second + // ESC ] 8 ; ; 0x93 OSC 8; the C1 byte terminates the OSC and makes + // the URI non-empty, so a hyperlink starts + // ESC [ 11 A CUU, moving the cursor back onto the first page + // ESC [ 22 J ED 22, i.e. scroll_complete -> Screen.scrollClear + // B print, which attaches the cursor hyperlink + // ESC ] 8 ; ; ESC OSC 8 with an empty URI, ending the hyperlink + // + // The grid must be wide enough to fill a page from a single REP, so + // this does not reproduce at 80x24. + const input = "A\x1b[48111b\x1b]8;;\x93\x1b[11A\x1b[22JB\x1b]8;;\x1b"; + + var t = try init(testing.io, alloc, .{ .cols = 200, .rows = 50 }); + defer t.deinit(alloc); + + { + var s = t.vtStream(); + defer s.deinit(); + s.nextSlice(input); + } + + // With slow runtime safety on, the page integrity checks during the + // stream above already catch the bug. Verify the ref counts explicitly + // as well so this test is meaningful with runtime safety off: every + // cell holding a hyperlink ID owns a reference, so a count below the + // number of holding cells means a live cell points at an entry that + // was already freed. + var node_ = t.screens.active.pages.pages.first; + while (node_) |node| : (node_ = node.next) { + const page = node.page(); + const cap = page.hyperlink_set.layout.cap; + if (cap == 0) continue; + + const holders = try alloc.alloc(u32, cap); + defer alloc.free(holders); + @memset(holders, 0); + + for (page.rows.ptr(page.memory)[0..page.size.rows]) |*row| { + if (!row.hyperlink) continue; + for (row.cells.ptr(page.memory)[0..page.size.cols]) |*cell| { + if (!cell.hyperlink) continue; + const id = page.lookupHyperlink(cell) orelse continue; + if (id < cap) holders[id] += 1; + } + } + + for (holders, 0..) |held, id| { + if (held == 0) continue; + const refs = page.hyperlink_set.refCount(page.memory, @intCast(id)); + try testing.expect(refs >= held); + } + } + + // If the cursor still has an active hyperlink, its own extra + // reference must live on the cursor's page. + const cursor = &t.screens.active.cursor; + if (cursor.hyperlink_id != 0) { + const page = cursor.page_pin.node.page(); + try testing.expect( + page.hyperlink_set.refCount(page.memory, cursor.hyperlink_id) > 0, + ); + } +} + test "Terminal: linefeed and carriage return" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Print and CR. @@ -6238,7 +7546,7 @@ test "Terminal: linefeed and carriage return" { } test "Terminal: linefeed unsets pending wrap" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -6252,7 +7560,7 @@ test "Terminal: linefeed unsets pending wrap" { } test "Terminal: linefeed mode automatic carriage return" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -6268,7 +7576,7 @@ test "Terminal: linefeed mode automatic carriage return" { } test "Terminal: carriage return unsets pending wrap" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); // Basic grid writing @@ -6279,7 +7587,7 @@ test "Terminal: carriage return unsets pending wrap" { } test "Terminal: carriage return origin mode moves to left margin" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); t.modes.set(.origin, true); @@ -6290,7 +7598,7 @@ test "Terminal: carriage return origin mode moves to left margin" { } test "Terminal: carriage return left of left margin moves to zero" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); t.screens.active.cursor.x = 1; @@ -6300,7 +7608,7 @@ test "Terminal: carriage return left of left margin moves to zero" { } test "Terminal: carriage return right of left margin moves to left margin" { - var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 5, .rows = 80 }); defer t.deinit(testing.allocator); t.screens.active.cursor.x = 3; @@ -6310,7 +7618,7 @@ test "Terminal: carriage return right of left margin moves to left margin" { } test "Terminal: backspace" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // BS @@ -6328,7 +7636,8 @@ test "Terminal: backspace" { test "Terminal: horizontal tabs" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); // HT @@ -6349,7 +7658,8 @@ test "Terminal: horizontal tabs" { test "Terminal: horizontal tabs starting on tabstop" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(t.screens.active.cursor.y, 9); @@ -6367,7 +7677,8 @@ test "Terminal: horizontal tabs starting on tabstop" { test "Terminal: horizontal tabs with right margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.scrolling_region.left = 2; @@ -6386,7 +7697,8 @@ test "Terminal: horizontal tabs with right margin" { test "Terminal: horizontal tabs back" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); // Edge of screen @@ -6409,7 +7721,8 @@ test "Terminal: horizontal tabs back" { test "Terminal: horizontal tabs back starting on tabstop" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(t.screens.active.cursor.y, 9); @@ -6427,7 +7740,8 @@ test "Terminal: horizontal tabs back starting on tabstop" { test "Terminal: horizontal tabs with left margin in origin mode" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.modes.set(.origin, true); @@ -6447,7 +7761,8 @@ test "Terminal: horizontal tabs with left margin in origin mode" { test "Terminal: horizontal tab back with cursor before left margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 20, .rows = 5 }); defer t.deinit(alloc); t.modes.set(.origin, true); @@ -6467,7 +7782,8 @@ test "Terminal: horizontal tab back with cursor before left margin" { test "Terminal: cursorPos resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -6485,7 +7801,8 @@ test "Terminal: cursorPos resets wrap" { test "Terminal: cursorPos off the screen" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(500, 500); @@ -6500,7 +7817,8 @@ test "Terminal: cursorPos off the screen" { test "Terminal: cursorPos relative to origin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.top = 2; @@ -6518,7 +7836,8 @@ test "Terminal: cursorPos relative to origin" { test "Terminal: cursorPos relative to origin with left/right" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.top = 2; @@ -6538,7 +7857,8 @@ test "Terminal: cursorPos relative to origin with left/right" { test "Terminal: cursorPos limits with full scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.top = 2; @@ -6558,7 +7878,7 @@ test "Terminal: cursorPos limits with full scroll region" { // Probably outdated, but dates back to the original terminal implementation. test "Terminal: setCursorPos (original test)" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); @@ -6611,7 +7931,8 @@ test "Terminal: setCursorPos (original test)" { test "Terminal: setTopAndBottomMargin simple" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6641,7 +7962,8 @@ test "Terminal: setTopAndBottomMargin simple" { test "Terminal: setTopAndBottomMargin top only" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6671,7 +7993,8 @@ test "Terminal: setTopAndBottomMargin top only" { test "Terminal: setTopAndBottomMargin top and bottom" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6699,7 +8022,8 @@ test "Terminal: setTopAndBottomMargin top and bottom" { test "Terminal: setTopAndBottomMargin top equal to bottom" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6728,7 +8052,8 @@ test "Terminal: setTopAndBottomMargin top equal to bottom" { test "Terminal: setLeftAndRightMargin simple" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6756,7 +8081,8 @@ test "Terminal: setLeftAndRightMargin simple" { test "Terminal: setLeftAndRightMargin left only" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6789,7 +8115,8 @@ test "Terminal: setLeftAndRightMargin left only" { test "Terminal: setLeftAndRightMargin left and right" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6820,7 +8147,8 @@ test "Terminal: setLeftAndRightMargin left and right" { test "Terminal: setLeftAndRightMargin left equal right" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6851,7 +8179,8 @@ test "Terminal: setLeftAndRightMargin left equal right" { test "Terminal: setLeftAndRightMargin mode 69 unset" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6882,7 +8211,8 @@ test "Terminal: setLeftAndRightMargin mode 69 unset" { test "Terminal: insertLines simple" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6914,7 +8244,8 @@ test "Terminal: insertLines simple" { test "Terminal: insertLines colors with bg color" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6955,7 +8286,8 @@ test "Terminal: insertLines colors with bg color" { test "Terminal: insertLines handles style refs" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -6989,7 +8321,8 @@ test "Terminal: insertLines handles style refs" { test "Terminal: insertLines outside of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -7018,7 +8351,8 @@ test "Terminal: insertLines outside of scroll region" { test "Terminal: insertLines top/bottom scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -7051,7 +8385,8 @@ test "Terminal: insertLines top/bottom scroll region" { test "Terminal: insertLines across page boundary marks all shifted rows dirty" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 1024 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 10, .max_scrollback_bytes = 1024 }); defer t.deinit(alloc); const first_page = t.screens.active.pages.pages.first.?; @@ -7099,9 +8434,96 @@ test "Terminal: insertLines across page boundary marks all shifted rows dirty" { } } +test "Terminal: insertLines hyperlink-dense row crosses page boundary" { + // Regression test for the cross-page copy of insertLines: when the + // shifted row carries more unique hyperlinks than the destination + // page's hyperlink capacity, the copy must increase the destination + // page's capacity and retry rather than corrupting the page list. + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 10, .max_scrollback_bytes = 1024 }); + defer t.deinit(alloc); + + const pages = &t.screens.active.pages; + + // Fill the first page so it is exactly full, then two more rows so + // the second page holds the last two active rows (y=3 and y=4). + const first_page_rows = pages.pages.first.?.capacity().rows; + for (0..first_page_rows + 1) |_| try t.linefeed(); + try testing.expect(pages.pages.first != pages.pages.last); + try testing.expectEqual(@as(usize, 2), pages.pages.last.?.rows()); + + // Marker rows so we can verify the shift afterwards. + t.setCursorPos(1, 1); + try t.printString("0"); + t.setCursorPos(2, 1); + try t.printString("1"); + t.setCursorPos(4, 1); + try t.printString("3"); + t.setCursorPos(5, 1); + try t.printString("4"); + + // Fill the last row of the first page (active y=2) with unique + // hyperlinks: more than the second page can hold with its default + // hyperlink capacity. Writing them grows the first page's capacity + // as needed; the second page keeps its default capacity. + t.setCursorPos(3, 1); + for (0..10) |i| { + var buf: [64]u8 = undefined; + const uri = try std.fmt.bufPrint(&buf, "http://example.com/{d}", .{i}); + try t.screens.active.startHyperlink(uri, null); + try t.print(@intCast('A' + i)); + t.screens.active.endHyperlink(); + } + { + const pin = pages.pin(.{ .active = .{ .y = 2 } }).?; + try testing.expectEqual(pages.pages.first.?, pin.node); + try testing.expectEqual(pin.node.rows() - 1, @as(usize, pin.y)); + } + try testing.expect(pages.pages.last.?.page().hyperlink_set.layout.cap < 10); + + // Insert a line at the top: every row shifts down by one and the + // dense row crosses the page boundary into the second page. + t.setCursorPos(1, 1); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n0\n1\nABCDEFGHIJ\n3", str); + } + + // The second page's hyperlink capacity had to grow to receive the + // row, proving the capacity-retry path ran. + try testing.expect(pages.pages.last.?.page().hyperlink_set.layout.cap >= 10); + + // Every cell of the dense row must still resolve to a real + // hyperlink entry with the correct URI. A half-applied shift + // leaves cells whose hyperlink flag is set but that have no map + // entry, which aborts in clearCells later. + for (0..10) |x| { + const list_cell = pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 3, + } }).?; + try testing.expect(list_cell.cell.hyperlink); + const page: *Page = list_cell.node.page(); + const id = page.lookupHyperlink(list_cell.cell).?; + const link = page.hyperlink_set.get(page.memory, id); + var buf: [64]u8 = undefined; + const uri = try std.fmt.bufPrint(&buf, "http://example.com/{d}", .{x}); + try testing.expectEqualStrings(uri, link.uri.slice(page.memory)); + } + + // All pages must pass integrity checks. + var node_: ?*PageList.List.Node = pages.pages.first; + while (node_) |node| : (node_ = node.next) node.page().assertIntegrity(); +} + test "Terminal: insertLines (legacy test)" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // Initial value @@ -7134,7 +8556,8 @@ test "Terminal: insertLines (legacy test)" { test "Terminal: insertLines zero" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // This should do nothing @@ -7144,7 +8567,8 @@ test "Terminal: insertLines zero" { test "Terminal: insertLines with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 6 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 6 }); defer t.deinit(alloc); // Initial value @@ -7183,7 +8607,8 @@ test "Terminal: insertLines with scroll region" { test "Terminal: insertLines more than remaining" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // Initial value @@ -7221,7 +8646,8 @@ test "Terminal: insertLines more than remaining" { test "Terminal: insertLines resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -7239,7 +8665,8 @@ test "Terminal: insertLines resets pending wrap" { test "Terminal: insertLines resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 3, .cols = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); try t.print('1'); @@ -7265,7 +8692,8 @@ test "Terminal: insertLines resets wrap" { test "Terminal: insertLines multi-codepoint graphemes" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Disable grapheme clustering @@ -7297,7 +8725,8 @@ test "Terminal: insertLines multi-codepoint graphemes" { test "Terminal: insertLines left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -7328,7 +8757,8 @@ test "Terminal: insertLines left/right scroll region" { test "Terminal: scrollUp simple" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -7361,7 +8791,8 @@ test "Terminal: scrollUp simple" { test "Terminal: scrollUp moves hyperlink" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -7412,7 +8843,8 @@ test "Terminal: scrollUp moves hyperlink" { test "Terminal: scrollUp clears hyperlink" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.screens.active.startHyperlink("http://example.com", null); @@ -7449,7 +8881,8 @@ test "Terminal: scrollUp clears hyperlink" { test "Terminal: scrollUp top/bottom scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -7479,7 +8912,8 @@ test "Terminal: scrollUp top/bottom scroll region" { test "Terminal: scrollUp left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -7512,7 +8946,8 @@ test "Terminal: scrollUp left/right scroll region" { test "Terminal: scrollUp left/right scroll region hyperlink" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -7618,7 +9053,8 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { test "Terminal: scrollUp preserves pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(1, 5); @@ -7639,7 +9075,8 @@ test "Terminal: scrollUp preserves pending wrap" { test "Terminal: scrollUp full top/bottom region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("top"); @@ -7663,7 +9100,8 @@ test "Terminal: scrollUp full top/bottom region" { test "Terminal: scrollUp full top/bottomleft/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("top"); @@ -7694,7 +9132,8 @@ test "Terminal: scrollUp creates scrollback in primary screen" { // When in primary screen with full-width scroll region at top, // scrollUp (CSI S) should push lines into scrollback like xterm. const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5, .max_scrollback_bytes = 10 }); defer t.deinit(alloc); // Fill the screen with content @@ -7737,10 +9176,11 @@ test "Terminal: scrollUp creates scrollback in primary screen" { } } -test "Terminal: scrollUp with max_scrollback zero" { - // When max_scrollback is 0, scrollUp should still work but not retain history +test "Terminal: scrollUp with max_scrollback_bytes zero" { + // When max_scrollback_bytes is 0, scrollUp should still work but not retain history const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5, .max_scrollback_bytes = 0 }); defer t.deinit(alloc); try t.printString("AAAAA"); @@ -7769,10 +9209,11 @@ test "Terminal: scrollUp with max_scrollback zero" { } } -test "Terminal: scrollUp with max_scrollback zero and top margin" { - // When max_scrollback is 0 and top margin is set, should use deleteLines path +test "Terminal: scrollUp with max_scrollback_bytes zero and top margin" { + // When max_scrollback_bytes is 0 and top margin is set, should use deleteLines path const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5, .max_scrollback_bytes = 0 }); defer t.deinit(alloc); try t.printString("AAAAA"); @@ -7799,10 +9240,11 @@ test "Terminal: scrollUp with max_scrollback zero and top margin" { } } -test "Terminal: scrollUp with max_scrollback zero and left/right margin" { - // When max_scrollback is 0 with left/right margins, uses deleteLines path +test "Terminal: scrollUp with max_scrollback_bytes zero and left/right margin" { + // When max_scrollback_bytes is 0 with left/right margins, uses deleteLines path const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 0 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 10, .max_scrollback_bytes = 0 }); defer t.deinit(alloc); try t.printString("AAAAABBBBB"); @@ -7829,7 +9271,8 @@ test "Terminal: scrollUp with max_scrollback zero and left/right margin" { test "Terminal: scrollDown simple" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -7861,7 +9304,8 @@ test "Terminal: scrollDown simple" { test "Terminal: scrollDown hyperlink moves" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.screens.active.startHyperlink("http://example.com", null); @@ -7912,7 +9356,8 @@ test "Terminal: scrollDown hyperlink moves" { test "Terminal: scrollDown outside of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -7947,7 +9392,8 @@ test "Terminal: scrollDown outside of scroll region" { test "Terminal: scrollDown left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -7981,7 +9427,8 @@ test "Terminal: scrollDown left/right scroll region" { test "Terminal: scrollDown left/right scroll region hyperlink" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.screens.active.startHyperlink("http://example.com", null); @@ -8087,7 +9534,8 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { test "Terminal: scrollDown outside of left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -8121,7 +9569,8 @@ test "Terminal: scrollDown outside of left/right scroll region" { test "Terminal: scrollDown preserves pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); t.setCursorPos(1, 5); @@ -8142,7 +9591,8 @@ test "Terminal: scrollDown preserves pending wrap" { test "Terminal: eraseChars simple operation" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -8163,7 +9613,8 @@ test "Terminal: eraseChars simple operation" { test "Terminal: eraseChars minimum one" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -8182,7 +9633,8 @@ test "Terminal: eraseChars minimum one" { test "Terminal: eraseChars beyond screen edge" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for (" ABC") |c| try t.print(c); @@ -8198,7 +9650,8 @@ test "Terminal: eraseChars beyond screen edge" { test "Terminal: eraseChars wide character" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('橋'); @@ -8216,7 +9669,8 @@ test "Terminal: eraseChars wide character" { test "Terminal: eraseChars resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -8234,7 +9688,8 @@ test "Terminal: eraseChars resets pending wrap" { test "Terminal: eraseChars resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE123") |c| try t.print(c); @@ -8264,7 +9719,8 @@ test "Terminal: eraseChars resets wrap" { test "Terminal: eraseChars preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -8303,7 +9759,8 @@ test "Terminal: eraseChars preserves background sgr" { test "Terminal: eraseChars handles refcounted styles" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -8325,7 +9782,8 @@ test "Terminal: eraseChars handles refcounted styles" { test "Terminal: eraseChars protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -8342,7 +9800,8 @@ test "Terminal: eraseChars protected attributes respected with iso" { test "Terminal: eraseChars protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -8361,7 +9820,8 @@ test "Terminal: eraseChars protected attributes ignored with dec most recent" { test "Terminal: eraseChars protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -8378,7 +9838,8 @@ test "Terminal: eraseChars protected attributes ignored with dec set" { test "Terminal: eraseChars wide char boundary conditions" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 1, .cols = 8 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 1, .cols = 8 }); defer t.deinit(alloc); try t.printString("😀a😀b😀"); @@ -8401,7 +9862,8 @@ test "Terminal: eraseChars wide char boundary conditions" { test "Terminal: eraseChars wide char splits proper cell boundaries" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 1, .cols = 30 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 1, .cols = 30 }); defer t.deinit(alloc); // This is a test for a bug: https://github.com/ghostty-org/ghostty/issues/2817 @@ -8431,7 +9893,8 @@ test "Terminal: eraseChars wide char splits proper cell boundaries" { test "Terminal: eraseChars wide char wrap boundary conditions" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 3, .cols = 8 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 3, .cols = 8 }); defer t.deinit(alloc); try t.printString(".......😀abcde😀......"); @@ -8460,9 +9923,42 @@ test "Terminal: eraseChars wide char wrap boundary conditions" { } } +test "Terminal: eraseChars clearing wrapped wide char marks spacer head row dirty" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 3, .cols = 5 }); + defer t.deinit(alloc); + + // The wide char doesn't fit so it wraps, leaving a spacer head at + // the end of the first row. + try t.printString("ABCD字"); + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + try testing.expectEqual(Cell.Wide.spacer_head, list_cell.cell.wide); + try testing.expect(list_cell.row.wrap); + } + + t.setCursorPos(2, 1); + t.clearDirty(); + t.eraseChars(1); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); + + // Erasing the wide char also clears the spacer head on the previous + // row, so that row must be dirty too. + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 2 } })); + + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + try testing.expectEqual(Cell.Wide.narrow, list_cell.cell.wide); + } +} + test "Terminal: reverseIndex" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // Initial value @@ -8489,7 +9985,8 @@ test "Terminal: reverseIndex" { test "Terminal: reverseIndex from the top" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -8522,7 +10019,8 @@ test "Terminal: reverseIndex from the top" { test "Terminal: reverseIndex top of scrolling region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 10 }); defer t.deinit(alloc); // Initial value @@ -8555,7 +10053,8 @@ test "Terminal: reverseIndex top of scrolling region" { test "Terminal: reverseIndex top of screen" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -8576,7 +10075,8 @@ test "Terminal: reverseIndex top of screen" { test "Terminal: reverseIndex not top of screen" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -8597,7 +10097,8 @@ test "Terminal: reverseIndex not top of screen" { test "Terminal: reverseIndex top/bottom margins" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -8618,7 +10119,8 @@ test "Terminal: reverseIndex top/bottom margins" { test "Terminal: reverseIndex outside top/bottom margins" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -8639,7 +10141,8 @@ test "Terminal: reverseIndex outside top/bottom margins" { test "Terminal: reverseIndex left/right margins" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -8661,7 +10164,8 @@ test "Terminal: reverseIndex left/right margins" { test "Terminal: reverseIndex outside left/right margins" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -8683,7 +10187,8 @@ test "Terminal: reverseIndex outside left/right margins" { test "Terminal: index" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); try t.index(); @@ -8701,7 +10206,8 @@ test "Terminal: index" { test "Terminal: index from the bottom" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(5, 1); @@ -8724,7 +10230,8 @@ test "Terminal: index from the bottom" { test "Terminal: index scrolling with hyperlink" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(5, 1); @@ -8769,7 +10276,8 @@ test "Terminal: index scrolling with hyperlink" { test "Terminal: index outside of scrolling region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); @@ -8780,7 +10288,8 @@ test "Terminal: index outside of scrolling region" { test "Terminal: index from the bottom outside of scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 2); @@ -8800,7 +10309,8 @@ test "Terminal: index from the bottom outside of scroll region" { test "Terminal: index no scroll region, top of screen" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -8820,7 +10330,8 @@ test "Terminal: index no scroll region, top of screen" { test "Terminal: index bottom of primary screen" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(5, 1); @@ -8841,7 +10352,8 @@ test "Terminal: index bottom of primary screen" { test "Terminal: index bottom of primary screen background sgr" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(5, 1); @@ -8874,7 +10386,8 @@ test "Terminal: index bottom of primary screen background sgr" { test "Terminal: index inside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -8895,7 +10408,8 @@ test "Terminal: index inside scroll region" { test "Terminal: index bottom of scroll region with hyperlinks" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 2); @@ -8943,7 +10457,8 @@ test "Terminal: index bottom of scroll region with hyperlinks" { test "Terminal: index bottom of scroll region clear hyperlinks" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5, .max_scrollback_bytes = 0 }); defer t.deinit(alloc); t.setTopAndBottomMargin(2, 3); @@ -8982,7 +10497,8 @@ test "Terminal: index bottom of scroll region clear hyperlinks" { test "Terminal: index bottom of scroll region with background SGR" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -9019,7 +10535,8 @@ test "Terminal: index bottom of scroll region with background SGR" { test "Terminal: index bottom of primary screen with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -9047,7 +10564,8 @@ test "Terminal: index bottom of primary screen with scroll region" { test "Terminal: index outside left/right margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -9071,7 +10589,8 @@ test "Terminal: index outside left/right margin" { test "Terminal: index inside left/right margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.printString("AAAAAA"); @@ -9105,7 +10624,8 @@ test "Terminal: index inside left/right margin" { test "Terminal: index bottom of scroll region creates scrollback" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -9130,7 +10650,8 @@ test "Terminal: index bottom of scroll region creates scrollback" { test "Terminal: index bottom of scroll region no scrollback" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5, .max_scrollback_bytes = 0 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -9151,7 +10672,8 @@ test "Terminal: index bottom of scroll region no scrollback" { test "Terminal: index bottom of scroll region blank line preserves SGR" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -9192,7 +10714,8 @@ 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 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("1\n2\n3\n4\n5"); @@ -9232,7 +10755,8 @@ test "Terminal: index bottom of scroll region with top margin and background SGR test "Terminal: index bottom of alt screen full region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 3, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 3, .cols = 5 }); defer t.deinit(alloc); try t.switchScreenMode(.@"1049", true); @@ -9265,7 +10789,8 @@ test "Terminal: index bottom of alt screen full region" { test "Terminal: index bottom of alt screen top region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.switchScreenMode(.@"1049", true); @@ -9292,7 +10817,8 @@ test "Terminal: index bottom of alt screen top region" { test "Terminal: scrollUp top region no scrollback" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5, .max_scrollback_bytes = 0 }); defer t.deinit(alloc); try t.printString("A\nB\nC\nD\nE"); @@ -9314,7 +10840,8 @@ test "Terminal: scrollUp top region no scrollback" { test "Terminal: cursorUp basic" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(3, 1); @@ -9331,7 +10858,8 @@ test "Terminal: cursorUp basic" { test "Terminal: cursorUp below top scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(2, 4); @@ -9349,7 +10877,8 @@ test "Terminal: cursorUp below top scroll margin" { test "Terminal: cursorUp above top scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(3, 5); @@ -9368,7 +10897,8 @@ test "Terminal: cursorUp above top scroll margin" { test "Terminal: cursorUp resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -9386,7 +10916,8 @@ test "Terminal: cursorUp resets wrap" { test "Terminal: cursorLeft no wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -9404,7 +10935,8 @@ test "Terminal: cursorLeft no wrap" { test "Terminal: cursorLeft unsets pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -9422,7 +10954,8 @@ test "Terminal: cursorLeft unsets pending wrap state" { test "Terminal: cursorLeft unsets pending wrap state with longer jump" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -9440,7 +10973,8 @@ test "Terminal: cursorLeft unsets pending wrap state with longer jump" { test "Terminal: cursorLeft reverse wrap with pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9461,7 +10995,8 @@ test "Terminal: cursorLeft reverse wrap with pending wrap state" { test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9482,7 +11017,8 @@ test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { test "Terminal: cursorLeft reverse wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9502,7 +11038,8 @@ test "Terminal: cursorLeft reverse wrap" { test "Terminal: cursorLeft reverse wrap with no soft wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9524,7 +11061,8 @@ test "Terminal: cursorLeft reverse wrap with no soft wrap" { test "Terminal: cursorLeft reverse wrap before left margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9542,7 +11080,8 @@ test "Terminal: cursorLeft reverse wrap before left margin" { test "Terminal: cursorLeft extended reverse wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9564,7 +11103,8 @@ test "Terminal: cursorLeft extended reverse wrap" { test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9586,7 +11126,8 @@ test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { test "Terminal: cursorLeft extended reverse wrap is priority if both set" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9609,7 +11150,8 @@ test "Terminal: cursorLeft extended reverse wrap is priority if both set" { test "Terminal: cursorLeft extended reverse wrap above top scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9625,7 +11167,8 @@ test "Terminal: cursorLeft extended reverse wrap above top scroll region" { test "Terminal: cursorLeft reverse wrap on first row" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -9641,7 +11184,8 @@ test "Terminal: cursorLeft reverse wrap on first row" { test "Terminal: cursorDown basic" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -9657,7 +11201,8 @@ test "Terminal: cursorDown basic" { test "Terminal: cursorDown above bottom scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -9674,7 +11219,8 @@ test "Terminal: cursorDown above bottom scroll margin" { test "Terminal: cursorDown below bottom scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setTopAndBottomMargin(1, 3); @@ -9692,7 +11238,8 @@ test "Terminal: cursorDown below bottom scroll margin" { test "Terminal: cursorDown resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -9710,7 +11257,8 @@ test "Terminal: cursorDown resets wrap" { test "Terminal: cursorRight resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -9728,7 +11276,8 @@ test "Terminal: cursorRight resets wrap" { test "Terminal: cursorRight to the edge of screen" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.cursorRight(100); @@ -9743,7 +11292,8 @@ test "Terminal: cursorRight to the edge of screen" { test "Terminal: cursorRight left of right margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.right = 2; @@ -9759,7 +11309,8 @@ test "Terminal: cursorRight left of right margin" { test "Terminal: cursorRight right of right margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.scrolling_region.right = 2; @@ -9776,7 +11327,8 @@ test "Terminal: cursorRight right of right margin" { test "Terminal: deleteLines simple" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -9808,7 +11360,8 @@ test "Terminal: deleteLines simple" { test "Terminal: deleteLines colors with bg color" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("ABC"); @@ -9849,7 +11402,8 @@ test "Terminal: deleteLines colors with bg color" { test "Terminal: deleteLines across page boundary marks all shifted rows dirty" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 10, .max_scrollback = 1024 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 10, .max_scrollback_bytes = 1024 }); defer t.deinit(alloc); const first_page = t.screens.active.pages.pages.first.?; @@ -9897,9 +11451,101 @@ test "Terminal: deleteLines across page boundary marks all shifted rows dirty" { } } +test "Terminal: deleteLines hyperlink-dense row crosses page boundary" { + // Regression test for the cross-page copy of deleteLines: when the + // shifted row carries more unique hyperlinks than the destination + // page's hyperlink capacity, the copy must increase the destination + // page's capacity and retry rather than corrupting the page list. + // + // This is the mirror of the insertLines variant: the dense row + // starts as the first row of the second page and is pulled up into + // the first page, which also happens to be the cursor's page so + // this exercises the cursor accounting of the capacity increase. + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 10, .max_scrollback_bytes = 1024 }); + defer t.deinit(alloc); + + const pages = &t.screens.active.pages; + + // Fill the first page so it is exactly full, then two more rows so + // the second page holds the last two active rows (y=3 and y=4). + const first_page_rows = pages.pages.first.?.capacity().rows; + for (0..first_page_rows + 1) |_| try t.linefeed(); + try testing.expect(pages.pages.first != pages.pages.last); + try testing.expectEqual(@as(usize, 2), pages.pages.last.?.rows()); + + // Marker rows so we can verify the shift afterwards. + t.setCursorPos(1, 1); + try t.printString("0"); + t.setCursorPos(2, 1); + try t.printString("1"); + t.setCursorPos(3, 1); + try t.printString("2"); + t.setCursorPos(5, 1); + try t.printString("4"); + + // Fill the first row of the second page (active y=3) with unique + // hyperlinks: more than the first page can hold with its default + // hyperlink capacity. Writing them grows the second page's + // capacity as needed; the first page keeps its default capacity. + t.setCursorPos(4, 1); + for (0..10) |i| { + var buf: [64]u8 = undefined; + const uri = try std.fmt.bufPrint(&buf, "http://example.com/{d}", .{i}); + try t.screens.active.startHyperlink(uri, null); + try t.print(@intCast('A' + i)); + t.screens.active.endHyperlink(); + } + { + const pin = pages.pin(.{ .active = .{ .y = 3 } }).?; + try testing.expectEqual(pages.pages.last.?, pin.node); + try testing.expectEqual(@as(usize, 0), @as(usize, pin.y)); + } + try testing.expect(pages.pages.first.?.page().hyperlink_set.layout.cap < 10); + + // Delete the top line: every row shifts up by one and the dense + // row crosses the page boundary into the first page. + t.setCursorPos(1, 1); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1\n2\nABCDEFGHIJ\n4", str); + } + + // The first page's hyperlink capacity had to grow to receive the + // row, proving the capacity-retry path ran. + try testing.expect(pages.pages.first.?.page().hyperlink_set.layout.cap >= 10); + + // Every cell of the dense row must still resolve to a real + // hyperlink entry with the correct URI. A half-applied shift + // leaves cells whose hyperlink flag is set but that have no map + // entry, which aborts in clearCells later. + for (0..10) |x| { + const list_cell = pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 2, + } }).?; + try testing.expect(list_cell.cell.hyperlink); + const page: *Page = list_cell.node.page(); + const id = page.lookupHyperlink(list_cell.cell).?; + const link = page.hyperlink_set.get(page.memory, id); + var buf: [64]u8 = undefined; + const uri = try std.fmt.bufPrint(&buf, "http://example.com/{d}", .{x}); + try testing.expectEqualStrings(uri, link.uri.slice(page.memory)); + } + + // All pages must pass integrity checks. + var node_: ?*PageList.List.Node = pages.pages.first; + while (node_) |node| : (node_ = node.next) node.page().assertIntegrity(); +} + test "Terminal: deleteLines (legacy)" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 80, .rows = 80 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); // Initial value @@ -9934,7 +11580,8 @@ test "Terminal: deleteLines (legacy)" { test "Terminal: deleteLines with scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 80, .rows = 80 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); // Initial value @@ -9977,7 +11624,8 @@ test "Terminal: deleteLines with scroll region" { test "Terminal: deleteLines with scroll region, large count" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 80, .rows = 80 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); // Initial value @@ -10020,7 +11668,8 @@ test "Terminal: deleteLines with scroll region, large count" { test "Terminal: deleteLines with scroll region, cursor outside of region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 80, .rows = 80 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 80, .rows = 80 }); defer t.deinit(alloc); // Initial value @@ -10055,7 +11704,8 @@ test "Terminal: deleteLines with scroll region, cursor outside of region" { test "Terminal: deleteLines resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -10073,7 +11723,8 @@ test "Terminal: deleteLines resets pending wrap" { test "Terminal: deleteLines resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 3, .cols = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 3, .cols = 3 }); defer t.deinit(alloc); try t.print('1'); @@ -10104,7 +11755,8 @@ test "Terminal: deleteLines resets wrap" { test "Terminal: deleteLines left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -10136,7 +11788,8 @@ test "Terminal: deleteLines left/right scroll region" { test "Terminal: deleteLines left/right scroll region from top" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -10167,7 +11820,8 @@ test "Terminal: deleteLines left/right scroll region from top" { test "Terminal: deleteLines left/right scroll region high count" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -10199,7 +11853,8 @@ test "Terminal: deleteLines left/right scroll region high count" { test "Terminal: deleteLines wide character spacer head" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); // Initial value @@ -10235,7 +11890,8 @@ test "Terminal: deleteLines wide character spacer head" { test "Terminal: deleteLines wide character spacer head left scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); // Initial value @@ -10276,7 +11932,8 @@ test "Terminal: deleteLines wide character spacer head left scroll margin" { test "Terminal: deleteLines wide character spacer head right scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); // Initial value @@ -10317,7 +11974,8 @@ test "Terminal: deleteLines wide character spacer head right scroll margin" { test "Terminal: deleteLines wide character spacer head left and right scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); // Initial value @@ -10359,7 +12017,8 @@ test "Terminal: deleteLines wide character spacer head left and right scroll mar test "Terminal: deleteLines wide character spacer head left (< 2) and right scroll margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); defer t.deinit(alloc); // Initial value @@ -10401,7 +12060,8 @@ test "Terminal: deleteLines wide character spacer head left (< 2) and right scro test "Terminal: deleteLines wide characters split by left/right scroll region boundaries" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); // Initial value @@ -10436,7 +12096,8 @@ test "Terminal: deleteLines wide characters split by left/right scroll region bo test "Terminal: deleteLines zero" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 5 }); defer t.deinit(alloc); // This should do nothing @@ -10446,7 +12107,8 @@ test "Terminal: deleteLines zero" { test "Terminal: default style is empty" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -10454,14 +12116,15 @@ test "Terminal: default style is empty" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint.data); try testing.expectEqual(@as(style.Id, 0), cell.style_id); } } test "Terminal: bold style" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -10470,7 +12133,7 @@ test "Terminal: bold style" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint.data); try testing.expect(cell.style_id != 0); 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); @@ -10479,7 +12142,8 @@ test "Terminal: bold style" { test "Terminal: garbage collect overwritten" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -10491,7 +12155,7 @@ test "Terminal: garbage collect overwritten" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint.data); try testing.expect(cell.style_id == 0); } @@ -10502,7 +12166,8 @@ test "Terminal: garbage collect overwritten" { test "Terminal: do not garbage collect old styles in use" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -10513,7 +12178,7 @@ test "Terminal: do not garbage collect old styles in use" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint); + try testing.expectEqual(@as(u21, 'B'), cell.content.codepoint.data); try testing.expect(cell.style_id == 0); } @@ -10524,7 +12189,8 @@ test "Terminal: do not garbage collect old styles in use" { test "Terminal: print with style marks the row as styled" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -10540,7 +12206,8 @@ test "Terminal: print with style marks the row as styled" { test "Terminal: DECALN" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 2 }); defer t.deinit(alloc); // Initial value @@ -10567,7 +12234,8 @@ test "Terminal: DECALN" { test "Terminal: decaln reset margins" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); // Initial value @@ -10585,7 +12253,8 @@ test "Terminal: decaln reset margins" { test "Terminal: decaln preserves color" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); // Initial value @@ -10614,7 +12283,8 @@ test "Terminal: decaln preserves color" { test "Terminal: DECALN resets graphemes with protected mode" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); // Add protected mode. A previous version of DECALN accidentally preserved @@ -10650,7 +12320,8 @@ test "Terminal: DECALN resets graphemes with protected mode" { test "Terminal: insertBlanks zero" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); try t.print('A'); @@ -10671,7 +12342,8 @@ test "Terminal: insertBlanks" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); try t.print('A'); @@ -10695,7 +12367,8 @@ test "Terminal: insertBlanks pushes off end" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); try t.print('A'); @@ -10718,7 +12391,8 @@ test "Terminal: insertBlanks more than size" { // NOTE: this is not verified with conformance tests, so these // tests might actually be verifying wrong behavior. const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); try t.print('A'); @@ -10739,7 +12413,8 @@ test "Terminal: insertBlanks more than size" { test "Terminal: insertBlanks no scroll region, fits" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -10758,7 +12433,8 @@ test "Terminal: insertBlanks no scroll region, fits" { test "Terminal: insertBlanks preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -10788,7 +12464,8 @@ test "Terminal: insertBlanks preserves background sgr" { test "Terminal: insertBlanks shift off screen" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); for (" ABC") |c| try t.print(c); @@ -10807,7 +12484,8 @@ test "Terminal: insertBlanks shift off screen" { test "Terminal: insertBlanks split multi-cell character" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); for ("123") |c| try t.print(c); @@ -10826,7 +12504,8 @@ test "Terminal: insertBlanks split multi-cell character" { test "Terminal: insertBlanks inside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); t.scrolling_region.left = 2; @@ -10849,7 +12528,8 @@ test "Terminal: insertBlanks inside left/right scroll region" { test "Terminal: insertBlanks outside left/right scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); t.setCursorPos(1, 4); @@ -10872,7 +12552,8 @@ test "Terminal: insertBlanks outside left/right scroll region" { test "Terminal: insertBlanks left/right scroll region large count" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); t.modes.set(.origin, true); @@ -10893,7 +12574,8 @@ test "Terminal: insertBlanks left/right scroll region large count" { test "Terminal: insertBlanks deleting graphemes" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Disable grapheme clustering @@ -10929,7 +12611,8 @@ test "Terminal: insertBlanks deleting graphemes" { test "Terminal: insertBlanks shift graphemes" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Enable grapheme clustering @@ -10965,7 +12648,8 @@ test "Terminal: insertBlanks shift graphemes" { test "Terminal: insertBlanks split multi-cell character from tail" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); try t.printString("橋123"); @@ -10989,7 +12673,8 @@ test "Terminal: insertBlanks shifts hyperlinks" { // link should be preserved, blanks should not be linked const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 2 }); defer t.deinit(alloc); try t.screens.active.startHyperlink("http://example.com", null); @@ -11030,7 +12715,8 @@ test "Terminal: insertBlanks shifts hyperlinks" { test "Terminal: insertBlanks pushes hyperlink off end completely" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); try t.screens.active.startHyperlink("http://example.com", null); @@ -11066,7 +12752,8 @@ test "Terminal: insertBlanks wide char straddling right margin" { // away via swapCells but leaves the orphaned spacer_tail in place, // causing a page integrity violation. const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Fill row: A B C D 橋 _ _ _ _ _ @@ -11101,7 +12788,8 @@ test "Terminal: insertBlanks wide char spacer_tail orphaned beyond right margin" // is left behind, causing a page integrity violation: // "spacer tail not following wide" const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Fill cols 0–9 with wide chars: 中中中中中 @@ -11131,7 +12819,8 @@ test "Terminal: insertBlanks wide char spacer_tail orphaned beyond right margin" test "Terminal: insert mode with space" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 2 }); defer t.deinit(alloc); for ("hello") |c| try t.print(c); @@ -11148,7 +12837,8 @@ test "Terminal: insert mode with space" { test "Terminal: insert mode doesn't wrap pushed characters" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("hello") |c| try t.print(c); @@ -11165,7 +12855,8 @@ test "Terminal: insert mode doesn't wrap pushed characters" { test "Terminal: insert mode does nothing at the end of the line" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("hello") |c| try t.print(c); @@ -11181,7 +12872,8 @@ test "Terminal: insert mode does nothing at the end of the line" { test "Terminal: insert mode with wide characters" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("hello") |c| try t.print(c); @@ -11198,7 +12890,8 @@ test "Terminal: insert mode with wide characters" { test "Terminal: insert mode with wide characters at end" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("well") |c| try t.print(c); @@ -11214,7 +12907,8 @@ test "Terminal: insert mode with wide characters at end" { test "Terminal: insert mode pushing off wide character" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); for ("123") |c| try t.print(c); @@ -11232,7 +12926,8 @@ test "Terminal: insert mode pushing off wide character" { test "Terminal: deleteChars" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -11251,7 +12946,8 @@ test "Terminal: deleteChars" { test "Terminal: deleteChars zero count" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -11270,7 +12966,8 @@ test "Terminal: deleteChars zero count" { test "Terminal: deleteChars more than half" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -11289,7 +12986,8 @@ test "Terminal: deleteChars more than half" { test "Terminal: deleteChars more than line width" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -11308,7 +13006,8 @@ test "Terminal: deleteChars more than line width" { test "Terminal: deleteChars should shift left" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -11327,7 +13026,8 @@ test "Terminal: deleteChars should shift left" { test "Terminal: deleteChars resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -11345,7 +13045,8 @@ test "Terminal: deleteChars resets pending wrap" { test "Terminal: deleteChars resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE123") |c| try t.print(c); @@ -11374,7 +13075,8 @@ test "Terminal: deleteChars resets wrap" { test "Terminal: deleteChars simple operation" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -11393,7 +13095,8 @@ test "Terminal: deleteChars simple operation" { test "Terminal: deleteChars preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 10 }); defer t.deinit(alloc); for ("ABC123") |c| try t.print(c); @@ -11426,7 +13129,8 @@ test "Terminal: deleteChars preserves background sgr" { test "Terminal: deleteChars outside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -11447,7 +13151,8 @@ test "Terminal: deleteChars outside scroll region" { test "Terminal: deleteChars inside scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); try t.printString("ABC123"); @@ -11468,7 +13173,8 @@ test "Terminal: deleteChars inside scroll region" { test "Terminal: deleteChars split wide character from spacer tail" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); try t.printString("A橋123"); @@ -11484,7 +13190,8 @@ test "Terminal: deleteChars split wide character from spacer tail" { test "Terminal: deleteChars split wide character from wide" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); try t.printString("橋123"); @@ -11494,20 +13201,21 @@ test "Terminal: deleteChars split wide character from wide" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, '1'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '1'), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: deleteChars split wide character from end" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 6, .rows = 10 }); defer t.deinit(alloc); try t.printString("A橋123"); @@ -11517,20 +13225,21 @@ test "Terminal: deleteChars split wide character from end" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0x6A4B), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x6A4B), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); } } test "Terminal: deleteChars with a spacer head at the end" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 10 }); defer t.deinit(alloc); try t.printString("0123橋123"); @@ -11548,14 +13257,15 @@ test "Terminal: deleteChars with a spacer head at the end" { { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } test "Terminal: deleteChars split wide character tail" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(1, t.cols - 1); @@ -11573,7 +13283,8 @@ test "Terminal: deleteChars split wide character tail" { test "Terminal: deleteChars wide char boundary conditions" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 1, .cols = 8 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 1, .cols = 8 }); defer t.deinit(alloc); // EXPLANATION(qwerasd): @@ -11637,7 +13348,8 @@ test "Terminal: deleteChars wide char boundary conditions" { test "Terminal: deleteChars wide char wrap boundary conditions" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 3, .cols = 8 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 3, .cols = 8 }); defer t.deinit(alloc); // EXPLANATION(qwerasd): @@ -11677,9 +13389,16 @@ test "Terminal: deleteChars wide char wrap boundary conditions" { } t.setCursorPos(2, 2); + t.clearDirty(); t.deleteChars(3); t.screens.active.cursor.page_pin.node.page().assertIntegrity(); + // Deleting the wide char also clears the spacer head on the previous + // row, so that row must be dirty too. + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 2 } })); + { const str = try t.plainString(alloc); defer testing.allocator.free(str); @@ -11693,7 +13412,8 @@ test "Terminal: deleteChars wide char wrap boundary conditions" { test "Terminal: deleteChars wide char across right margin" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 3, .cols = 8 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 3, .cols = 8 }); defer t.deinit(alloc); // scroll region @@ -11736,7 +13456,8 @@ test "Terminal: deleteChars wide char across right margin" { test "Terminal: saveCursor" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); try t.setAttribute(.{ .bold = {} }); @@ -11754,7 +13475,8 @@ test "Terminal: saveCursor" { test "Terminal: saveCursor position" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(1, 5); @@ -11774,7 +13496,8 @@ test "Terminal: saveCursor position" { test "Terminal: saveCursor pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setCursorPos(1, 5); @@ -11794,7 +13517,8 @@ test "Terminal: saveCursor pending wrap state" { test "Terminal: saveCursor origin mode" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.modes.set(.origin, true); @@ -11814,12 +13538,13 @@ test "Terminal: saveCursor origin mode" { test "Terminal: saveCursor resize" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setCursorPos(1, 10); t.saveCursor(); - try t.resize(alloc, 5, 5); + try t.resize(alloc, .{ .cols = 5, .rows = 5 }); t.restoreCursor(); try t.print('X'); @@ -11832,7 +13557,8 @@ test "Terminal: saveCursor resize" { test "Terminal: saveCursor protected pen" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -11847,7 +13573,8 @@ test "Terminal: saveCursor protected pen" { test "Terminal: saveCursor doesn't modify hyperlink state" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); try t.screens.active.startHyperlink("http://example.com", null); @@ -11863,9 +13590,10 @@ test "Terminal: restoreCursor uses default style on OutOfSpace" { // manualStyleUpdate fails with OutOfSpace (can't split a 1-row page // and styles are at max capacity). const alloc = testing.allocator; + const io_impl = testing.io; // Use a single row so the page can't be split - var t = try init(alloc, .{ .cols = 10, .rows = 1 }); + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 1 }); defer t.deinit(alloc); // Set a style and save the cursor @@ -11917,7 +13645,8 @@ test "Terminal: restoreCursor uses default style on OutOfSpace" { test "Terminal: setProtectedMode" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); try testing.expect(!t.screens.active.cursor.protected); @@ -11933,7 +13662,8 @@ test "Terminal: setProtectedMode" { test "Terminal: eraseLine simple erase right" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -11951,7 +13681,8 @@ test "Terminal: eraseLine simple erase right" { test "Terminal: eraseLine resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -11969,7 +13700,8 @@ test "Terminal: eraseLine resets pending wrap" { test "Terminal: eraseLine resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE123") |c| try t.print(c); @@ -11996,7 +13728,8 @@ test "Terminal: eraseLine resets wrap" { test "Terminal: eraseLine right preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -12029,7 +13762,8 @@ test "Terminal: eraseLine right preserves background sgr" { test "Terminal: eraseLine right wide character" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("AB") |c| try t.print(c); @@ -12049,7 +13783,8 @@ test "Terminal: eraseLine right wide character" { test "Terminal: eraseLine right protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -12068,7 +13803,8 @@ test "Terminal: eraseLine right protected attributes respected with iso" { test "Terminal: eraseLine right protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -12089,7 +13825,8 @@ test "Terminal: eraseLine right protected attributes ignored with dec most recen test "Terminal: eraseLine right protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -12108,7 +13845,8 @@ test "Terminal: eraseLine right protected attributes ignored with dec set" { test "Terminal: eraseLine right protected requested" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("12345678") |c| try t.print(c); @@ -12129,7 +13867,8 @@ test "Terminal: eraseLine right protected requested" { test "Terminal: eraseLine simple erase left" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -12147,7 +13886,8 @@ test "Terminal: eraseLine simple erase left" { test "Terminal: eraseLine left resets wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -12167,7 +13907,8 @@ test "Terminal: eraseLine left resets wrap" { test "Terminal: eraseLine left preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -12200,7 +13941,8 @@ test "Terminal: eraseLine left preserves background sgr" { test "Terminal: eraseLine left wide character" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("AB") |c| try t.print(c); @@ -12220,7 +13962,8 @@ test "Terminal: eraseLine left wide character" { test "Terminal: eraseLine left protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -12239,7 +13982,8 @@ test "Terminal: eraseLine left protected attributes respected with iso" { test "Terminal: eraseLine left protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -12260,7 +14004,8 @@ test "Terminal: eraseLine left protected attributes ignored with dec most recent test "Terminal: eraseLine left protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -12279,7 +14024,8 @@ test "Terminal: eraseLine left protected attributes ignored with dec set" { test "Terminal: eraseLine left protected requested" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("123456789") |c| try t.print(c); @@ -12300,7 +14046,8 @@ test "Terminal: eraseLine left protected requested" { test "Terminal: eraseLine complete preserves background sgr" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -12331,9 +14078,39 @@ test "Terminal: eraseLine complete preserves background sgr" { } } +test "Terminal: eraseLine complete resets wrap" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE123") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.wrap); + } + + t.setCursorPos(1, 1); + t.eraseLine(.complete, false); + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(!list_cell.row.wrap); + } + try t.print('X'); + try t.resize(alloc, .{ .rows = 5, .cols = 10 }); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n123", str); + } +} + test "Terminal: eraseLine complete protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -12352,7 +14129,8 @@ test "Terminal: eraseLine complete protected attributes respected with iso" { test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -12373,7 +14151,8 @@ test "Terminal: eraseLine complete protected attributes ignored with dec most re test "Terminal: eraseLine complete protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -12392,7 +14171,8 @@ test "Terminal: eraseLine complete protected attributes ignored with dec set" { test "Terminal: eraseLine complete protected requested" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); for ("123456789") |c| try t.print(c); @@ -12413,7 +14193,8 @@ test "Terminal: eraseLine complete protected requested" { test "Terminal: tabClear single" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 30, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 30, .rows = 5 }); defer t.deinit(alloc); t.horizontalTab(); @@ -12426,7 +14207,8 @@ test "Terminal: tabClear single" { test "Terminal: tabClear all" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 30, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 30, .rows = 5 }); defer t.deinit(alloc); t.tabClear(.all); @@ -12438,7 +14220,8 @@ test "Terminal: tabClear all" { test "Terminal: printRepeat simple" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("A"); @@ -12454,7 +14237,8 @@ test "Terminal: printRepeat simple" { test "Terminal: printRepeat wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString(" A"); @@ -12470,7 +14254,8 @@ test "Terminal: printRepeat wrap" { test "Terminal: printRepeat no previous character" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printRepeat(1); @@ -12485,7 +14270,8 @@ test "Terminal: printRepeat no previous character" { test "Terminal: printSlice simple ascii" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 3 }); defer t.deinit(alloc); try t.printSlice(&.{ 'h', 'e', 'l', 'l', 'o' }); @@ -12502,7 +14288,8 @@ test "Terminal: printSlice simple ascii" { test "Terminal: printSlice wraps and scrolls" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); // 12 chars: fills row 1 (5), row 2 (5), wraps+scrolls, 2 more. @@ -12519,7 +14306,8 @@ test "Terminal: printSlice wraps and scrolls" { test "Terminal: printSlice pending wrap state" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 2 }); defer t.deinit(alloc); try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e' }); @@ -12538,18 +14326,19 @@ test "Terminal: printSlice pending wrap state" { /// the other using printSlice() with random chunking, verifying that /// the results are identical. fn testPrintSliceDifferential( + io_impl: std.Io, alloc: Allocator, rand: std.Random, ops: usize, cols: size.CellCountInt, rows: size.CellCountInt, ) !void { - var t1 = try init(alloc, .{ + var t1 = try init(io_impl, alloc, .{ .cols = cols, .rows = rows, }); defer t1.deinit(alloc); - var t2 = try init(alloc, .{ + var t2 = try init(io_impl, alloc, .{ .cols = cols, .rows = rows, }); @@ -12558,11 +14347,12 @@ fn testPrintSliceDifferential( // 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, + '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; @@ -12700,20 +14490,22 @@ fn testPrintSliceDifferential( test "Terminal: printSlice differential fuzz vs print" { const alloc = testing.allocator; + const io_impl = testing.io; // 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); + try testPrintSliceDifferential(io_impl, alloc, rand, 500, 80, 24); + try testPrintSliceDifferential(io_impl, alloc, rand, 500, 10, 4); + try testPrintSliceDifferential(io_impl, alloc, rand, 500, 5, 2); + try testPrintSliceDifferential(io_impl, alloc, rand, 200, 2, 2); } test "Terminal: printAttributes" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); var storage: [64]u8 = undefined; @@ -12742,20 +14534,31 @@ test "Terminal: printAttributes" { try t.setAttribute(.inverse); try t.setAttribute(.invisible); try t.setAttribute(.strikethrough); + try t.setAttribute(.overline); try t.setAttribute(.{ .direct_color_fg = .{ .r = 100, .g = 200, .b = 255 } }); try t.setAttribute(.{ .direct_color_bg = .{ .r = 101, .g = 102, .b = 103 } }); defer t.setAttribute(.unset) catch unreachable; const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;1;2;3;4;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); + try testing.expectEqualStrings("0;1;2;3;4;53;5;7;8;9;38:2::100:200:255;48:2::101:102:103", buf); } - { - try t.setAttribute(.{ .underline = .single }); - defer t.setAttribute(.unset) catch unreachable; + const Case = struct { + underline: sgr.Attribute.Underline, + expected: []const u8, + }; + for ([_]Case{ + .{ .underline = .single, .expected = "0;4" }, + .{ .underline = .double, .expected = "0;4:2" }, + .{ .underline = .curly, .expected = "0;4:3" }, + .{ .underline = .dotted, .expected = "0;4:4" }, + .{ .underline = .dashed, .expected = "0;4:5" }, + }) |case| { + try t.setAttribute(.{ .underline = case.underline }); const buf = try t.printAttributes(&storage); - try testing.expectEqualStrings("0;4", buf); + try testing.expectEqualStrings(case.expected, buf); } + try t.setAttribute(.unset); { const buf = try t.printAttributes(&storage); try testing.expectEqualStrings("0", buf); @@ -12764,7 +14567,8 @@ test "Terminal: printAttributes" { test "Terminal: eraseDisplay simple erase below" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -12792,7 +14596,8 @@ test "Terminal: eraseDisplay simple erase below" { test "Terminal: eraseDisplay erase below preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -12832,7 +14637,8 @@ test "Terminal: eraseDisplay erase below preserves SGR bg" { test "Terminal: eraseDisplay below split multi-cell" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("AB橋C"); @@ -12854,7 +14660,8 @@ test "Terminal: eraseDisplay below split multi-cell" { test "Terminal: eraseDisplay below protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -12877,7 +14684,8 @@ test "Terminal: eraseDisplay below protected attributes respected with iso" { test "Terminal: eraseDisplay below protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -12902,7 +14710,8 @@ test "Terminal: eraseDisplay below protected attributes ignored with dec most re test "Terminal: eraseDisplay below protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -12925,7 +14734,8 @@ test "Terminal: eraseDisplay below protected attributes ignored with dec set" { test "Terminal: eraseDisplay below protected attributes respected with force" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -12948,7 +14758,8 @@ test "Terminal: eraseDisplay below protected attributes respected with force" { test "Terminal: eraseDisplay simple erase above" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -12975,7 +14786,8 @@ test "Terminal: eraseDisplay simple erase above" { test "Terminal: eraseDisplay erase above preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABC") |c| try t.print(c); @@ -13015,7 +14827,8 @@ test "Terminal: eraseDisplay erase above preserves SGR bg" { test "Terminal: eraseDisplay above split multi-cell" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.printString("AB橋C"); @@ -13037,7 +14850,8 @@ test "Terminal: eraseDisplay above split multi-cell" { test "Terminal: eraseDisplay above protected attributes respected with iso" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -13060,7 +14874,8 @@ test "Terminal: eraseDisplay above protected attributes respected with iso" { test "Terminal: eraseDisplay above protected attributes ignored with dec most recent" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.iso); @@ -13085,7 +14900,8 @@ test "Terminal: eraseDisplay above protected attributes ignored with dec most re test "Terminal: eraseDisplay above protected attributes ignored with dec set" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -13108,7 +14924,8 @@ test "Terminal: eraseDisplay above protected attributes ignored with dec set" { test "Terminal: eraseDisplay above protected attributes respected with force" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.setProtectedMode(.dec); @@ -13131,7 +14948,8 @@ test "Terminal: eraseDisplay above protected attributes respected with force" { test "Terminal: eraseDisplay protected complete" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -13159,7 +14977,8 @@ test "Terminal: eraseDisplay protected complete" { test "Terminal: eraseDisplay protected below" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -13181,7 +15000,8 @@ test "Terminal: eraseDisplay protected below" { test "Terminal: eraseDisplay scroll complete" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.print('A'); @@ -13198,7 +15018,8 @@ test "Terminal: eraseDisplay scroll complete" { test "Terminal: eraseDisplay protected above" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 3 }); defer t.deinit(alloc); try t.print('A'); @@ -13220,7 +15041,8 @@ test "Terminal: eraseDisplay protected above" { test "Terminal: eraseDisplay complete preserves cursor" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Set our cursur @@ -13237,7 +15059,8 @@ test "Terminal: eraseDisplay complete preserves cursor" { test "Terminal: semantic prompt" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Prompt @@ -13281,7 +15104,8 @@ test "Terminal: semantic prompt" { test "Terminal: semantic prompt continuations" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Prompt @@ -13331,7 +15155,8 @@ test "Terminal: index in prompt mode marks new row as prompt continuation" { // a newline, assume the new row is a prompt continuation (since Fish // doesn't emit OSC133 k=s markers for continuation lines). const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Start a prompt @@ -13368,7 +15193,8 @@ test "Terminal: index in input mode does not mark new row as prompt" { // Input mode should NOT trigger prompt continuation on newline // (only prompt mode does, not input mode) const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Start a prompt then switch to input @@ -13397,7 +15223,8 @@ test "Terminal: index in input mode does not mark new row as prompt" { test "Terminal: index in output mode does not mark new row as prompt" { // Output mode should NOT trigger prompt continuation const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Complete prompt cycle: prompt -> input -> output @@ -13426,7 +15253,8 @@ test "Terminal: OSC133C at x=0 on prompt row clears prompt mark" { // then immediately sends OSC133C (start output) at column 0, we // should clear the prompt continuation mark we just set. const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Start a prompt @@ -13463,7 +15291,8 @@ test "Terminal: OSC133C at x=0 on prompt row clears prompt mark" { test "Terminal: OSC133C at x>0 on prompt row does not clear prompt mark" { // If we're not at column 0, we shouldn't clear the prompt mark const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Start a prompt on a row @@ -13505,7 +15334,8 @@ test "Terminal: OSC133C at x>0 on prompt row does not clear prompt mark" { test "Terminal: multiple newlines in prompt mode marks all rows" { // Multiple newlines should each mark their row as prompt continuation const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Start a prompt @@ -13548,7 +15378,8 @@ test "Terminal: multiple newlines in prompt mode marks all rows" { test "Terminal: OSC133A click_events=1 sets click to click_events" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Verify default state is none @@ -13565,7 +15396,8 @@ test "Terminal: OSC133A click_events=1 sets click to click_events" { 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 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // Verify default state is none @@ -13582,7 +15414,8 @@ test "Terminal: OSC133A click_events=2 sets click to click_events (relative)" { test "Terminal: OSC133A click_events=0 does not set click_events" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // OSC 133;A with click_events=0 @@ -13597,7 +15430,8 @@ test "Terminal: OSC133A click_events=0 does not set click_events" { test "Terminal: OSC133A cl option sets click to cl value" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // OSC 133;A with cl=m (multiple) @@ -13611,7 +15445,8 @@ test "Terminal: OSC133A cl option sets click to cl value" { test "Terminal: OSC133A cl=line sets click to line" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); try t.semanticPrompt(.{ @@ -13624,7 +15459,8 @@ test "Terminal: OSC133A cl=line sets click to line" { test "Terminal: OSC133A click_events=1 takes priority over cl" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // OSC 133;A with both click_events=1 and cl=m @@ -13639,7 +15475,8 @@ test "Terminal: OSC133A click_events=1 takes priority over cl" { test "Terminal: OSC133A click_events=0 falls back to cl" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // OSC 133;A with click_events=0 and cl=v @@ -13654,7 +15491,8 @@ test "Terminal: OSC133A click_events=0 falls back to cl" { test "Terminal: OSC133A no click options leaves click as none" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 5 }); defer t.deinit(alloc); // OSC 133;A with no click-related options @@ -13668,7 +15506,8 @@ test "Terminal: OSC133A no click options leaves click as none" { test "Terminal: cursorIsAtPrompt" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 3 }); defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); @@ -13697,7 +15536,8 @@ test "Terminal: cursorIsAtPrompt" { test "Terminal: cursorIsAtPrompt alternate screen" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 2 }); defer t.deinit(alloc); try testing.expect(!t.cursorIsAtPrompt()); @@ -13711,8 +15551,66 @@ test "Terminal: cursorIsAtPrompt alternate screen" { try testing.expect(!t.cursorIsAtPrompt()); } +test "Terminal: cursor defaults update current default cursor" { + var t = try init(testing.io, testing.allocator, .{ + .cols = 10, + .rows = 10, + .default_cursor_style = .bar, + .default_cursor_blink = true, + }); + defer t.deinit(testing.allocator); + + // Initialization applies the configured defaults. + try testing.expect(t.cursor.is_default); + try testing.expectEqual(.bar, t.screens.active.cursor.cursor_style); + try testing.expect(t.modes.get(.cursor_blinking)); + + // Configuration changes are immediately visible while the cursor still + // follows its defaults. + t.setDefaultCursorStyle(.underline); + t.setDefaultCursorBlink(false); + try testing.expect(t.cursor.is_default); + try testing.expectEqual(.underline, t.screens.active.cursor.cursor_style); + try testing.expect(!t.modes.get(.cursor_blinking)); + + // Null restores the terminal emulator's blinking default. + t.setDefaultCursorBlink(null); + try testing.expect(t.modes.get(.cursor_blinking)); +} + +test "Terminal: cursor defaults do not override explicit cursor" { + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.setCursorStyle(.blinking_bar); + try testing.expect(!t.cursor.is_default); + try testing.expectEqual(.bar, t.screens.active.cursor.cursor_style); + try testing.expect(t.modes.get(.cursor_blinking)); + + // New defaults are retained without replacing the explicit appearance. + t.setDefaultCursorStyle(.underline); + t.setDefaultCursorBlink(false); + try testing.expectEqual(.underline, t.cursor.default_style); + try testing.expectEqual(false, t.cursor.default_blink); + try testing.expectEqual(.bar, t.screens.active.cursor.cursor_style); + try testing.expect(t.modes.get(.cursor_blinking)); + + // Selecting the default applies the values that changed above. + t.setCursorStyle(.default); + try testing.expect(t.cursor.is_default); + try testing.expectEqual(.underline, t.screens.active.cursor.cursor_style); + try testing.expect(!t.modes.get(.cursor_blinking)); + + // A full reset also leaves the cursor on the configured defaults. + t.setCursorStyle(.steady_block); + t.fullReset(); + try testing.expect(t.cursor.is_default); + try testing.expectEqual(.underline, t.screens.active.cursor.cursor_style); + try testing.expect(!t.modes.get(.cursor_blinking)); +} + test "Terminal: fullReset with a non-empty pen" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); @@ -13734,7 +15632,7 @@ test "Terminal: fullReset with a non-empty pen" { } test "Terminal: fullReset hyperlink" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.screens.active.startHyperlink("http://example.com", null); @@ -13743,7 +15641,7 @@ test "Terminal: fullReset hyperlink" { } test "Terminal: fullReset with a non-empty saved cursor" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); @@ -13764,7 +15662,7 @@ test "Terminal: fullReset with a non-empty saved cursor" { } test "Terminal: fullReset origin mode" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); t.setCursorPos(3, 5); @@ -13778,7 +15676,7 @@ test "Terminal: fullReset origin mode" { } test "Terminal: fullReset status display" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); t.status_display = .status_line; @@ -13786,9 +15684,37 @@ test "Terminal: fullReset status display" { try testing.expect(t.status_display == .main); } +test "Terminal: fullReset preserves kitty graphics limits" { + if (comptime !build_options.kitty_graphics) return error.SkipZigTest; + + const alloc = testing.allocator; + const temp_dir = "/tmp/ghostty-kitty-images"; + + var t = try init(testing.io, alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + t.setKittyGraphicsLoadingLimits(.allWithTempDir(temp_dir)); + for ([_]usize{ 1234, 0 }) |total_limit| { + t.setKittyGraphicsSizeLimit(alloc, total_limit); + t.fullReset(); + + const storage = &t.screens.active.kitty_images; + try testing.expectEqual(total_limit, storage.total_limit); + try testing.expect(storage.image_limits.file); + try testing.expect(storage.image_limits.shared_memory); + switch (storage.image_limits.temporary_file) { + .enabled => |value| try testing.expectEqualStrings( + temp_dir, + value.directory, + ), + .disabled => return error.TestUnexpectedResult, + } + } +} + // https://github.com/mitchellh/ghostty/issues/1607 test "Terminal: fullReset clears alt screen kitty keyboard state" { - var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10 }); defer t.deinit(testing.allocator); try t.switchScreenMode(.@"1049", true); @@ -13806,7 +15732,7 @@ test "Terminal: fullReset clears alt screen kitty keyboard state" { } test "Terminal: fullReset default modes" { - var t = try init(testing.allocator, .{ + var t = try init(testing.io, testing.allocator, .{ .cols = 10, .rows = 10, .default_modes = .{ .grapheme_cluster = true }, @@ -13818,7 +15744,7 @@ test "Terminal: fullReset default modes" { } test "Terminal: fullReset tracked pins" { - var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + var t = try init(testing.io, testing.allocator, .{ .cols = 80, .rows = 80 }); defer t.deinit(testing.allocator); // Create a tracked pin @@ -13832,12 +15758,13 @@ test "Terminal: fullReset tracked pins" { // this test around to ensure we don't regress at multiple layers. test "Terminal: resize less cols with wide char then print" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 3, .rows = 3 }); defer t.deinit(alloc); try t.print('x'); try t.print('😀'); // 0x1F600 - try t.resize(alloc, 2, 3); + try t.resize(alloc, .{ .cols = 2, .rows = 3 }); t.setCursorPos(1, 2); try t.print('😀'); // 0x1F600 } @@ -13846,27 +15773,29 @@ test "Terminal: resize less cols with wide char then print" { // This was found via fuzzing so its highly specific. test "Terminal: resize with left and right margin set" { const alloc = testing.allocator; + const io_impl = testing.io; const cols = 70; const rows = 23; - var t = try init(alloc, .{ .cols = cols, .rows = rows }); + var t = try init(io_impl, alloc, .{ .cols = cols, .rows = rows }); defer t.deinit(alloc); t.modes.set(.enable_left_and_right_margin, true); try t.print('0'); t.modes.set(.enable_mode_3, true); - try t.resize(alloc, cols, rows); + try t.resize(alloc, .{ .cols = cols, .rows = rows }); t.setLeftAndRightMargin(2, 0); try t.printRepeat(1850); _ = t.modes.restore(.enable_mode_3); - try t.resize(alloc, cols, rows); + try t.resize(alloc, .{ .cols = cols, .rows = rows }); } // https://github.com/mitchellh/ghostty/issues/1343 test "Terminal: resize with wraparound off" { const alloc = testing.allocator; + const io_impl = testing.io; const cols = 4; const rows = 2; - var t = try init(alloc, .{ .cols = cols, .rows = rows }); + var t = try init(io_impl, alloc, .{ .cols = cols, .rows = rows }); defer t.deinit(alloc); t.modes.set(.wraparound, false); @@ -13875,7 +15804,7 @@ test "Terminal: resize with wraparound off" { try t.print('2'); try t.print('3'); const new_cols = 2; - try t.resize(alloc, new_cols, rows); + try t.resize(alloc, .{ .cols = new_cols, .rows = rows }); const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -13884,9 +15813,10 @@ test "Terminal: resize with wraparound off" { test "Terminal: resize with wraparound on" { const alloc = testing.allocator; + const io_impl = testing.io; const cols = 4; const rows = 2; - var t = try init(alloc, .{ .cols = cols, .rows = rows }); + var t = try init(io_impl, alloc, .{ .cols = cols, .rows = rows }); defer t.deinit(alloc); t.modes.set(.wraparound, true); @@ -13895,7 +15825,7 @@ test "Terminal: resize with wraparound on" { try t.print('2'); try t.print('3'); const new_cols = 2; - try t.resize(alloc, new_cols, rows); + try t.resize(alloc, .{ .cols = new_cols, .rows = rows }); const str = try t.plainString(testing.allocator); defer testing.allocator.free(str); @@ -13904,7 +15834,8 @@ test "Terminal: resize with wraparound on" { test "Terminal: resize with high unique style per cell" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); for (0..t.rows) |y| { @@ -13919,12 +15850,13 @@ test "Terminal: resize with high unique style per cell" { } } - try t.resize(alloc, 60, 30); + try t.resize(alloc, .{ .cols = 60, .rows = 30 }); } test "Terminal: resize with high unique style per cell with wrapping" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 30, .rows = 30 }); defer t.deinit(alloc); const cell_count: u16 = @intCast(t.rows * t.cols); @@ -13940,12 +15872,13 @@ test "Terminal: resize with high unique style per cell with wrapping" { try t.print('x'); } - try t.resize(alloc, 60, 30); + try t.resize(alloc, .{ .cols = 60, .rows = 30 }); } test "Terminal: resize with reflow and saved cursor" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 3 }); defer t.deinit(alloc); try t.printString("1A2B"); t.setCursorPos(2, 2); @@ -13955,7 +15888,7 @@ test "Terminal: resize with reflow and saved cursor" { .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); + try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint.data); } { @@ -13965,7 +15898,7 @@ test "Terminal: resize with reflow and saved cursor" { } t.saveCursor(); - try t.resize(alloc, 5, 3); + try t.resize(alloc, .{ .cols = 5, .rows = 3 }); t.restoreCursor(); { @@ -13981,13 +15914,14 @@ test "Terminal: resize with reflow and saved cursor" { .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); + try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint.data); } } test "Terminal: resize with reflow and saved cursor pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 2, .rows = 3 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 2, .rows = 3 }); defer t.deinit(alloc); try t.printString("1A2B"); { @@ -13996,7 +15930,7 @@ test "Terminal: resize with reflow and saved cursor pending wrap" { .y = t.screens.active.cursor.y, } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); + try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint.data); } { @@ -14006,7 +15940,7 @@ test "Terminal: resize with reflow and saved cursor pending wrap" { } t.saveCursor(); - try t.resize(alloc, 5, 3); + try t.resize(alloc, .{ .cols = 5, .rows = 3 }); t.restoreCursor(); { @@ -14026,7 +15960,8 @@ test "Terminal: resize with reflow and saved cursor pending wrap" { test "Terminal: DECCOLM without DEC mode 40" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.@"132_column", true); @@ -14038,7 +15973,8 @@ test "Terminal: DECCOLM without DEC mode 40" { test "Terminal: DECCOLM unset" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.enable_mode_3, true); @@ -14049,7 +15985,8 @@ test "Terminal: DECCOLM unset" { test "Terminal: DECCOLM resets pending wrap" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); for ("ABCDE") |c| try t.print(c); @@ -14064,7 +16001,8 @@ test "Terminal: DECCOLM resets pending wrap" { test "Terminal: DECCOLM preserves SGR bg" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); try t.setAttribute(.{ .direct_color_bg = .{ @@ -14088,7 +16026,8 @@ test "Terminal: DECCOLM preserves SGR bg" { test "Terminal: DECCOLM resets scroll region" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); t.modes.set(.enable_left_and_right_margin, true); @@ -14107,7 +16046,8 @@ test "Terminal: DECCOLM resets scroll region" { test "Terminal: mode 47 alt screen plain" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Print on primary screen @@ -14158,7 +16098,8 @@ test "Terminal: mode 47 alt screen plain" { test "Terminal: mode 47 copies cursor both directions" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Color our cursor red @@ -14194,7 +16135,8 @@ test "Terminal: mode 47 copies cursor both directions" { test "Terminal: mode 1047 alt screen plain" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Print on primary screen @@ -14245,7 +16187,8 @@ test "Terminal: mode 1047 alt screen plain" { test "Terminal: mode 1047 copies cursor both directions" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Color our cursor red @@ -14281,7 +16224,8 @@ test "Terminal: mode 1047 copies cursor both directions" { test "Terminal: mode 1049 alt screen plain" { const alloc = testing.allocator; - var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .rows = 5, .cols = 5 }); defer t.deinit(alloc); // Print on primary screen @@ -14346,7 +16290,8 @@ test "Terminal: mode 1049 alt screen plain" { // characters straddling the right margin boundary leave orphaned spacer_tails. test "Terminal: deleteLines wide char at right margin with full clear" { const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 80, .rows = 24 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 80, .rows = 24 }); defer t.deinit(alloc); // Place a wide character at col 39 (1-indexed) on several rows. @@ -14368,8 +16313,11 @@ test "Terminal: deleteLines wide char at right margin with full clear" { } test "Terminal: glyph APC stores session glossary entries" { + if (comptime !build_options.glyph_protocol) return error.SkipZigTest; + const alloc = testing.allocator; - var t = try init(alloc, .{ .cols = 80, .rows = 24 }); + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 80, .rows = 24 }); defer t.deinit(alloc); var register_parser = glyph.CommandParser.init(alloc, 1024 * 1024); @@ -14398,3 +16346,161 @@ test "Terminal: glyph APC stores session glossary entries" { t.fullReset(); try testing.expect(!t.glyph_glossary.contains(0xE0A0)); } + +test "Terminal: scroll region linefeed recycled row has default metadata" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 5 }); + defer t.deinit(alloc); + + // A soft-wrapped line across rows 0-2 so that row 1 has both wrap + // flags set. Mark row 1 as a prompt as well (OSC 133 A would). + for (0..12) |_| try t.print('A'); + t.screens.active.pages.getCell( + .{ .active = .{ .y = 1 } }, + ).?.row.semantic_prompt = .prompt; + + // DECSTBM rows 2-4, cursor to the region bottom, and linefeed: + // row 1 is discarded and its Row storage recycled as the new + // blank region-bottom row. + t.setTopAndBottomMargin(2, 4); + t.setCursorPos(4, 1); + try t.linefeed(); + + { + const rac = t.screens.active.pages.getCell(.{ .active = .{ .y = 3 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: alt screen scroll up recycled row has default metadata" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + try t.switchScreenMode(.@"1049", true); + + // A soft-wrapped line across rows 0-1 and a prompt mark on row 0. + for (0..7) |_| try t.print('A'); + t.screens.active.pages.getCell( + .{ .active = .{} }, + ).?.row.semantic_prompt = .prompt; + + // Scroll up: with no scrollback, row 0 is discarded and its Row + // storage recycled as the new blank bottom row. + try t.scrollUp(1); + + { + const rac = t.screens.active.pages.getCell(.{ .active = .{ .y = 2 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: insertLines count over region blanks row metadata" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 5 }); + defer t.deinit(alloc); + + // A soft-wrapped line across rows 0-2 so that row 1 has both wrap + // flags set, plus a prompt mark on row 1. + for (0..12) |_| try t.print('A'); + t.screens.active.pages.getCell( + .{ .active = .{ .y = 1 } }, + ).?.row.semantic_prompt = .prompt; + + // Insert more lines than remain in the region: every row from the + // cursor to the region bottom is blanked in place, with no shifts. + t.setCursorPos(2, 1); + t.insertLines(10); + + for (1..5) |y| { + const rac = t.screens.active.pages.getCell( + .{ .active = .{ .y = @intCast(y) } }, + ).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: deleteLines count over region blanks row metadata" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 5 }); + defer t.deinit(alloc); + + for (0..12) |_| try t.print('A'); + t.screens.active.pages.getCell( + .{ .active = .{ .y = 1 } }, + ).?.row.semantic_prompt = .prompt; + + t.setCursorPos(2, 1); + t.deleteLines(10); + + for (1..5) |y| { + const rac = t.screens.active.pages.getCell( + .{ .active = .{ .y = @intCast(y) } }, + ).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: deleteLines blank row does not retain semantic prompt" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + // Mark row 0 as a prompt row, then delete it. The blank row that + // appears at the region bottom reuses the deleted row's storage + // and must not read as a prompt (e.g. for prompt navigation). + try t.print('$'); + t.screens.active.pages.getCell( + .{ .active = .{} }, + ).?.row.semantic_prompt = .prompt; + + t.setCursorPos(1, 1); + t.deleteLines(1); + + { + const rac = t.screens.active.pages.getCell(.{ .active = .{ .y = 2 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Terminal: eraseDisplay complete ignores stale prompt on recycled row" { + const alloc = testing.allocator; + const io_impl = testing.io; + var t = try init(io_impl, alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + // Screen content that must NOT enter the scrollback on a clear. + try t.printString("hello"); + + // Mark row 1 as a prompt row and then discard it with a region + // scroll, recycling its storage as the blank bottom row. + t.screens.active.pages.getCell( + .{ .active = .{ .y = 1 } }, + ).?.row.semantic_prompt = .prompt; + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(3, 1); + try t.linefeed(); + t.setTopAndBottomMargin(0, 0); + + // ED2: since no prompt is on screen, this must NOT take the + // scroll-and-clear path that pushes content into scrollback. A + // stale prompt flag on the recycled blank bottom row would. + t.eraseDisplay(.complete, false); + + try testing.expectEqual(t.screens.active.pages.rows, t.screens.active.pages.total_rows); +} |
