diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/Terminal.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/Terminal.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/Terminal.zig | 13089 |
1 files changed, 13089 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig new file mode 100644 index 00000000..99536e7a --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/Terminal.zig @@ -0,0 +1,13089 @@ +//! The primary terminal emulation structure. This represents a single +//! "terminal" containing a grid of characters and exposes various operations +//! on that grid. This also maintains the scrollback buffer. +const Terminal = @This(); + +const std = @import("std"); +const build_options = @import("terminal_options"); +const lib = @import("lib.zig"); +const assert = @import("../quirks.zig").inlineAssert; +const testing = std.testing; +const Allocator = std.mem.Allocator; +const unicode = @import("../unicode/main.zig"); +const uucode = @import("uucode"); + +const ansi = @import("ansi.zig"); +const modespkg = @import("modes.zig"); +const charsets = @import("charsets.zig"); +const csi = @import("csi.zig"); +const hyperlink = @import("hyperlink.zig"); +const kitty = @import("kitty.zig"); +const osc = @import("osc.zig"); +const point = @import("point.zig"); +const sgr = @import("sgr.zig"); +const Tabstops = @import("Tabstops.zig"); +const color = @import("color.zig"); +const mouse = @import("mouse.zig"); +const Stream = @import("stream_terminal.zig").Stream; + +const size = @import("size.zig"); +const pagepkg = @import("page.zig"); +const style = @import("style.zig"); +const Screen = @import("Screen.zig"); +const ScreenSet = @import("ScreenSet.zig"); +const Page = pagepkg.Page; +const Cell = pagepkg.Cell; +const Row = pagepkg.Row; + +const log = std.log.scoped(.terminal); + +/// Default tabstop interval +const TABSTOP_INTERVAL = 8; + +/// The set of screens behind this terminal (e.g. primary vs alternate). +screens: ScreenSet, + +/// Whether we're currently writing to the status line (DECSASD and DECSSDT). +/// We don't support a status line currently so we just black hole this +/// data so that it doesn't mess up our main display. +status_display: ansi.StatusDisplay = .main, + +/// Where the tabstops are. +tabstops: Tabstops, + +/// The size of the terminal. +rows: size.CellCountInt, +cols: size.CellCountInt, + +/// The size of the screen in pixels. This is used for pty events and images +width_px: u32 = 0, +height_px: u32 = 0, + +/// The current scrolling region. +scrolling_region: ScrollingRegion, + +/// The last reported pwd, if any. +pwd: std.ArrayList(u8), + +/// The title of the terminal as set by escape sequences (e.g. OSC 0/2). +title: std.ArrayList(u8), + +/// The color state for this terminal. +colors: Colors, + +/// The previous printed character. This is used for the repeat previous +/// char CSI (ESC [ <n> b). +previous_char: ?u21 = null, + +/// The modes that this terminal currently has active. +modes: modespkg.ModeState = .{}, + +/// The most recently set mouse shape for the terminal. +mouse_shape: mouse.Shape = .text, + +/// 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 + // prompts (OSC133) can annotate their new prompts with `redraw=0` to + // disable clearing the prompt on resize. + shell_redraws_prompt: osc.semantic_prompt.Redraw = .true, + + // This is set via ESC[4;2m. Any other modify key mode just sets + // this to false and we act in mode 1 by default. + modify_other_keys_2: bool = false, + + /// The mouse event mode and format. These are set to the last + /// set mode in modes. You can't get the right event/format to use + /// based on modes alone because modes don't show you what order + /// this was called so we have to track it separately. + mouse_event: mouse.Event = .none, + mouse_format: mouse.Format = .x10, + + /// Set via the XTSHIFTESCAPE sequence. If true (XTSHIFTESCAPE = 1) + /// then we want to capture the shift key for the mouse protocol + /// if the configuration allows it. + mouse_shift_capture: enum(u2) { null, false, true } = .null, + + /// True if the window is focused. + focused: 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. + password_input: bool = false, + + /// True if the terminal should perform selection scrolling. + selection_scroll: bool = false, + + /// Dirty flag used only by the search thread. The renderer is expected + /// to set this to true if the viewport was dirty as it was rendering. + /// This is used by the search thread to more efficiently re-search the + /// viewport and active area. + /// + /// Since the renderer is going to inspect the viewport/active area ANYWAYS, + /// this lets our search thread do less work and hold the lock less time, + /// resulting in more throughput for everything. + search_viewport_dirty: bool = false, + + /// Dirty flags for the renderer. + dirty: Dirty = .{}, +} = .{}, + +/// The various color configurations a terminal maintains and that can +/// be set dynamically via OSC, with defaults usually coming from a +/// configuration. +pub const Colors = struct { + background: color.DynamicRGB, + foreground: color.DynamicRGB, + cursor: color.DynamicRGB, + palette: color.DynamicPalette, + + pub const default: Colors = .{ + .background = .unset, + .foreground = .unset, + .cursor = .unset, + .palette = .default, + }; +}; + +/// This is a set of dirty flags the renderer can use to determine +/// what parts of the screen need to be redrawn. It is up to the renderer +/// to clear these flags. +/// +/// This only contains dirty flags for terminal state, not for the screen +/// state. The screen state has its own dirty flags. +pub const Dirty = packed struct { + /// Set when the color palette is modified in any way. + palette: bool = false, + + /// Set when the reverse colors mode is modified. + reverse_colors: bool = false, + + /// Screen clear of some kind. This can be due to a screen change, + /// erase display, etc. + clear: bool = false, + + /// Set when the pre-edit is modified. + preedit: bool = false, +}; + +/// Scrolling region is the area of the screen designated where scrolling +/// occurs. When scrolling the screen, only this viewport is scrolled. +pub const ScrollingRegion = struct { + // Top and bottom of the scroll region (0-indexed) + // Precondition: top < bottom + top: size.CellCountInt, + bottom: size.CellCountInt, + + // Left/right scroll regions. + // Precondition: right > left + // Precondition: right <= cols - 1 + left: size.CellCountInt, + right: size.CellCountInt, +}; + +pub const Options = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, + max_scrollback: usize = 10_000, + colors: Colors = .default, + + /// The default mode state. When the terminal gets a reset, it + /// will revert back to this state. + default_modes: modespkg.ModePacked = .{}, +}; + +/// Initialize a new terminal. +pub fn init( + alloc: Allocator, + opts: Options, +) !Terminal { + const cols = opts.cols; + const rows = opts.rows; + + var screen_set: ScreenSet = try .init(alloc, .{ + .cols = cols, + .rows = rows, + .max_scrollback = opts.max_scrollback, + }); + errdefer screen_set.deinit(alloc); + + return .{ + .cols = cols, + .rows = rows, + .screens = screen_set, + .tabstops = try .init(alloc, cols, TABSTOP_INTERVAL), + .scrolling_region = .{ + .top = 0, + .bottom = rows - 1, + .left = 0, + .right = cols - 1, + }, + .pwd = .empty, + .title = .empty, + .colors = opts.colors, + .modes = .{ + .values = opts.default_modes, + .default = opts.default_modes, + }, + }; +} + +pub fn deinit(self: *Terminal, alloc: Allocator) void { + self.tabstops.deinit(alloc); + self.screens.deinit(alloc); + self.pwd.deinit(alloc); + self.title.deinit(alloc); + self.* = undefined; +} + +/// Return a terminal.Stream that can process VT streams and update this +/// terminal state. The streams will only process read-only data that +/// modifies terminal state. +/// +/// Sequences that query or otherwise require output will be ignored. +/// If you want to handle side effects, use `vtHandler` and set the +/// effects field yourself, then initialize a stream. +/// +/// This must be deinitialized by the caller. +/// +/// Important: this creates a new stream each time with fresh parser state. +/// If you need to persist parser state across multiple writes (e.g. +/// 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()); +} + +/// This is the handler-side only for vtStream. +pub fn vtHandler(self: *Terminal) Stream.Handler { + return .init(self); +} + +/// The general allocator we should use for this terminal. +pub fn gpa(self: *Terminal) Allocator { + return self.screens.active.alloc; +} + +/// 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); + var it = view.iterator(); + while (it.nextCodepoint()) |cp| { + switch (cp) { + '\n' => { + self.carriageReturn(); + try self.linefeed(); + }, + + else => try self.print(cp), + } + } +} + +/// 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); + } +} + +pub fn print(self: *Terminal, c: u21) !void { + // log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x }); + + // If we're not on the main display, do nothing for now + if (self.status_display != .main) { + @branchHint(.cold); + return; + } + + // After doing any printing, wrapping, scrolling, etc. we want to ensure + // that our screen remains in a consistent state. + defer self.screens.active.assertIntegrity(); + + // Our right margin depends where our cursor is now. + const right_limit = if (self.screens.active.cursor.x > self.scrolling_region.right) + self.cols + else + self.scrolling_region.right + 1; + + // Perform grapheme clustering if grapheme support is enabled (mode 2027). + // This is MUCH slower than the normal path so the conditional below is + // purposely ordered in least-likely to most-likely so we can drop out + // as quickly as possible. + if (c > 255 and + self.modes.get(.grapheme_cluster) and + self.screens.active.cursor.x > 0) + grapheme: { + @branchHint(.unlikely); + // We need the previous cell to determine if we're at a grapheme + // break or not. If we are NOT, then we are still combining the + // same grapheme, and will be appending to prev.cell. Otherwise, we are + // in a new cell. + const Prev = struct { cell: *Cell, left: size.CellCountInt }; + var prev: Prev = prev: { + const left: size.CellCountInt = left: { + // If we have wraparound, then we use the prev col unless + // there's a pending wrap, in which case we use the current. + if (self.modes.get(.wraparound)) { + break :left @intFromBool(!self.screens.active.cursor.pending_wrap); + } + + // If we do not have wraparound, the logic is trickier. If + // we're not on the last column, then we just use the previous + // column. Otherwise, we need to check if there is text to + // figure out if we're attaching to the prev or current. + if (self.screens.active.cursor.x != right_limit - 1) break :left 1; + break :left @intFromBool(self.screens.active.cursor.page_cell.codepoint() == 0); + }; + + // If the previous cell is a wide spacer tail, then we actually + // want to use the cell before that because that has the actual + // content. + const immediate = self.screens.active.cursorCellLeft(left); + break :prev switch (immediate.wide) { + else => .{ .cell = immediate, .left = left }, + .spacer_tail => .{ + .cell = self.screens.active.cursorCellLeft(left + 1), + .left = left + 1, + }, + }; + }; + + // If our cell has no content, then this is a new cell and + // necessarily a grapheme break. + if (prev.cell.codepoint() == 0) break :grapheme; + + const grapheme_break = brk: { + var state: uucode.grapheme.BreakState = .default; + var cp1: u21 = prev.cell.content.codepoint; + if (prev.cell.hasGrapheme()) { + const cps = self.screens.active.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?; + for (cps) |cp2| { + // log.debug("cp1={x} cp2={x}", .{ cp1, cp2 }); + assert(!unicode.graphemeBreak(cp1, cp2, &state)); + cp1 = cp2; + } + } + + // log.debug("cp1={x} cp2={x} end", .{ cp1, c }); + break :brk unicode.graphemeBreak(cp1, c, &state); + }; + + // If we can NOT break, this means that "c" is part of a grapheme + // with the previous char. + if (!grapheme_break) { + var desired_wide: enum { no_change, wide, narrow } = .no_change; + + // If this is an emoji variation selector then we need to modify + // the cell width accordingly. VS16 makes the character wide and + // VS15 makes it narrow. + if (c == 0xFE0F or c == 0xFE0E) { + const prev_props = unicode.table.get(prev.cell.content.codepoint); + // Check if it is a valid variation sequence in + // emoji-variation-sequences.txt, and if not, ignore the char. + if (!prev_props.emoji_vs_base) return; + + switch (c) { + 0xFE0F => desired_wide = .wide, + 0xFE0E => desired_wide = .narrow, + else => unreachable, + } + } else if (!unicode.table.get(c).width_zero_in_grapheme) { + // If we have a code point that contributes to the width of a + // grapheme, it necessarily means that we're at least at width + // 2, since the first code point must be at least width 1 to + // start. (Note that Prepend code points could effectively mean + // the first code point should be width 0, but we don't handle + // that yet.) + desired_wide = .wide; + } + + switch (desired_wide) { + .wide => wide: { + if (prev.cell.wide == .wide) break :wide; + + // Move our cursor back to the previous. We'll move + // the cursor within this block to the proper location. + self.screens.active.cursorLeft(prev.left); + + // If we don't have space for the wide char, we need to + // insert spacers and wrap. We need special handling if the + // previous cell has grapheme data. + if (self.screens.active.cursor.x == right_limit - 1) { + if (!self.modes.get(.wraparound)) return; + + // This path can write a spacer_head before printWrap + // which can trigger integrity violations so mark + // the wrap first to keep the intermediary state valid + // if we're wrapping. + 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; + 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; + + try self.printWrap(); + self.printCell(prev_cp, .wide); + + const new_pin = self.screens.active.cursor.page_pin.*; + const new_rac = new_pin.rowAndCell(); + + transfer_graphemes: { + var old_pin = self.screens.active.cursor.page_pin.up(1) orelse break :transfer_graphemes; + old_pin.x = right_limit - 1; + const old_rac = old_pin.rowAndCell(); + + if (new_pin.node == old_pin.node) { + new_pin.node.data.moveGrapheme(prev.cell, new_rac.cell); + prev.cell.content_tag = .codepoint; + new_rac.cell.content_tag = .codepoint_grapheme; + new_rac.row.grapheme = true; + } else { + const cps = old_pin.node.data.lookupGrapheme(old_rac.cell).?; + for (cps) |cp| { + try self.screens.active.appendGrapheme(new_rac.cell, cp); + } + old_pin.node.data.clearGrapheme(old_rac.cell); + } + + old_pin.node.data.updateRowGraphemeFlag(old_rac.row); + } + + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to + prev.cell = new_rac.cell; + } else { + self.printCell( + 0, + if (row_wrap) .spacer_head else .narrow, + ); + try self.printWrap(); + self.printCell(prev_cp, .wide); + + // Point prev.cell to our new previous cell that + // we'll be appending graphemes to + prev.cell = self.screens.active.cursor.page_cell; + } + } else { + prev.cell.wide = .wide; + } + + // Write our spacer, since prev.cell is now wide + self.screens.active.cursorRight(1); + self.printCell(0, .spacer_tail); + + // 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; + } else { + self.screens.active.cursorRight(1); + } + }, + + .narrow => narrow: { + // Prev cell is no longer wide + 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); + } + + break :narrow; + }, + + else => {}, + } + + log.debug("c={X} grapheme attach to left={} primary_cp={X}", .{ + c, + prev.left, + prev.cell.codepoint(), + }); + self.screens.active.cursorMarkDirty(); + try self.screens.active.appendGrapheme(prev.cell, c); + return; + } + } + + // Determine the width of this character so we can handle + // non-single-width characters properly. We have a fast-path for + // byte-sized characters since they're so common. We can ignore + // control characters because they're always filtered prior. + const width: usize = if (c <= 0xFF) 1 else @intCast(unicode.table.get(c).width); + + // Note: it is possible to have a width of "3" and a width of "-1" from + // uucode.x's wcwidth. We should look into those cases and handle them + // appropriately. + assert(width <= 2); + // log.debug("c={x} width={}", .{ c, width }); + + // Attach zero-width characters to our cell as grapheme data. + if (width == 0) { + @branchHint(.unlikely); + // If we have grapheme clustering enabled, we don't blindly attach + // any zero width character to our cells and we instead just ignore + // it. + if (self.modes.get(.grapheme_cluster)) return; + + // If we're at cell zero, then this is malformed data and we don't + // print anything or even store this. Zero-width characters are ALWAYS + // attached to some other non-zero-width character at the time of + // writing. + if (self.screens.active.cursor.x == 0) { + log.warn("zero-width character with no prior character, ignoring", .{}); + return; + } + + // Find our previous cell + const prev = prev: { + const immediate = self.screens.active.cursorCellLeft(1); + if (immediate.wide != .spacer_tail) break :prev immediate; + break :prev self.screens.active.cursorCellLeft(2); + }; + + // If our previous cell has no text, just ignore the zero-width character + if (!prev.hasText()) { + log.warn("zero-width character with no prior character, ignoring", .{}); + return; + } + + // 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 emoji = prev_props.grapheme_break == .extended_pictographic; + if (!emoji) return; + } + + try self.screens.active.appendGrapheme(prev, c); + return; + } + + // We have a printable character, save it + self.previous_char = c; + + // If we're soft-wrapping, then handle that first. + if (self.screens.active.cursor.pending_wrap and self.modes.get(.wraparound)) { + try self.printWrap(); + } + + // If we have insert mode enabled then we need to handle that. We + // only do insert mode if we're not at the end of the line. + if (self.modes.get(.insert) and + self.screens.active.cursor.x + width < self.cols) + { + self.insertBlanks(width); + } + + switch (width) { + // Single cell is very easy: just write in the cell + 1 => { + @branchHint(.likely); + self.screens.active.cursorMarkDirty(); + @call(.always_inline, printCell, .{ self, c, .narrow }); + }, + + // Wide character requires a spacer. We print this by + // using two cells: the first is flagged "wide" and has the + // wide char. The second is guaranteed to be a spacer if + // we're not at the end of the line. + 2 => if ((right_limit - self.scrolling_region.left) > 1) { + // If we don't have space for the wide char, we need + // to insert spacers and wrap. Then we just print the wide + // char as normal. + if (self.screens.active.cursor.x == right_limit - 1) { + // If we don't have wraparound enabled then we don't print + // this character at all and don't move the cursor. This is + // how xterm behaves. + if (!self.modes.get(.wraparound)) return; + + // We only create a spacer head if we're at the real edge + // of the screen. Otherwise, we clear the space with a narrow. + // This allows soft wrapping to work correctly. + if (right_limit == self.cols) { + // Special-case: we need to set wrap to true even + // though we call printWrap below because if there is + // a page resize during printCell then it'll fail + // integrity checks. + self.screens.active.cursor.page_row.wrap = true; + self.printCell(0, .spacer_head); + } else { + self.printCell(0, .narrow); + } + try self.printWrap(); + } + + self.screens.active.cursorMarkDirty(); + self.printCell(c, .wide); + self.screens.active.cursorRight(1); + self.printCell(0, .spacer_tail); + } else { + // This is pretty broken, terminals should never be only 1-wide. + // We should prevent this downstream. + self.screens.active.cursorMarkDirty(); + self.printCell(0, .narrow); + }, + + else => unreachable, + } + + // If we're at the column limit, then we need to wrap the next time. + // In this case, we don't move the cursor. + if (self.screens.active.cursor.x == right_limit - 1) { + self.screens.active.cursor.pending_wrap = true; + return; + } + + // Move the cursor + self.screens.active.cursorRight(1); +} + +fn printCell( + self: *Terminal, + unmapped_c: u21, + wide: Cell.Wide, +) void { + defer self.screens.active.assertIntegrity(); + + // TODO: spacers should use a bgcolor only cell + + const c: u21 = c: { + // TODO: non-utf8 handling, gr + + // If we're single shifting, then we use the key exactly once. + const key = if (self.screens.active.charset.single_shift) |key_once| blk: { + self.screens.active.charset.single_shift = null; + break :blk key_once; + } else self.screens.active.charset.gl; + + const set = self.screens.active.charset.charsets.get(key); + + // UTF-8 or ASCII is used as-is + if (set == .utf8 or set == .ascii) { + @branchHint(.likely); + break :c unmapped_c; + } + + // If we're outside of ASCII range this is an invalid value in + // this table so we just return space. + if (unmapped_c > std.math.maxInt(u8)) break :c ' '; + + // Get our lookup table and map it + const table = charsets.table(set); + break :c @intCast(table[@intCast(unmapped_c)]); + }; + + const cell = self.screens.active.cursor.page_cell; + + // If the wide property of this cell is the same, then we don't + // need to do the special handling here because the structure will + // be the same. If it is NOT the same, then we may need to clear some + // cells. + if (cell.wide != wide) { + switch (cell.wide) { + // Previous cell was narrow. Do nothing. + .narrow => {}, + + // Previous cell was wide. We need to clear the tail and head. + .wide => wide: { + if (self.screens.active.cursor.x >= self.cols - 1) break :wide; + + const spacer_cell = self.screens.active.cursorCellRight(1); + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, + spacer_cell[0..1], + ); + + // If we're near the left edge, a wide char may have + // wrapped from the previous row, leaving a spacer_head + // at the end of that row. Clear it so the previous row + // doesn't keep a stale spacer_head. + if (self.screens.active.cursor.y > 0 and self.screens.active.cursor.x <= 1) { + const head_cell = self.screens.active.cursorCellEndOfPrev(); + if (head_cell.wide == .spacer_head) head_cell.wide = .narrow; + } + }, + + .spacer_tail => { + assert(self.screens.active.cursor.x > 0); + + // 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) { + cell.wide = .narrow; + } + + const wide_cell = self.screens.active.cursorCellLeft(1); + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, + wide_cell[0..1], + ); + // If we're near the left edge, a wide char may have + // wrapped from the previous row, leaving a spacer_head + // at the end of that row. Clear it so the previous row + // doesn't keep a stale spacer_head. + if (self.screens.active.cursor.y > 0 and self.screens.active.cursor.x <= 1) { + const head_cell = self.screens.active.cursorCellEndOfPrev(); + if (head_cell.wide == .spacer_head) head_cell.wide = .narrow; + } + }, + + // TODO: this case was not handled in the old terminal implementation + // but it feels like we should do something. investigate other + // terminals (xterm mainly) and see what's up. + .spacer_head => {}, + } + } + + // If the prior value had graphemes, clear those + if (cell.hasGrapheme()) { + const page = &self.screens.active.cursor.page_pin.node.data; + page.clearGrapheme(cell); + page.updateRowGraphemeFlag(self.screens.active.cursor.page_row); + } + + // We don't need to update the style refs unless the + // cell's new style will be different after writing. + const style_changed = cell.style_id != self.screens.active.cursor.style_id; + if (style_changed) { + var page = &self.screens.active.cursor.page_pin.node.data; + + // Release the old style. + if (cell.style_id != style.default_id) { + assert(self.screens.active.cursor.page_row.styled); + page.styles.release(page.memory, cell.style_id); + } + } + + // Keep track if we had a hyperlink so we can unset it. + const had_hyperlink = cell.hyperlink; + + // Write + cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = c }, + .style_id = self.screens.active.cursor.style_id, + .wide = wide, + .protected = self.screens.active.cursor.protected, + .semantic_content = self.screens.active.cursor.semantic_content, + }; + + if (style_changed) { + var page = &self.screens.active.cursor.page_pin.node.data; + + // Use the new style. + if (cell.style_id != style.default_id) { + page.styles.use(page.memory, cell.style_id); + self.screens.active.cursor.page_row.styled = true; + } + } + + // If this is a Kitty unicode placeholder then we need to mark the + // row so that the renderer can lookup rows with these much faster. + if (comptime build_options.kitty_graphics) { + if (c == kitty.graphics.unicode.placeholder) { + @branchHint(.unlikely); + self.screens.active.cursor.page_row.kitty_virtual_placeholder = true; + } + } + + // We check for an active hyperlink first because setHyperlink + // handles clearing the old hyperlink and an optimization if we're + // overwriting the same hyperlink. + if (self.screens.active.cursor.hyperlink_id > 0) { + self.screens.active.cursorSetHyperlink() catch |err| { + @branchHint(.unlikely); + log.warn("error reallocating for more hyperlink space, ignoring hyperlink err={}", .{err}); + assert(!cell.hyperlink); + }; + } else if (had_hyperlink) { + // If the previous cell had a hyperlink then we need to clear it. + var page = &self.screens.active.cursor.page_pin.node.data; + page.clearHyperlink(cell); + page.updateRowHyperlinkFlag(self.screens.active.cursor.page_row); + } +} + +fn printWrap(self: *Terminal) !void { + // We only mark that we soft-wrapped if we're at the edge of our + // full screen. We don't mark the row as wrapped if we're in the + // middle due to a right margin. + const cursor: *Screen.Cursor = &self.screens.active.cursor; + const mark_wrap = cursor.x == self.cols - 1; + if (mark_wrap) cursor.page_row.wrap = true; + + // Get the old semantic prompt so we can extend it to the next + // line. We need to do this before we index() because we may + // modify memory. + const old_semantic = cursor.semantic_content; + const old_semantic_clear = cursor.semantic_content_clear_eol; + + // Move to the next line + try self.index(); + self.screens.active.cursorHorizontalAbsolute(self.scrolling_region.left); + + // Our pointer should never move + assert(cursor == &self.screens.active.cursor); + + // We always reset our semantic prompt state + cursor.semantic_content = old_semantic; + cursor.semantic_content_clear_eol = old_semantic_clear; + switch (old_semantic) { + .output, .input => {}, + .prompt => cursor.page_row.semantic_prompt = .prompt_continuation, + } + + if (mark_wrap) { + const row = self.screens.active.cursor.page_row; + // Always mark the row as a continuation + row.wrap_continuation = true; + } + + // Assure that our screen is consistent + self.screens.active.assertIntegrity(); +} + +/// Set the charset into the given slot. +pub fn configureCharset(self: *Terminal, slot: charsets.Slots, set: charsets.Charset) void { + self.screens.active.charset.charsets.set(slot, set); +} + +/// Invoke the charset in slot into the active slot. If single is true, +/// then this will only be invoked for a single character. +pub fn invokeCharset( + self: *Terminal, + active: charsets.ActiveSlot, + slot: charsets.Slots, + single: bool, +) void { + if (single) { + assert(active == .GL); + self.screens.active.charset.single_shift = slot; + return; + } + + switch (active) { + .GL => self.screens.active.charset.gl = slot, + .GR => self.screens.active.charset.gr = slot, + } +} + +/// Carriage return moves the cursor to the first column. +pub fn carriageReturn(self: *Terminal) void { + // Always reset pending wrap state + self.screens.active.cursor.pending_wrap = false; + + // In origin mode we always move to the left margin + self.screens.active.cursorHorizontalAbsolute(if (self.modes.get(.origin)) + self.scrolling_region.left + else if (self.screens.active.cursor.x >= self.scrolling_region.left) + self.scrolling_region.left + else + 0); +} + +/// Linefeed moves the cursor to the next line. +pub fn linefeed(self: *Terminal) !void { + try self.index(); + if (self.modes.get(.linefeed)) self.carriageReturn(); +} + +/// Backspace moves the cursor back a column (but not less than 0). +pub fn backspace(self: *Terminal) void { + self.cursorLeft(1); +} + +/// Move the cursor up amount lines. If amount is greater than the maximum +/// move distance then it is internally adjusted to the maximum. If amount is +/// 0, adjust it to 1. +pub fn cursorUp(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screens.active.cursor.pending_wrap = false; + + // The maximum amount the cursor can move up depends on scrolling regions + const max = if (self.screens.active.cursor.y >= self.scrolling_region.top) + self.screens.active.cursor.y - self.scrolling_region.top + else + self.screens.active.cursor.y; + const count = @min(max, @max(count_req, 1)); + + // We can safely intCast below because of the min/max clamping we did above. + self.screens.active.cursorUp(@intCast(count)); +} + +/// Move the cursor down amount lines. If amount is greater than the maximum +/// move distance then it is internally adjusted to the maximum. This sequence +/// will not scroll the screen or scroll region. If amount is 0, adjust it to 1. +pub fn cursorDown(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screens.active.cursor.pending_wrap = false; + + // The max the cursor can move to depends where the cursor currently is + const max = if (self.screens.active.cursor.y <= self.scrolling_region.bottom) + self.scrolling_region.bottom - self.screens.active.cursor.y + else + self.rows - self.screens.active.cursor.y - 1; + const count = @min(max, @max(count_req, 1)); + self.screens.active.cursorDown(@intCast(count)); +} + +/// Move the cursor right amount columns. If amount is greater than the +/// maximum move distance then it is internally adjusted to the maximum. +/// This sequence will not scroll the screen or scroll region. If amount is +/// 0, adjust it to 1. +pub fn cursorRight(self: *Terminal, count_req: usize) void { + // Always resets pending wrap + self.screens.active.cursor.pending_wrap = false; + + // The max the cursor can move to depends where the cursor currently is + const max = if (self.screens.active.cursor.x <= self.scrolling_region.right) + self.scrolling_region.right - self.screens.active.cursor.x + else + self.cols - self.screens.active.cursor.x - 1; + const count = @min(max, @max(count_req, 1)); + self.screens.active.cursorRight(@intCast(count)); +} + +/// Move the cursor to the left amount cells. If amount is 0, adjust it to 1. +pub fn cursorLeft(self: *Terminal, count_req: usize) void { + // Wrapping behavior depends on various terminal modes + const WrapMode = enum { none, reverse, reverse_extended }; + const wrap_mode: WrapMode = wrap_mode: { + if (!self.modes.get(.wraparound)) break :wrap_mode .none; + if (self.modes.get(.reverse_wrap_extended)) break :wrap_mode .reverse_extended; + if (self.modes.get(.reverse_wrap)) break :wrap_mode .reverse; + break :wrap_mode .none; + }; + + var count = @max(count_req, 1); + + // If we are in no wrap mode, then we move the cursor left and exit + // since this is the fastest and most typical path. + if (wrap_mode == .none) { + self.screens.active.cursorLeft(@min(count, self.screens.active.cursor.x)); + self.screens.active.cursor.pending_wrap = false; + return; + } + + // If we have a pending wrap state and we are in either reverse wrap + // modes then we decrement the amount we move by one to match xterm. + if (self.screens.active.cursor.pending_wrap) { + count -= 1; + self.screens.active.cursor.pending_wrap = false; + } + + // The margins we can move to. + const top = self.scrolling_region.top; + const bottom = self.scrolling_region.bottom; + const right_margin = self.scrolling_region.right; + const left_margin = if (self.screens.active.cursor.x < self.scrolling_region.left) + 0 + else + self.scrolling_region.left; + + // Handle some edge cases when our cursor is already on the left margin. + if (self.screens.active.cursor.x == left_margin) { + switch (wrap_mode) { + // In reverse mode, if we're already before the top margin + // then we just set our cursor to the top-left and we're done. + .reverse => if (self.screens.active.cursor.y <= top) { + self.screens.active.cursorAbsolute(left_margin, top); + return; + }, + + // Handled in while loop + .reverse_extended => {}, + + // Handled above + .none => unreachable, + } + } + + while (true) { + // We can move at most to the left margin. + const max = self.screens.active.cursor.x - left_margin; + + // We want to move at most the number of columns we have left + // or our remaining count. Do the move. + const amount = @min(max, count); + count -= amount; + self.screens.active.cursorLeft(amount); + + // If we have no more to move, then we're done. + if (count == 0) break; + + // If we are at the top, then we are done. + if (self.screens.active.cursor.y == top) { + if (wrap_mode != .reverse_extended) break; + + self.screens.active.cursorAbsolute(right_margin, bottom); + count -= 1; + continue; + } + + // UNDEFINED TERMINAL BEHAVIOR. This situation is not handled in xterm + // and currently results in a crash in xterm. Given no other known + // terminal [to me] implements XTREVWRAP2, I decided to just mimic + // the behavior of xterm up and not including the crash by wrapping + // up to the (0, 0) and stopping there. My reasoning is that for an + // appropriately sized value of "count" this is the behavior that xterm + // would have. This is unit tested. + if (self.screens.active.cursor.y == 0) { + assert(self.screens.active.cursor.x == left_margin); + break; + } + + // If our previous line is not wrapped then we are done. + if (wrap_mode != .reverse_extended) { + const prev_row = self.screens.active.cursorRowUp(1); + if (!prev_row.wrap) break; + } + + self.screens.active.cursorAbsolute(right_margin, self.screens.active.cursor.y - 1); + count -= 1; + } +} + +/// Save cursor position and further state. +/// +/// The primary and alternate screen have distinct save state. One saved state +/// is kept per screen (main / alternative). If for the current screen state +/// was already saved it is overwritten. +pub fn saveCursor(self: *Terminal) void { + self.screens.active.saved_cursor = .{ + .x = self.screens.active.cursor.x, + .y = self.screens.active.cursor.y, + .style = self.screens.active.cursor.style, + .protected = self.screens.active.cursor.protected, + .pending_wrap = self.screens.active.cursor.pending_wrap, + .origin = self.modes.get(.origin), + .charset = self.screens.active.charset, + }; +} + +/// Restore cursor position and other state. +/// +/// The primary and alternate screen have distinct save state. +/// If no save was done before values are reset to their initial values. +pub fn restoreCursor(self: *Terminal) void { + const saved: Screen.SavedCursor = self.screens.active.saved_cursor orelse .{ + .x = 0, + .y = 0, + .style = .{}, + .protected = false, + .pending_wrap = false, + .origin = false, + .charset = .{}, + }; + + // Set the style first because it can fail + self.screens.active.cursor.style = saved.style; + self.screens.active.manualStyleUpdate() catch |err| { + // Regardless of the error here, we revert back to an unstyled + // cursor. It is more important that the restore succeeds in + // other attributes because terminals have no way to communicate + // failure back. + log.warn("restoreCursor error updating style err={}", .{err}); + const screen: *Screen = self.screens.active; + screen.cursor.style = .{}; + self.screens.active.manualStyleUpdate() catch unreachable; + }; + + self.screens.active.charset = saved.charset; + self.modes.set(.origin, saved.origin); + self.screens.active.cursor.pending_wrap = saved.pending_wrap; + self.screens.active.cursor.protected = saved.protected; + self.screens.active.cursorAbsolute( + @min(saved.x, self.cols - 1), + @min(saved.y, self.rows - 1), + ); + + // Ensure our screen is consistent + self.screens.active.assertIntegrity(); +} + +/// Set the character protection mode for the terminal. +pub fn setProtectedMode(self: *Terminal, mode: ansi.ProtectedMode) void { + switch (mode) { + .off => { + self.screens.active.cursor.protected = false; + + // screen.protected_mode is NEVER reset to ".off" because + // logic such as eraseChars depends on knowing what the + // _most recent_ mode was. + }, + + .iso => { + self.screens.active.cursor.protected = true; + self.screens.active.protected_mode = .iso; + }, + + .dec => { + self.screens.active.cursor.protected = true; + self.screens.active.protected_mode = .dec; + }, + } +} + +/// Perform a semantic prompt command. +/// +/// If there is an error, we do our best to get the terminal into +/// some coherent state, since callers typically can't handle errors +/// (since they're sending sequences via the pty). +pub fn semanticPrompt( + self: *Terminal, + cmd: osc.Command.SemanticPrompt, +) !void { + switch (cmd.action) { + .fresh_line => try self.semanticPromptFreshLine(), + + .fresh_line_new_prompt => { + // "First do a fresh-line." + try self.semanticPromptFreshLine(); + + const screen: *Screen = self.screens.active; + + // "Subsequent text (until a OSC "133;B" or OSC "133;I" command) + // is a prompt string (as if followed by OSC 133;P;k=i\007)." + screen.cursorSetSemanticContent(.{ + .prompt = cmd.readOption(.prompt_kind) orelse .initial, + }); + + // This is a kitty-specific flag that notes that the shell + // is NOT capable of redraw. Redraw defaults to true so this + // usually just disables it, but either is possible. + if (cmd.readOption(.redraw)) |v| { + self.flags.shell_redraws_prompt = v; + } + + click: { + // Handle click_events as a priority over cl. click_events + // is another Kitty-specific extension that converts clicks + // within a prompt area to SGR mouse events and defers to the + // shell to handle them. + if (cmd.readOption(.click_events)) |v| { + if (v) { + screen.semantic_prompt.click = .click_events; + break :click; + } + } + + // If click_events was not set or disabled, fallback to `cl`. + if (cmd.readOption(.cl)) |v| { + screen.semantic_prompt.click = .{ .cl = v }; + } + } + + // The "aid" and "cl" options are also valid for this + // command but we don't yet handle these in any meaningful way. + }, + + .new_command => { + // Spec: + // Same as OSC "133;A" but may first implicitly terminate a + // previous command: if the options specify an aid and there + // is an active (open) command with matching aid, finish the + // innermost such command (as well as any other commands + // nested more deeply). If no aid is specified, treat as an + // aid whose value is the empty string. + + // Ghostty: + // We don't currently do explicit command tracking in any way + // so there is no need to terminate prior commands. We just + // perform the `A` action. + try self.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = cmd.options_unvalidated, + }); + }, + + .prompt_start => { + // Explicit start of prompt. Optional after an A or N command. + // The k (kind) option specifies the type of prompt: + // regular primary prompt (k=i or default), + // right-side prompts (k=r), or prompts for continuation lines (k=c or k=s). + self.screens.active.cursorSetSemanticContent(.{ + .prompt = cmd.readOption(.prompt_kind) orelse .initial, + }); + }, + + .end_prompt_start_input => { + // End of prompt and start of user input, terminated by a OSC + // "133;C" or another prompt (OSC "133;P"). + self.screens.active.cursorSetSemanticContent(.{ + .input = .clear_explicit, + }); + }, + + .end_prompt_start_input_terminate_eol => { + // End of prompt and start of user input, terminated by end-of-line. + self.screens.active.cursorSetSemanticContent(.{ + .input = .clear_eol, + }); + }, + + .end_input_start_output => { + // "End of input, and start of output." + self.screens.active.cursorSetSemanticContent(.output); + + // If our current row is marked as a prompt and we're + // at column zero then we assume we're un-prompting. This + // is a heuristic to deal with fish, mostly. The issue that + // fish brings up is that it has no PS2 equivalent and its + // builtin OSC133 marking doesn't output continuation lines + // as k=s. So, we assume when we get a newline with a prompt + // cursor that the new line is also a prompt. But fish changes + // to output on the newline. So if we're at col 0 we just assume + // we're overwriting the prompt. + if (self.screens.active.cursor.page_row.semantic_prompt != .none and + self.screens.active.cursor.x == 0) + { + self.screens.active.cursor.page_row.semantic_prompt = .none; + } + }, + + .end_command => { + // From a terminal state perspective, this doesn't really do + // anything. Other terminals appear to do nothing here. I think + // its reasonable at this point to reset our semantic content + // state but the spec doesn't really say what to do. + self.screens.active.cursorSetSemanticContent(.output); + }, + } +} + +// OSC 133;L +fn semanticPromptFreshLine(self: *Terminal) !void { + const left_margin = if (self.screens.active.cursor.x < self.scrolling_region.left) + 0 + else + self.scrolling_region.left; + + // Spec: "If the cursor is the initial column (left, assuming + // left-to-right writing), do nothing" This specification is very under + // specified. We are taking the liberty to assume that in a left/right + // margin context, if the cursor is outside of the left margin, we treat + // it as being at the left margin for the purposes of this command. + // This is arbitrary. If someone has a better reasonable idea we can + // apply it. + if (self.screens.active.cursor.x == left_margin) return; + + self.carriageReturn(); + try self.index(); +} + +/// The semantic prompt type. This is used when tracking a line type and +/// requires integration with the shell. By default, we mark a line as "none" +/// meaning we don't know what type it is. +/// +/// See: https://gitlab.freedesktop.org/Per_Bothner/specifications/blob/master/proposals/semantic-prompts.md +pub const SemanticPrompt = enum { + prompt, + prompt_continuation, + input, + command, +}; + +/// Returns true if the cursor is currently at a prompt. Another way to look +/// at this is it returns false if the shell is currently outputting something. +/// This requires shell integration (semantic prompt integration). +/// +/// If the shell integration doesn't exist, this will always return false. +pub fn cursorIsAtPrompt(self: *Terminal) bool { + // If we're on the secondary screen, we're never at a prompt. + if (self.screens.active_key == .alternate) return false; + + // If our page row is a prompt then we're always at a prompt + const cursor: *const Screen.Cursor = &self.screens.active.cursor; + if (cursor.page_row.semantic_prompt != .none) return true; + + // Otherwise, determine our cursor state + return switch (cursor.semantic_content) { + .input, .prompt => true, + .output => false, + }; +} + +/// Horizontal tab moves the cursor to the next tabstop, clearing +/// the screen to the left the tabstop. +pub fn horizontalTab(self: *Terminal) void { + while (self.screens.active.cursor.x < self.scrolling_region.right) { + // Move the cursor right + self.screens.active.cursorRight(1); + + // If the last cursor position was a tabstop we return. We do + // "last cursor position" because we want a space to be written + // at the tabstop unless we're at the end (the while condition). + if (self.tabstops.get(self.screens.active.cursor.x)) return; + } +} + +// Same as horizontalTab but moves to the previous tabstop instead of the next. +pub fn horizontalTabBack(self: *Terminal) void { + // With origin mode enabled, our leftmost limit is the left margin. + const left_limit = if (self.modes.get(.origin)) self.scrolling_region.left else 0; + + while (true) { + // If we're already at the edge of the screen, then we're done. + if (self.screens.active.cursor.x <= left_limit) return; + + // Move the cursor left + self.screens.active.cursorLeft(1); + if (self.tabstops.get(self.screens.active.cursor.x)) return; + } +} + +/// Clear tab stops. +pub fn tabClear(self: *Terminal, cmd: csi.TabClear) void { + switch (cmd) { + .current => self.tabstops.unset(self.screens.active.cursor.x), + .all => self.tabstops.reset(0), + else => log.warn("invalid or unknown tab clear setting: {}", .{cmd}), + } +} + +/// Set a tab stop on the current cursor. +/// TODO: test +pub fn tabSet(self: *Terminal) void { + self.tabstops.set(self.screens.active.cursor.x); +} + +/// TODO: test +pub fn tabReset(self: *Terminal) void { + self.tabstops.reset(TABSTOP_INTERVAL); +} + +/// Move the cursor to the next line in the scrolling region, possibly scrolling. +/// +/// If the cursor is outside of the scrolling region: move the cursor one line +/// down if it is not on the bottom-most line of the screen. +/// +/// If the cursor is inside the scrolling region: +/// If the cursor is on the bottom-most line of the scrolling region: +/// invoke scroll up with amount=1 +/// If the cursor is not on the bottom-most line of the scrolling region: +/// move the cursor one line down +/// +/// This unsets the pending wrap state without wrapping. +pub fn index(self: *Terminal) !void { + const screen: *Screen = self.screens.active; + + // Unset pending wrap state + screen.cursor.pending_wrap = false; + + // We handle our cursor semantic prompt state AFTER doing the + // scrolling, because we may need to apply to new rows. + defer if (screen.cursor.semantic_content != .output) { + @branchHint(.unlikely); + + // Always reset any semantic content clear-eol state. + // + // The specification is not clear what "end-of-line" means. If we + // discover that there are more scenarios we should be unsetting + // this we should document and test it. + if (screen.cursor.semantic_content_clear_eol) { + screen.cursor.semantic_content = .output; + screen.cursor.semantic_content_clear_eol = false; + } else { + // If we aren't clearing our state at EOL and we're not output, + // then we mark the new row as a prompt continuation. This is + // to work around shells that don't send OSC 133 k=s sequences + // for continuations. + // + // This can be a false positive if the shell changes content + // type later and outputs something. We handle that in the + // semanticPrompt function. + screen.cursor.page_row.semantic_prompt = .prompt_continuation; + } + } else { + // This should never be set in the output mode. + assert(!screen.cursor.semantic_content_clear_eol); + }; + + // Outside of the scroll region we move the cursor one line down. + if (screen.cursor.y < self.scrolling_region.top or + screen.cursor.y > self.scrolling_region.bottom) + { + // We only move down if we're not already at the bottom of + // the screen. + if (screen.cursor.y < self.rows - 1) { + screen.cursorDown(1); + } + + return; + } + + // If the cursor is inside the scrolling region and on the bottom-most + // line, then we scroll up. If our scrolling region is the full screen + // we create scrollback. + if (screen.cursor.y == self.scrolling_region.bottom and + screen.cursor.x >= self.scrolling_region.left and + screen.cursor.x <= self.scrolling_region.right) + { + if (comptime build_options.kitty_graphics) { + // Scrolling dirties the images because it updates their placements pins. + screen.kitty_images.dirty = true; + } + + // If our scrolling region is at the top, we create scrollback. + if (self.scrolling_region.top == 0 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + try screen.cursorScrollAbove(); + return; + } + + // Slow path for left and right scrolling region margins. + if (self.scrolling_region.left != 0 or + self.scrolling_region.right != self.cols - 1 or + + // PERF(mitchellh): If we have an SGR background set then + // we need to preserve that background in our erased rows. + // scrollUp does that but eraseRowBounded below does not. + // However, scrollUp is WAY slower. We should optimize this + // case to work in the eraseRowBounded codepath and remove + // this check. + !screen.blankCell().isZero()) + { + try self.scrollUp(1); + return; + } + + // Otherwise use a fast path function from PageList to efficiently + // scroll the contents of the scrolling region. + + // Preserve old cursor just for assertions + const old_cursor = screen.cursor; + + try screen.pages.eraseRowBounded( + .{ .active = .{ .y = self.scrolling_region.top } }, + self.scrolling_region.bottom - self.scrolling_region.top, + ); + + // eraseRow and eraseRowBounded will end up moving the cursor pin + // up by 1, so we need to move it back down. A `cursorReload` + // would be better option but this is more efficient and this is + // a super hot path so we do this instead. + assert(screen.cursor.x == old_cursor.x); + assert(screen.cursor.y == old_cursor.y); + screen.cursor.y -= 1; + screen.cursorDown(1); + + // The operations above can prune our cursor style so we need to + // update. This should never fail because the above can only FREE + // memory. + screen.manualStyleUpdate() catch |err| { + std.log.warn("deleteLines manualStyleUpdate err={}", .{err}); + screen.cursor.style = .{}; + screen.manualStyleUpdate() catch unreachable; + }; + + return; + } + + // Increase cursor by 1, maximum to bottom of scroll region + if (screen.cursor.y < self.scrolling_region.bottom) { + screen.cursorDown(1); + } +} + +/// Move the cursor to the previous line in the scrolling region, possibly +/// scrolling. +/// +/// If the cursor is outside of the scrolling region, move the cursor one +/// line up if it is not on the top-most line of the screen. +/// +/// If the cursor is inside the scrolling region: +/// +/// * If the cursor is on the top-most line of the scrolling region: +/// invoke scroll down with amount=1 +/// * If the cursor is not on the top-most line of the scrolling region: +/// move the cursor one line up +pub fn reverseIndex(self: *Terminal) void { + if (self.screens.active.cursor.y != self.scrolling_region.top or + self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) + { + self.cursorUp(1); + return; + } + + self.scrollDown(1); +} + +/// Set Cursor Position. Move cursor to the position indicated +/// by row and column (1-indexed). If column is 0, it is adjusted to 1. +/// If column is greater than the right-most column it is adjusted to +/// the right-most column. If row is 0, it is adjusted to 1. If row is +/// greater than the bottom-most row it is adjusted to the bottom-most +/// row. +pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void { + // If cursor origin mode is set the cursor row will be moved relative to + // the top margin row and adjusted to be above or at bottom-most row in + // the current scroll region. + // + // If origin mode is set and left and right margin mode is set the cursor + // will be moved relative to the left margin column and adjusted to be on + // or left of the right margin column. + const params: struct { + x_offset: size.CellCountInt = 0, + y_offset: size.CellCountInt = 0, + x_max: size.CellCountInt, + y_max: size.CellCountInt, + } = if (self.modes.get(.origin)) .{ + .x_offset = self.scrolling_region.left, + .y_offset = self.scrolling_region.top, + .x_max = self.scrolling_region.right + 1, // We need this 1-indexed + .y_max = self.scrolling_region.bottom + 1, // We need this 1-indexed + } else .{ + .x_max = self.cols, + .y_max = self.rows, + }; + + // Unset pending wrap state + self.screens.active.cursor.pending_wrap = false; + + // Calculate our new x/y + const row = if (row_req == 0) 1 else row_req; + const col = if (col_req == 0) 1 else col_req; + const x = @min(params.x_max, col + params.x_offset) -| 1; + const y = @min(params.y_max, row + params.y_offset) -| 1; + + // If the y is unchanged then this is fast pointer math + if (y == self.screens.active.cursor.y) { + if (x > self.screens.active.cursor.x) { + self.screens.active.cursorRight(x - self.screens.active.cursor.x); + } else { + self.screens.active.cursorLeft(self.screens.active.cursor.x - x); + } + + return; + } + + // If everything changed we do an absolute change which is slightly slower + self.screens.active.cursorAbsolute(x, y); + // log.info("set cursor position: col={} row={}", .{ self.screens.active.cursor.x, self.screens.active.cursor.y }); +} + +/// Set Top and Bottom Margins If bottom is not specified, 0 or bigger than +/// the number of the bottom-most row, it is adjusted to the number of the +/// bottom most row. +/// +/// If top < bottom set the top and bottom row of the scroll region according +/// to top and bottom and move the cursor to the top-left cell of the display +/// (when in cursor origin mode is set to the top-left cell of the scroll region). +/// +/// Otherwise: Set the top and bottom row of the scroll region to the top-most +/// and bottom-most line of the screen. +/// +/// Top and bottom are 1-indexed. +pub fn setTopAndBottomMargin(self: *Terminal, top_req: usize, bottom_req: usize) void { + const top = @max(1, top_req); + const bottom = @min(self.rows, if (bottom_req == 0) self.rows else bottom_req); + if (top >= bottom) return; + + self.scrolling_region.top = @intCast(top - 1); + self.scrolling_region.bottom = @intCast(bottom - 1); + self.setCursorPos(1, 1); +} + +/// DECSLRM +pub fn setLeftAndRightMargin(self: *Terminal, left_req: usize, right_req: usize) void { + // We must have this mode enabled to do anything + if (!self.modes.get(.enable_left_and_right_margin)) return; + + const left = @max(1, left_req); + const right = @min(self.cols, if (right_req == 0) self.cols else right_req); + if (left >= right) return; + + self.scrolling_region.left = @intCast(left - 1); + self.scrolling_region.right = @intCast(right - 1); + self.setCursorPos(1, 1); +} + +/// Scroll the text down by one row. +pub fn scrollDown(self: *Terminal, count: usize) void { + // Preserve our x/y to restore. + const old_x = self.screens.active.cursor.x; + const old_y = self.screens.active.cursor.y; + const old_wrap = self.screens.active.cursor.pending_wrap; + defer { + self.screens.active.cursorAbsolute(old_x, old_y); + self.screens.active.cursor.pending_wrap = old_wrap; + } + + // Move to the top of the scroll region + self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.insertLines(count); +} + +/// Removes amount lines from the top of the scroll region. The remaining lines +/// to the bottom margin are shifted up and space from the bottom margin up +/// is filled with empty lines. +/// +/// The new lines are created according to the current SGR state. +/// +/// Does not change the (absolute) cursor position. +pub fn scrollUp(self: *Terminal, count: usize) !void { + // Preserve our x/y to restore. + const old_x = self.screens.active.cursor.x; + const old_y = self.screens.active.cursor.y; + const old_wrap = self.screens.active.cursor.pending_wrap; + defer { + self.screens.active.cursorAbsolute(old_x, old_y); + self.screens.active.cursor.pending_wrap = old_wrap; + } + + // If our scroll region is at the top and we have no left/right + // margins then we move the scrolled out text into the scrollback. + if (self.scrolling_region.top == 0 and + self.scrolling_region.left == 0 and + self.scrolling_region.right == self.cols - 1) + { + // Scrolling dirties the images because it updates their placements pins. + if (comptime build_options.kitty_graphics) { + self.screens.active.kitty_images.dirty = true; + } + + // Clamp count to the scroll region height. + const region_height = self.scrolling_region.bottom + 1; + const adjusted_count = @min(count, region_height); + + // TODO: Create an optimized version that can scroll N times + // This isn't critical because in most cases, scrollUp is used + // with count=1, but it's still a big optimization opportunity. + + // Move our cursor to the bottom of the scroll region so we can + // use the cursorScrollAbove function to create scrollback + self.screens.active.cursorAbsolute(0, self.scrolling_region.bottom); + for (0..adjusted_count) |_| try self.screens.active.cursorScrollAbove(); + return; + } + + // Move to the top of the scroll region + self.screens.active.cursorAbsolute(self.scrolling_region.left, self.scrolling_region.top); + self.deleteLines(count); +} + +/// Options for scrolling the viewport of the terminal grid. +pub const ScrollViewport = union(Tag) { + /// Scroll to the top of the scrollback + top, + + /// Scroll to the bottom, i.e. the top of the active area + bottom, + + /// Scroll by some delta amount, up is negative. + delta: isize, + + pub const Tag = lib.Enum(lib.target, &.{ + "top", + "bottom", + "delta", + }); + + const c_union = lib.TaggedUnion( + lib.target, + @This(), + // Padding: largest variant is isize (8 bytes on 64-bit). + // Use [2]u64 (16 bytes) for future expansion. + [2]u64, + ); + pub const C = c_union.C; + pub const CValue = c_union.CValue; + pub const cval = c_union.cval; +}; + +/// Scroll the viewport of the terminal grid. +pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { + self.screens.active.scroll(switch (behavior) { + .top => .{ .top = {} }, + .bottom => .{ .active = {} }, + .delta => |delta| .{ .delta_row = delta }, + }); +} + +/// To be called before shifting a row (as in insertLines and deleteLines) +/// +/// Takes care of boundary conditions such as potentially split wide chars +/// across scrolling region boundaries and orphaned spacer heads at line +/// ends. +fn rowWillBeShifted( + self: *Terminal, + page: *Page, + row: *Row, +) void { + const cells = row.cells.ptr(page.memory.ptr); + + // If our scrolling region includes the rightmost column then we + // need to turn any spacer heads in to normal empty cells, since + // once we move them they no longer correspond with soft-wrapped + // wide characters. + // + // If it contains either of the 2 leftmost columns, then the wide + // characters in the first column which may be associated with a + // spacer head will be either moved or cleared, so we also need + // to turn the spacer heads in to empty cells in that case. + if (self.scrolling_region.right == self.cols - 1 or + self.scrolling_region.left < 2) + { + const end_cell: *Cell = &cells[page.size.cols - 1]; + if (end_cell.wide == .spacer_head) { + end_cell.wide = .narrow; + } + } + + // If the leftmost or rightmost cells of our scrolling region + // are parts of wide chars, we need to clear the cells' contents + // since they'd be split by the move. + const left_cell: *Cell = &cells[self.scrolling_region.left]; + const right_cell: *Cell = &cells[self.scrolling_region.right]; + + if (left_cell.wide == .spacer_tail) { + const wide_cell: *Cell = &cells[self.scrolling_region.left - 1]; + if (wide_cell.hasGrapheme()) { + page.clearGrapheme(wide_cell); + page.updateRowGraphemeFlag(row); + } + wide_cell.content.codepoint = 0; + wide_cell.wide = .narrow; + left_cell.wide = .narrow; + } + + if (right_cell.wide == .wide) { + const tail_cell: *Cell = &cells[self.scrolling_region.right + 1]; + if (right_cell.hasGrapheme()) { + page.clearGrapheme(right_cell); + page.updateRowGraphemeFlag(row); + } + right_cell.content.codepoint = 0; + right_cell.wide = .narrow; + tail_cell.wide = .narrow; + } +} + +// TODO(qwerasd): `insertLines` and `deleteLines` are 99% identical, +// the majority of their logic can (and should) be abstracted in to +// a single shared helper function, probably on `Screen` not here. +// I'm just too lazy to do that rn :p + +/// Insert amount lines at the current cursor row. The contents of the line +/// at the current cursor row and below (to the bottom-most line in the +/// scrolling region) are shifted down by amount lines. The contents of the +/// amount bottom-most lines in the scroll region are lost. +/// +/// This unsets the pending wrap state without wrapping. If the current cursor +/// position is outside of the current scroll region it does nothing. +/// +/// If amount is greater than the remaining number of lines in the scrolling +/// region it is adjusted down (still allowing for scrolling out every remaining +/// line in the scrolling region) +/// +/// In left and right margin mode the margins are respected; lines are only +/// scrolled in the scroll region. +/// +/// All cleared space is colored according to the current SGR state. +/// +/// Moves the cursor to the left margin. +pub fn insertLines(self: *Terminal, count: usize) void { + // Rare, but happens + if (count == 0) return; + + // If the cursor is outside the scroll region we do nothing. + if (self.screens.active.cursor.y < self.scrolling_region.top or + self.screens.active.cursor.y > self.scrolling_region.bottom or + self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; + + if (comptime build_options.kitty_graphics) { + // Scrolling dirties the images because it updates their placements pins. + self.screens.active.kitty_images.dirty = true; + } + + // At the end we need to return the cursor to the row it started on. + const start_y = self.screens.active.cursor.y; + defer { + self.screens.active.cursorAbsolute(self.scrolling_region.left, start_y); + + // Always unset pending wrap + self.screens.active.cursor.pending_wrap = false; + } + + // We have a slower path if we have left or right scroll margins. + const left_right = self.scrolling_region.left > 0 or + self.scrolling_region.right < self.cols - 1; + + // Remaining rows from our cursor to the bottom of the scroll region. + const rem = self.scrolling_region.bottom - self.screens.active.cursor.y + 1; + + // We can only insert lines up to our remaining lines in the scroll + // region. So we take whichever is smaller. + const adjusted_count = @min(count, rem); + + // Create a new tracked pin which we'll use to navigate the page list + // so that if we need to adjust capacity it will be properly tracked. + var cur_p = self.screens.active.pages.trackPin( + self.screens.active.cursor.page_pin.down(rem - 1).?, + ) catch |err| { + comptime assert(@TypeOf(err) == error{OutOfMemory}); + + // This error scenario means that our GPA is OOM. This is not a + // situation we can gracefully handle. We can't just ignore insertLines + // because it'll result in a corrupted screen. Ideally in the future + // we flag the state as broken and show an error message to the user. + // For now, we panic. + log.err("insertLines trackPin error err={}", .{err}); + @panic("insertLines trackPin OOM"); + }; + defer self.screens.active.pages.untrackPin(cur_p); + + // Our current y position relative to the cursor + var y: usize = rem; + + // Traverse from the bottom up + while (y > 0) { + const cur_rac = cur_p.rowAndCell(); + const cur_row: *Row = cur_rac.row; + + // If this is one of the lines we need to shift, do so + if (y > adjusted_count) { + const off_p = cur_p.up(adjusted_count).?; + const off_rac = off_p.rowAndCell(); + const off_row: *Row = off_rac.row; + + self.rowWillBeShifted(&cur_p.node.data, cur_row); + self.rowWillBeShifted(&off_p.node.data, off_row); + + // If our scrolling region is full width, then we unset wrap. + if (!left_right) { + off_row.wrap = false; + cur_row.wrap = false; + off_row.wrap_continuation = false; + cur_row.wrap_continuation = false; + } + + const src_p = off_p; + const src_row = off_row; + const dst_p = cur_p; + const dst_row = cur_row; + + // If our page doesn't match, then we need to do a copy from + // one page to another. This is the slow path. + if (src_p.node != dst_p.node) { + dst_p.node.data.clonePartialRowFrom( + &src_p.node.data, + dst_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 + // proper shifted rows and src gets non-garbage cell data that + // we can clear. + const dst = dst_row.*; + dst_row.* = src_row.*; + src_row.* = dst; + + // Ensure what we did didn't corrupt the page + cur_p.node.data.assertIntegrity(); + } else { + // Left/right scroll margins we have to + // copy cells, which is much slower... + const page = &cur_p.node.data; + page.moveCells( + src_row, + self.scrolling_region.left, + dst_row, + self.scrolling_region.left, + (self.scrolling_region.right - self.scrolling_region.left) + 1, + ); + } + } + } else { + // Clear the cells for this row, it has been shifted. + self.rowWillBeShifted(&cur_p.node.data, cur_row); + const page = &cur_p.node.data; + const cells = page.getCells(cur_row); + self.screens.active.clearCells( + page, + cur_row, + cells[self.scrolling_region.left .. self.scrolling_region.right + 1], + ); + } + + // Mark the row as dirty + cur_p.markDirty(); + + // We have successfully processed a line. + y -= 1; + // Move our pin up to the next row. + if (cur_p.up(1)) |p| cur_p.* = p; + } +} + +/// Removes amount lines from the current cursor row down. The remaining lines +/// to the bottom margin are shifted up and space from the bottom margin up is +/// filled with empty lines. +/// +/// If the current cursor position is outside of the current scroll region it +/// does nothing. If amount is greater than the remaining number of lines in the +/// scrolling region it is adjusted down. +/// +/// In left and right margin mode the margins are respected; lines are only +/// scrolled in the scroll region. +/// +/// If the cell movement splits a multi cell character that character cleared, +/// by replacing it by spaces, keeping its current attributes. All other +/// cleared space is colored according to the current SGR state. +/// +/// Moves the cursor to the left margin. +pub fn deleteLines(self: *Terminal, count: usize) void { + // Rare, but happens + if (count == 0) return; + + // If the cursor is outside the scroll region we do nothing. + if (self.screens.active.cursor.y < self.scrolling_region.top or + self.screens.active.cursor.y > self.scrolling_region.bottom or + self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; + + if (comptime build_options.kitty_graphics) { + // Scrolling dirties the images because it updates their placements pins. + self.screens.active.kitty_images.dirty = true; + } + + // At the end we need to return the cursor to the row it started on. + const start_y = self.screens.active.cursor.y; + defer { + self.screens.active.cursorAbsolute(self.scrolling_region.left, start_y); + // Always unset pending wrap + self.screens.active.cursor.pending_wrap = false; + } + + // We have a slower path if we have left or right scroll margins. + const left_right = self.scrolling_region.left > 0 or + self.scrolling_region.right < self.cols - 1; + + // Remaining rows from our cursor to the bottom of the scroll region. + const rem = self.scrolling_region.bottom - self.screens.active.cursor.y + 1; + + // We can only insert lines up to our remaining lines in the scroll + // region. So we take whichever is smaller. + const adjusted_count = @min(count, rem); + + // Create a new tracked pin which we'll use to navigate the page list + // so that if we need to adjust capacity it will be properly tracked. + var cur_p = self.screens.active.pages.trackPin( + self.screens.active.cursor.page_pin.*, + ) catch |err| { + // See insertLines + comptime assert(@TypeOf(err) == error{OutOfMemory}); + log.err("deleteLines trackPin error err={}", .{err}); + @panic("deleteLines trackPin OOM"); + }; + defer self.screens.active.pages.untrackPin(cur_p); + + // Our current y position relative to the cursor + var y: usize = 0; + + // Traverse from the top down + while (y < rem) { + const cur_rac = cur_p.rowAndCell(); + const cur_row: *Row = cur_rac.row; + + // If this is one of the lines we need to shift, do so + if (y < rem - adjusted_count) { + const off_p = cur_p.down(adjusted_count).?; + const off_rac = off_p.rowAndCell(); + const off_row: *Row = off_rac.row; + + self.rowWillBeShifted(&cur_p.node.data, cur_row); + self.rowWillBeShifted(&off_p.node.data, off_row); + + // If our scrolling region is full width, then we unset wrap. + if (!left_right) { + off_row.wrap = false; + cur_row.wrap = false; + off_row.wrap_continuation = false; + cur_row.wrap_continuation = false; + } + + const src_p = off_p; + const src_row = off_row; + const dst_p = cur_p; + const dst_row = cur_row; + + // If our page doesn't match, then we need to do a copy from + // one page to another. This is the slow path. + if (src_p.node != dst_p.node) { + dst_p.node.data.clonePartialRowFrom( + &src_p.node.data, + dst_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 + // proper shifted rows and src gets non-garbage cell data that + // we can clear. + const dst = dst_row.*; + dst_row.* = src_row.*; + src_row.* = dst; + + // Ensure what we did didn't corrupt the page + cur_p.node.data.assertIntegrity(); + } else { + // Left/right scroll margins we have to + // copy cells, which is much slower... + const page = &cur_p.node.data; + page.moveCells( + src_row, + self.scrolling_region.left, + dst_row, + self.scrolling_region.left, + (self.scrolling_region.right - self.scrolling_region.left) + 1, + ); + } + } + } else { + // Clear the cells for this row, it's from out of bounds. + self.rowWillBeShifted(&cur_p.node.data, cur_row); + const page = &cur_p.node.data; + const cells = page.getCells(cur_row); + self.screens.active.clearCells( + page, + cur_row, + cells[self.scrolling_region.left .. self.scrolling_region.right + 1], + ); + } + + // Mark the row as dirty + cur_p.markDirty(); + + // We have successfully processed a line. + y += 1; + // Move our pin down to the next row. + if (cur_p.down(1)) |p| cur_p.* = p; + } +} + +/// Inserts spaces at current cursor position moving existing cell contents +/// to the right. The contents of the count right-most columns in the scroll +/// region are lost. The cursor position is not changed. +/// +/// This unsets the pending wrap state without wrapping. +/// +/// The inserted cells are colored according to the current SGR state. +pub fn insertBlanks(self: *Terminal, count: usize) void { + // Unset pending wrap state without wrapping. Note: this purposely + // happens BEFORE the scroll region check below, because that's what + // xterm does. + self.screens.active.cursor.pending_wrap = false; + + // If we're given a zero then we do nothing. The rest of this function + // assumes count > 0 and will crash if zero so return early. Note that + // this shouldn't be possible with real CSI sequences because the value + // is clamped to 1 min. + if (count == 0) return; + + // If our cursor is outside the margins then do nothing. We DO reset + // wrap state still so this must remain below the above logic. + if (self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; + + // If our count is larger than the remaining amount, we just erase right. + // We only do this if we can erase the entire line (no right margin). + // if (right_limit == self.cols and + // count > right_limit - self.screens.active.cursor.x) + // { + // self.eraseLine(.right, false); + // return; + // } + + // left is just the cursor position but as a multi-pointer + const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + var page = &self.screens.active.cursor.page_pin.node.data; + + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + if (self.screens.active.cursor.page_cell.wide == .spacer_tail) { + assert(self.screens.active.cursor.x > 0); + self.screens.active.clearCells(page, self.screens.active.cursor.page_row, (left - 1)[0..2]); + } + + // Remaining cols from our cursor to the right margin. + const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1; + + // If the cell at the right margin is wide, its spacer tail is + // outside the scroll region and would be orphaned by either the + // shift or the clear. Clean up both halves up front. + { + const right_cell: *Cell = @ptrCast(left + (rem - 1)); + if (right_cell.wide == .wide) self.screens.active.clearCells( + page, + self.screens.active.cursor.page_row, + @as([*]Cell, @ptrCast(right_cell))[0..2], + ); + } + + // We can only insert blanks up to our remaining cols + const adjusted_count = @min(count, rem); + + // This is the amount of space at the right of the scroll region + // that will NOT be blank, so we need to shift the correct cols right. + // "scroll_amount" is the number of such cols. + const scroll_amount = rem - adjusted_count; + if (scroll_amount > 0) { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + + var x: [*]Cell = left + (scroll_amount - 1); + + // If our last cell we're shifting is wide, then we need to clear + // it to be empty so we don't split the multi-cell char. + const end: *Cell = @ptrCast(x); + if (end.wide == .wide) { + const end_multi: [*]Cell = @ptrCast(end); + assert(end_multi[1].wide == .spacer_tail); + self.screens.active.clearCells( + page, + self.screens.active.cursor.page_row, + end_multi[0..2], + ); + } + + // We work backwards so we don't overwrite data. + while (@intFromPtr(x) >= @intFromPtr(left)) : (x -= 1) { + const src: *Cell = @ptrCast(x); + const dst: *Cell = @ptrCast(x + adjusted_count); + page.swapCells(src, dst); + } + } + + // Insert blanks. The blanks preserve the background color. + self.screens.active.clearCells(page, self.screens.active.cursor.page_row, left[0..adjusted_count]); + + // Our row is always dirty + self.screens.active.cursorMarkDirty(); +} + +/// Removes amount characters from the current cursor position to the right. +/// The remaining characters are shifted to the left and space from the right +/// margin is filled with spaces. +/// +/// If amount is greater than the remaining number of characters in the +/// scrolling region, it is adjusted down. +/// +/// Does not change the cursor position. +pub fn deleteChars(self: *Terminal, count_req: usize) void { + if (count_req == 0) return; + + // If our cursor is outside the margins then do nothing. We DO reset + // wrap state still so this must remain below the above logic. + if (self.screens.active.cursor.x < self.scrolling_region.left or + self.screens.active.cursor.x > self.scrolling_region.right) return; + + // left is just the cursor position but as a multi-pointer + const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + var page = &self.screens.active.cursor.page_pin.node.data; + + // Remaining cols from our cursor to the right margin. + const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1; + + // We can only insert blanks up to our remaining cols + const count = @min(count_req, rem); + + self.screens.active.splitCellBoundary(self.screens.active.cursor.x); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x + count); + self.screens.active.splitCellBoundary(self.scrolling_region.right + 1); + + // This is the amount of space at the right of the scroll region + // that will NOT be blank, so we need to shift the correct cols right. + // "scroll_amount" is the number of such cols. + const scroll_amount = rem - count; + var x: [*]Cell = left; + if (scroll_amount > 0) { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + + const right: [*]Cell = left + (scroll_amount - 1); + + while (@intFromPtr(x) <= @intFromPtr(right)) : (x += 1) { + const src: *Cell = @ptrCast(x + count); + const dst: *Cell = @ptrCast(x); + page.swapCells(src, dst); + } + } + + // Insert blanks. The blanks preserve the background color. + self.screens.active.clearCells(page, self.screens.active.cursor.page_row, x[0 .. rem - scroll_amount]); + + // Our row's soft-wrap is always reset. + self.screens.active.cursorResetWrap(); + + // Our row is always dirty + self.screens.active.cursorMarkDirty(); +} + +pub fn eraseChars(self: *Terminal, count_req: usize) void { + const count = end: { + const remaining = self.cols - self.screens.active.cursor.x; + var end = @min(remaining, @max(count_req, 1)); + + // If our last cell is a wide char then we need to also clear the + // cell beyond it since we can't just split a wide char. + if (end != remaining) { + const last = self.screens.active.cursorCellRight(end - 1); + if (last.wide == .wide) end += 1; + } + + break :end end; + }; + + // Handle any boundary conditions on the edges of the erased area. + // + // TODO(qwerasd): This isn't actually correct if you take in to account + // protected modes. We need to figure out how to make `clearCells` or at + // least `clearUnprotectedCells` handle boundary conditions... + self.screens.active.splitCellBoundary(self.screens.active.cursor.x); + self.screens.active.splitCellBoundary(self.screens.active.cursor.x + count); + + // Reset our row's soft-wrap. + self.screens.active.cursorResetWrap(); + + // Mark our cursor row as dirty + self.screens.active.cursorMarkDirty(); + + // Clear the cells + const cells: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + + // If we never had a protection mode, then we can assume no cells + // are protected and go with the fast path. If the last protection + // mode was not ISO we also always ignore protection attributes. + if (self.screens.active.protected_mode != .iso) { + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, + cells[0..count], + ); + return; + } + + self.screens.active.clearUnprotectedCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, + cells[0..count], + ); +} + +/// Erase the line. +pub fn eraseLine( + self: *Terminal, + mode: csi.EraseLine, + protected_req: bool, +) void { + // Get our start/end positions depending on mode. + const start, const end = switch (mode) { + .right => right: { + var x = self.screens.active.cursor.x; + + // If our X is a wide spacer tail then we need to erase the + // previous cell too so we don't split a multi-cell character. + if (x > 0 and self.screens.active.cursor.page_cell.wide == .spacer_tail) { + x -= 1; + } + + // Reset our row's soft-wrap. + self.screens.active.cursorResetWrap(); + + break :right .{ x, self.cols }; + }, + + .left => left: { + var x = self.screens.active.cursor.x; + + // If our x is a wide char we need to delete the tail too. + if (self.screens.active.cursor.page_cell.wide == .wide) { + x += 1; + } + + 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 }, + + else => { + log.err("unimplemented erase line mode: {}", .{mode}); + return; + }, + }; + + // All modes will clear the pending wrap state and we know we have + // a valid mode at this point. + self.screens.active.cursor.pending_wrap = false; + + // We always mark our row as dirty + self.screens.active.cursorMarkDirty(); + + // Start of our cells + const cells: [*]Cell = cells: { + const cells: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); + break :cells cells - self.screens.active.cursor.x; + }; + + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + const protected = self.screens.active.protected_mode == .iso or protected_req; + + // If we're not respecting protected attributes, we can use a fast-path + // to fill the entire line. + if (!protected) { + self.screens.active.clearCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, + cells[start..end], + ); + return; + } + + self.screens.active.clearUnprotectedCells( + &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_row, + cells[start..end], + ); +} + +/// Erase the display. +pub fn eraseDisplay( + self: *Terminal, + mode: csi.EraseDisplay, + protected_req: bool, +) void { + // We respect protected attributes if explicitly requested (probably + // a DECSEL sequence) or if our last protected mode was ISO even if its + // not currently set. + const protected = self.screens.active.protected_mode == .iso or protected_req; + + switch (mode) { + .scroll_complete => { + self.screens.active.scrollClear() catch |err| { + log.warn("scroll clear failed, doing a normal clear err={}", .{err}); + self.eraseDisplay(.complete, protected_req); + return; + }; + + // Unsets pending wrap state + 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( + self.screens.active.alloc, + self, + .{ .all = true }, + ); + } + }, + + .complete => { + // If we're on the primary screen and our last non-empty row is + // a prompt, then we do a scroll_complete instead. This is a + // heuristic to get the generally desirable behavior that ^L + // at a prompt scrolls the screen contents prior to clearing. + // Most shells send `ESC [ H ESC [ 2 J` so we can't just check + // our current cursor position. See #905 + if (self.screens.active_key == .primary) at_prompt: { + // Go from the bottom of the active up and see if we're + // at a prompt. + const active_br = self.screens.active.pages.getBottomRight( + .active, + ) orelse break :at_prompt; + var it = active_br.rowIterator( + .left_up, + self.screens.active.pages.getTopLeft(.active), + ); + while (it.next()) |p| { + const row = p.rowAndCell().row; + switch (row.semantic_prompt) { + // If we're at a prompt or input area, then we are at a prompt. + .prompt, + .prompt_continuation, + => break, + + // If we have command output, then we're most certainly not + // at a prompt. + .none => break :at_prompt, + } + } else break :at_prompt; + + self.screens.active.scrollClear() catch { + // If we fail, we just fall back to doing a normal clear + // so we don't worry about the error. + }; + } + + // All active area + self.screens.active.clearRows( + .{ .active = .{} }, + null, + protected, + ); + + // Unsets pending wrap state + 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( + self.screens.active.alloc, + self, + .{ .all = true }, + ); + } + + // Cleared screen dirty bit + self.flags.dirty.clear = true; + }, + + .below => { + // All lines to the right (including the cursor) + self.eraseLine(.right, protected_req); + + // All lines below + if (self.screens.active.cursor.y + 1 < self.rows) { + self.screens.active.clearRows( + .{ .active = .{ .y = self.screens.active.cursor.y + 1 } }, + null, + protected, + ); + } + + // Unsets pending wrap state. Should be done by eraseLine. + assert(!self.screens.active.cursor.pending_wrap); + }, + + .above => { + // Erase to the left (including the cursor) + self.eraseLine(.left, protected_req); + + // All lines above + if (self.screens.active.cursor.y > 0) { + self.screens.active.clearRows( + .{ .active = .{ .y = 0 } }, + .{ .active = .{ .y = self.screens.active.cursor.y - 1 } }, + protected, + ); + } + + // Unsets pending wrap state + assert(!self.screens.active.cursor.pending_wrap); + }, + + .scrollback => self.screens.active.eraseHistory(null), + } +} + +/// Resets all margins and fills the whole screen with the character 'E' +/// +/// Sets the cursor to the top left corner. +pub fn decaln(self: *Terminal) !void { + // Clear our stylistic attributes. This is the only thing that can + // fail so we do it first so we can undo it. + const old_style = self.screens.active.cursor.style; + self.screens.active.cursor.style = .{ + .bg_color = self.screens.active.cursor.style.bg_color, + .fg_color = self.screens.active.cursor.style.fg_color, + }; + errdefer self.screens.active.cursor.style = old_style; + try self.screens.active.manualStyleUpdate(); + + // Reset margins, also sets cursor to top-left + self.scrolling_region = .{ + .top = 0, + .bottom = self.rows - 1, + .left = 0, + .right = self.cols - 1, + }; + + // Origin mode is disabled + self.modes.set(.origin, false); + + // Move our cursor to the top-left + self.setCursorPos(1, 1); + + // Use clearRows instead of eraseDisplay because we must NOT respect + // protected attributes here. + self.screens.active.clearRows( + .{ .active = .{} }, + null, + false, + ); + + // Fill with Es by moving the cursor but reset it after. + while (true) { + const page = &self.screens.active.cursor.page_pin.node.data; + const row = self.screens.active.cursor.page_row; + const cells_multi: [*]Cell = row.cells.ptr(page.memory); + const cells = cells_multi[0..page.size.cols]; + @memset(cells, .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'E' }, + .style_id = self.screens.active.cursor.style_id, + + // DECALN does not respect protected state. Verified with xterm. + .protected = false, + }); + + // If we have a ref-counted style, increase + if (self.screens.active.cursor.style_id != style.default_id) { + page.styles.useMultiple( + page.memory, + self.screens.active.cursor.style_id, + @intCast(cells.len), + ); + row.styled = true; + } + + // We messed with the page so assert its integrity here. + page.assertIntegrity(); + + self.screens.active.cursorMarkDirty(); + if (self.screens.active.cursor.y == self.rows - 1) break; + self.screens.active.cursorDown(1); + } + + // Reset the cursor to the top-left + self.setCursorPos(1, 1); +} + +/// Execute a kitty graphics command. The buf is used to populate with +/// the response that should be sent as an APC sequence. The response will +/// be a full, valid APC sequence. +/// +/// If an error occurs, the caller should response to the pty that a +/// an error occurred otherwise the behavior of the graphics protocol is +/// undefined. +pub fn kittyGraphics( + self: *Terminal, + alloc: Allocator, + cmd: *kitty.graphics.Command, +) ?kitty.graphics.Response { + return kitty.graphics.execute(alloc, self, cmd); +} + +/// Set a style attribute. +pub fn setAttribute(self: *Terminal, attr: sgr.Attribute) !void { + try self.screens.active.setAttribute(attr); +} + +/// Print the active attributes as a string. This is used to respond to DECRQSS +/// requests. +/// +/// 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(); + + // 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 i: usize = 0; + + if (pen.flags.bold) { + attrs[i] = '1'; + i += 1; + } + + if (pen.flags.faint) { + attrs[i] = '2'; + i += 1; + } + + if (pen.flags.italic) { + attrs[i] = '3'; + i += 1; + } + + if (pen.flags.underline != .none) { + attrs[i] = '4'; + i += 1; + } + + if (pen.flags.blink) { + attrs[i] = '5'; + i += 1; + } + + if (pen.flags.inverse) { + attrs[i] = '7'; + i += 1; + } + + if (pen.flags.invisible) { + attrs[i] = '8'; + i += 1; + } + + if (pen.flags.strikethrough) { + attrs[i] = '9'; + i += 1; + } + + for (attrs[0..i]) |c| { + try writer.print(";{c}", .{c}); + } + + switch (pen.fg_color) { + .none => {}, + .palette => |idx| if (idx >= 16) + try writer.print(";38:5:{}", .{idx}) + else if (idx >= 8) + try writer.print(";9{}", .{idx - 8}) + else + try writer.print(";3{}", .{idx}), + .rgb => |rgb| try writer.print(";38:2::{[r]}:{[g]}:{[b]}", rgb), + } + + switch (pen.bg_color) { + .none => {}, + .palette => |idx| if (idx >= 16) + try writer.print(";48:5:{}", .{idx}) + else if (idx >= 8) + try writer.print(";10{}", .{idx - 8}) + else + try writer.print(";4{}", .{idx}), + .rgb => |rgb| try writer.print(";48:2::{[r]}:{[g]}:{[b]}", rgb), + } + + return stream.getWritten(); +} + +/// The modes for DECCOLM. +pub const DeccolmMode = enum(u1) { + @"80_cols" = 0, + @"132_cols" = 1, +}; + +/// DECCOLM changes the terminal width between 80 and 132 columns. This +/// function call will do NOTHING unless `setDeccolmSupported` has been +/// called with "true". +/// +/// This breaks the expectation around modern terminals that they resize +/// with the window. This will fix the grid at either 80 or 132 columns. +/// The rows will continue to be variable. +pub fn deccolm(self: *Terminal, alloc: Allocator, mode: DeccolmMode) !void { + // If DEC mode 40 isn't enabled, then this is ignored. We also make + // sure that we don't have deccolm set because we want to fully ignore + // set mode. + if (!self.modes.get(.enable_mode_3)) { + self.modes.set(.@"132_column", false); + return; + } + + // Enable it + self.modes.set(.@"132_column", mode == .@"132_cols"); + + // Resize to the requested size + try self.resize( + alloc, + switch (mode) { + .@"132_cols" => 132, + .@"80_cols" => 80, + }, + self.rows, + ); + + // Erase our display and move our cursor. + self.eraseDisplay(.complete, false); + self.setCursorPos(1, 1); +} + +/// Resize the underlying terminal. +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) { + self.tabstops.deinit(alloc); + self.tabstops = try .init(alloc, cols, 8); + } + + // Resize primary screen, which supports reflow + const primary = self.screens.get(.primary).?; + try primary.resize(.{ + .cols = cols, + .rows = 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, + }); + + // 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; + + // Reset the scrolling region + self.scrolling_region = .{ + .top = 0, + .bottom = rows - 1, + .left = 0, + .right = cols - 1, + }; +} + +/// Set the pwd for the terminal. +pub fn setPwd(self: *Terminal, pwd: []const u8) !void { + self.pwd.clearRetainingCapacity(); + if (pwd.len > 0) { + try self.pwd.appendSlice(self.gpa(), pwd); + try self.pwd.append(self.gpa(), 0); + } +} + +/// Returns the pwd for the terminal, if any. The memory is owned by the +/// Terminal and is not copied. It is safe until a reset or setPwd. +pub fn getPwd(self: *const Terminal) ?[:0]const u8 { + if (self.pwd.items.len == 0) return null; + return self.pwd.items[0 .. self.pwd.items.len - 1 :0]; +} + +/// Set the title for the terminal, as set by escape sequences (e.g. OSC 0/2). +pub fn setTitle(self: *Terminal, t: []const u8) !void { + self.title.clearRetainingCapacity(); + if (t.len > 0) { + try self.title.appendSlice(self.gpa(), t); + try self.title.append(self.gpa(), 0); + } +} + +/// Returns the title for the terminal, if any. The memory is owned by the +/// Terminal and is not copied. It is safe until a reset or setTitle. +pub fn getTitle(self: *const Terminal) ?[:0]const u8 { + if (self.title.items.len == 0) return null; + return self.title.items[0 .. self.title.items.len - 1 :0]; +} + +/// Switch to the given screen type (alternate or primary). +/// +/// This does NOT handle behaviors such as clearing the screen, +/// copying the cursor, etc. This should be handled by downstream +/// callers. +/// +/// After calling this function, the `self.screen` field will point +/// to the current screen, and the returned value will be the previous +/// screen. If the return value is null, then the screen was not +/// switched because it was already the active screen. +/// +/// Note: This is written in a generic way so that we can support +/// more than two screens in the future if needed. There isn't +/// currently a spec for this, but it is something I think might +/// be useful in the future. +pub fn switchScreen(self: *Terminal, key: ScreenSet.Key) !?*Screen { + // If we're already on the requested screen we do nothing. + if (self.screens.active_key == key) return null; + const old = self.screens.active; + + // We always end hyperlink state when switching screens. + // We need to do this on the original screen. + old.endHyperlink(); + + // Switch the screens/ + const new = self.screens.get(key) orelse new: { + const primary = self.screens.get(.primary).?; + break :new try self.screens.getInit( + old.alloc, + key, + .{ + .cols = self.cols, + .rows = self.rows, + .max_scrollback = switch (key) { + .primary => primary.pages.explicit_max_size, + .alternate => 0, + }, + + // Inherit our Kitty image storage limit from the primary + // screen if we have to initialize. + .kitty_image_storage_limit = if (comptime build_options.kitty_graphics) + primary.kitty_images.total_limit + else + 0, + }, + ); + }; + + // The new screen should not have any hyperlinks set + assert(new.cursor.hyperlink_id == 0); + + // Bring our charset state with us + new.charset = old.charset; + + // Clear our selection + new.clearSelection(); + + if (comptime build_options.kitty_graphics) { + // Mark kitty images as dirty so they redraw. Without this set + // the images will remain where they were (the dirty bit on + // the screen only tracks the terminal grid, not the images). + new.kitty_images.dirty = true; + } + + // Mark our terminal as dirty to redraw the grid. + self.flags.dirty.clear = true; + + // Finalize the switch + self.screens.switchTo(key); + + return old; +} + +/// Switch screen via a mode switch (e.g. mode 47, 1047, 1049). +/// This is a much more opinionated operation than `switchScreen` +/// since it also handles the behaviors of the specific mode, +/// such as clearing the screen, saving/restoring the cursor, +/// etc. +/// +/// This should be used for legacy compatibility with VT protocols, +/// but more modern usage should use `switchScreen` instead and handle +/// details like clearing the screen, cursor saving, etc. manually. +pub fn switchScreenMode( + self: *Terminal, + mode: SwitchScreenMode, + enabled: bool, +) !void { + // The behavior in this function is completely based on reading + // the xterm source, specifically "charproc.c" for + // `srm_ALTBUF`, `srm_OPT_ALTBUF`, and `srm_OPT_ALTBUF_CURSOR`. + // We shouldn't touch anything in here without adding a unit + // test AND verifying the behavior with xterm. + + switch (mode) { + .@"47" => {}, + + // If we're disabling 1047 and we're on alt screen then + // we clear the screen. + .@"1047" => if (!enabled and self.screens.active_key == .alternate) { + self.eraseDisplay(.complete, false); + }, + + // 1049 unconditionally saves the cursor on enabling, even + // if we're already on the alternate screen. + .@"1049" => if (enabled) self.saveCursor(), + } + + // Switch screens first to whatever we're going to. + const to: ScreenSet.Key = if (enabled) .alternate else .primary; + const old_ = try self.switchScreen(to); + + switch (mode) { + // For these modes, we need to copy the cursor. We only copy + // the cursor if the screen actually changed, otherwise the + // cursor is already copied. The cursor is copied regardless + // of destination screen. + .@"47", .@"1047" => if (old_) |old| { + self.screens.active.cursorCopy(old.cursor, .{ + .hyperlink = false, + }) catch |err| { + log.warn( + "cursor copy failed entering alt screen err={}", + .{err}, + ); + }; + }, + + // Mode 1049 restores cursor on the primary screen when + // we disable it. + .@"1049" => if (enabled) { + assert(self.screens.active_key == .alternate); + self.eraseDisplay(.complete, false); + + // When we enter alt screen with 1049, we always copy the + // cursor from the primary screen (if we weren't already + // on it). + if (old_) |old| { + self.screens.active.cursorCopy(old.cursor, .{ + .hyperlink = false, + }) catch |err| { + log.warn( + "cursor copy failed entering alt screen err={}", + .{err}, + ); + }; + } + } else { + assert(self.screens.active_key == .primary); + self.restoreCursor(); + }, + } +} + +/// Modal screen changes. These map to the literal terminal +/// modes to enable or disable alternate screen modes. They each +/// have subtle behaviors so we define them as an enum here. +pub const SwitchScreenMode = enum { + /// Legacy alternate screen mode. This goes to the alternate + /// screen or primary screen and only copies the cursor. The + /// screen is not erased. + @"47", + + /// Alternate screen mode where the alternate screen is cleared + /// on exit. The primary screen is never cleared. The cursor is + /// copied. + @"1047", + + /// Save primary screen cursor, switch to alternate screen, + /// and clear the alternate screen on entry. On exit, + /// do not clear the screen, and restore the cursor on the + /// primary screen. + @"1049", +}; + +/// Return the current string value of the terminal. Newlines are +/// encoded as "\n". This omits any formatting such as fg/bg. +/// +/// The caller must free the string. +pub fn plainString(self: *Terminal, alloc: Allocator) ![]const u8 { + return try self.screens.active.dumpStringAlloc(alloc, .{ .viewport = .{} }); +} + +/// Same as plainString, but respects row wrap state when building the string. +pub fn plainStringUnwrapped(self: *Terminal, alloc: Allocator) ![]const u8 { + return try self.screens.active.dumpStringAllocUnwrapped(alloc, .{ .viewport = .{} }); +} + +/// Full reset. +/// +/// This will attempt to free the existing screen memory but if that fails +/// this will reuse the existing memory. In the latter case, memory may +/// be wasted (since its unused) but it isn't leaked. +pub fn fullReset(self: *Terminal) void { + // Ensure we're back on primary screen + self.screens.switchTo(.primary); + self.screens.remove( + self.screens.active.alloc, + .alternate, + ); + + // Reset our screens + self.screens.active.reset(); + + // Rest our basic state + self.modes.reset(); + self.flags = .{}; + self.tabstops.reset(TABSTOP_INTERVAL); + self.previous_char = null; + self.pwd.clearRetainingCapacity(); + self.title.clearRetainingCapacity(); + self.status_display = .main; + self.scrolling_region = .{ + .top = 0, + .bottom = self.rows - 1, + .left = 0, + .right = self.cols - 1, + }; + + // Always mark dirty so we redraw everything + self.flags.dirty.clear = true; +} + +/// Returns true if the point is dirty, used for testing. +fn isDirty(t: *const Terminal, pt: point.Point) bool { + return t.screens.active.pages.getCell(pt).?.isDirty(); +} + +/// Clear all dirty bits. Testing only. +fn clearDirty(t: *Terminal) void { + t.screens.active.pages.clearDirty(); +} + +test "Terminal: input with no control characters" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 40, .rows = 40 }); + defer t.deinit(alloc); + + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("hello", str); + } + + // The first row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 5, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 5, .y = 1 } })); +} + +test "Terminal: input with basic wraparound" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 40 }); + defer t.deinit(alloc); + + // Basic grid writing + for ("helloworldabc12") |c| try t.print(c); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("hello\nworld\nabc12", str); + } +} + +test "Terminal: input with basic wraparound dirty" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 40 }); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + t.clearDirty(); + try t.print('w'); + + // Old row is dirty because cursor moved from there + try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); +} + +test "Terminal: input that forces scroll" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 1, .rows = 5 }); + defer t.deinit(alloc); + + // Basic grid writing + for ("abcdef") |c| try t.print(c); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("b\nc\nd\ne\nf", str); + } +} + +test "Terminal: input unique style per cell" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + defer t.deinit(alloc); + + for (0..t.rows) |y| { + for (0..t.cols) |x| { + t.setCursorPos(y, x); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = @intCast(x), + .g = @intCast(y), + .b = 0, + } }); + try t.print('x'); + } + } +} + +test "Terminal: input glitch text" { + const glitch = @embedFile("res/glitch.txt"); + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + defer t.deinit(alloc); + + // Get our initial grapheme capacity. + const grapheme_cap = cap: { + const page = t.screens.active.pages.pages.first.?; + break :cap page.data.capacity.grapheme_bytes; + }; + + // Print glitch text until our capacity changes + while (true) { + const page = t.screens.active.pages.pages.first.?; + if (page.data.capacity.grapheme_bytes != grapheme_cap) break; + try t.printString(glitch); + } + + // We're testing to make sure that grapheme capacity gets increased. + const page = t.screens.active.pages.pages.first.?; + try testing.expect(page.data.capacity.grapheme_bytes > grapheme_cap); +} + +test "Terminal: zero-width character at start" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // This used to crash the terminal. This is not allowed so we should + // just ignore it. + try t.print(0x200D); + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + + // Should not be dirty since we changed nothing. + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +// https://github.com/mitchellh/ghostty/issues/1400 +test "Terminal: print single very long line" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // This would crash for issue 1400. So the assertion here is + // that we simply do not crash. + for (0..1000) |_| try t.print('x'); +} + +test "Terminal: print wide char" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + { + 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(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(Cell.Wide.spacer_tail, cell.wide); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print wide char at edge creates spacer head" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.setCursorPos(1, 10); + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + 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(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } + + // Our first row just had a spacer head added which does not affect + // rendering so only the place where the wide char was printed + // should be marked. + // BUT old row is dirty because cursor moved from there + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); +} + +test "Terminal: print wide char with 1-column width" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 1, .rows = 2 }); + defer t.deinit(alloc); + + try t.print('π'); // 0x1F600 + + // This prints a space so we should be dirty. + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print wide char in single-width terminal" { + var t = try init(testing.allocator, .{ .cols = 1, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + { + 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(Cell.Wide.narrow, cell.wide); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print over wide char at 0,0" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.print(0x1F600); // Smiley face + t.setCursorPos(0, 0); + try t.print('A'); + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + { + 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(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(Cell.Wide.narrow, cell.wide); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); +} + +test "Terminal: print over wide char at col 0 corrupts previous row" { + // Crash found by AFL++ fuzzer (afl-out/stream/default/crashes/id:000002). + // + // printCell, when overwriting a wide cell with a narrow cell at x<=1 + // and y>0, sets the last cell of the previous row to .narrow β even + // 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 }); + defer t.deinit(alloc); + + // Fill rows 0 and 1 with wide chars (5 per row on a 10-col terminal). + for (0..10) |_| try t.print(0x4E2D); + + // Move cursor to row 1, col 0 (on top of a wide char) and print a + // narrow character. This triggers printCell's .wide branch which + // corrupts row 0's last cell: col 9 changes from .spacer_tail to + // .narrow, orphaning the .wide at col 8. + t.setCursorPos(2, 1); + try t.print('A'); + + // Row 1, col 0 should be narrow (we just overwrote the wide char). + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + try testing.expectEqual(Cell.Wide.narrow, list_cell.cell.wide); + } + // Row 0, col 8 should still be .wide (the last wide char on the row). + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 8, .y = 0 } }).?; + try testing.expectEqual(Cell.Wide.wide, list_cell.cell.wide); + } + // Row 0, col 9 must remain .spacer_tail to pair with the .wide at col 8. + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?; + try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide); + } +} + +test "Terminal: print over wide spacer tail" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + try t.print('ζ©'); + t.setCursorPos(1, 2); + try t.print('X'); + + { + 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(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(Cell.Wide.narrow, cell.wide); + } + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print over wide char with bold" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.setAttribute(.{ .bold = {} }); + try t.print(0x1F600); // Smiley face + // verify we have styles in our style map + { + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + } + + // Go back and overwrite with no style + t.setCursorPos(0, 0); + try t.setAttribute(.{ .unset = {} }); + try t.print('A'); // Smiley face + + // verify our style is gone + { + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 0), page.styles.count()); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print over wide char with bg color" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + try t.print(0x1F600); // Smiley face + // verify we have styles in our style map + { + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + } + + // Go back and overwrite with no style + t.setCursorPos(0, 0); + try t.setAttribute(.{ .unset = {} }); + try t.print('A'); // Smiley face + + // verify our style is gone + { + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 0), page.styles.count()); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print multicodepoint grapheme, disabled mode 2027" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: π¨βπ©βπ§ (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have 6 cells taken up + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 6), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } + { + 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.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + } + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } + { + 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.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + } + { + 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.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + } + { + 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.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: VS16 doesn't make character with 2027 disabled" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("β€οΈ", str); + } + + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } +} + +test "Terminal: ignored VS16 doesn't mark dirty" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + + try t.print(0x2764); // Heart + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + t.clearDirty(); + try t.print(0xFE0F); // VS16 to make wide + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print invalid VS16 non-grapheme" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('x'); + try t.print(0xFE0F); + + // We should have 1 narrow cell. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.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); + } +} + +test "Terminal: invalid VS16 doesn't mark dirty" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, false); + + try t.print('x'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + t.clearDirty(); + try t.print(0xFE0F); // VS16 to make wide + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print multicodepoint grapheme, mode 2027" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: π¨βπ©βπ§ (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 4), cps.len); + } + { + 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.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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "#οΈ" (number sign with text presentation selector) + try t.print(0x23); // # Number sign (valid base) + try t.print(0xFE0E); // VS15 (text presentation selector) + + // VS15 should combine with the base character into a single grapheme cluster, + // taking 1 cell (narrow character). + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // The base emoji should be in cell 0 with the skin tone as a grapheme + { + 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.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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "#οΈ" (number sign with emoji presentation selector) + try t.print(0x23); // # Number sign (valid base) + try t.print(0xFE0F); // VS16 (emoji presentation selector) + + // VS16 should combine with the base character into a single grapheme cluster, + // taking 2 cells (wide character). + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // The base emoji should be in cell 0 with the skin tone as a grapheme + { + 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.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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "ππΏ" (waving hand with dark skin tone) + try t.print(0x1F44B); // π Waving hand (valid base) + try t.print(0x1F3FF); // πΏ Dark skin tone modifier + + // The skin tone should combine with the base emoji into a single grapheme cluster, + // taking 2 cells (wide character). + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // The base emoji should be in cell 0 with the skin tone as a grapheme + { + 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.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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // This is: "πΏ" (which may not render correctly in your editor!) + try t.print(0x22); // " + try t.print(0x1F3FF); // Dark skin tone + try t.print(0x22); // " + + // We should have 4 cells taken up. Importantly, the skin tone + // should not join with the quotes. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + + // Row should be dirty + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.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.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.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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: π¨βπ©βπ§ (which may or may not render correctly) + try t.print(0x1F468); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0x200D); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0x1F469); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0x200D); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0x1F467); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); +} + +test "Terminal: VS15 to make narrow character" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x2614); // Umbrella with rain drops, width=2 + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + try t.print(0xFE0E); // VS15 to make narrow + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + + // VS15 should send us back a cell since our char is no longer wide. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("βοΈ", str); + } + + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } +} + +test "Terminal: VS15 on already narrow emoji" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x26C8); // Thunder cloud and rain, width=1 + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0xFE0E); // VS15 to make narrow + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + + // Character takes up one cell + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("βοΈ", str); + } + + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } +} + +test "Terminal: print invalid VS15 following emoji is wide" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('\u{1F9E0}'); // π§ + try t.print(0xFE0E); // not valid with U+1F9E0 as base + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.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(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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('\u{1F469}'); // π© + try t.print(0xFE0E); // not valid with U+1F469 as base + try t.print('\u{200D}'); // ZWJ + try t.print('\u{1F466}'); // π¦ + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.data.lookupGrapheme(cell).?); + 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(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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try testing.expect(t.modes.get(.wraparound)); + + try t.print(0x1F34B); // Lemon, width=2 + try t.print(0x2614); // Umbrella with rain drops, width=2 + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + + // We only move to the end of the line because we're in a pending wrap + // state. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + try t.print(0xFE0E); // VS15 to make narrow + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + + // VS15 should clear the pending wrap state + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("πβοΈ", str); + } + + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } + + // 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(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(Cell.Wide.spacer_tail, spacer_cell.wide); + } +} + +test "Terminal: VS16 to make wide character on next line" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(2); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } })); + t.clearDirty(); + + try t.print(0xFE0F); // VS16 to make wide + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 0 } })); + t.clearDirty(); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // 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.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // '#' 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.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + // 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.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering and activate a hyperlink so printCell + // calls cursorSetHyperlink (which runs page integrity checks). + t.modes.set(.grapheme_cluster, true); + try t.screens.active.startHyperlink("http://example.com", null); + + t.cursorRight(2); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // Without the fix, this panicked with UnwrappedSpacerHead. + try t.print(0xFE0F); // VS16 to make wide + + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // 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(Cell.Wide.spacer_head, cell.wide); + try testing.expect(cell.hyperlink); + try testing.expect(list_cell.row.wrap); + } + { + // '#' 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.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + try testing.expect(cell.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(Cell.Wide.spacer_tail, cell.wide); + try testing.expect(cell.hyperlink); + } +} + +test "Terminal: VS16 to make wide character with pending wrap" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(1); + try t.print('#'); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + try t.print(0xFE0F); // VS16 to make wide + + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expect(t.screens.active.cursor.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.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + // 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.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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x2764); // Heart + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + try t.print(0xFE0F); // VS16 to make wide + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("β€οΈ", str); + } + + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } +} + +test "Terminal: VS16 repeated with mode 2027" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + try t.print(0x2764); // Heart + try t.print(0xFE0F); // VS16 to make wide + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("β€οΈβ€οΈ", str); + } + + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + const cps = list_cell.node.data.lookupGrapheme(cell).?; + try testing.expectEqual(@as(usize, 1), cps.len); + } +} + +test "Terminal: print invalid VS16 grapheme" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('x'); + try t.print(0xFE0F); // invalid VS16 + + // We should have 1 cells taken up, and narrow. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.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(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: print invalid VS16 with second char" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('x'); + try t.print(0xFE0F); + try t.print('y'); + + // We should have 2 cells taken up, from two separate narrow characters. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.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, 'y'), cell.content.codepoint); + try testing.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: print grapheme oΜ (o with nonspacing mark) should be narrow" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print('o'); + try t.print(0x0300); // combining grave accent + + // We should have 1 cell taken up. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // ΰ€ΰ₯βΰ€· + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + 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(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print Devanagari grapheme should be wide on next line" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 3 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorRight(2); + + // ΰ€ΰ₯βΰ€· + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // This one increases the width to wide + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // 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.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // 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.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + t.cursorDown(rows - 1); + + for (rows..t.screens.active.pages.pages.first.?.data.capacity.rows) |_| { + try t.index(); + } + + t.cursorRight(cols - 1); + + try testing.expectEqual(cols - 1, t.screens.active.cursor.x); + try testing.expectEqual(rows - 1, t.screens.active.cursor.y); + + // ΰ€ΰ₯βΰ€· + try t.print(0x0915); + try t.print(0x094D); + try t.print(0x200D); + try testing.expectEqual(cols - 1, t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.pending_wrap); + + // This one increases the width to wide + try t.print(0x0937); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(rows - 1, t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + { + // 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.expect(!cell.hasGrapheme()); + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + } + { + // 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.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1, .y = rows - 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print invalid VS16 with second char (combining)" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/1482 + try t.print('n'); + try t.print(0xFE0F); // invalid VS16 + try t.print(0x0303); // combining tilde + + // We should have 1 cells taken up, and narrow. + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.expect(cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.data.lookupGrapheme(cell).?); + 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(Cell.Wide.narrow, cell.wide); + } +} + +test "Terminal: overwrite grapheme should clear grapheme data" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.print(0x26C8); // Thunder cloud and rain + try t.print(0xFE0E); // VS15 to make narrow + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + t.clearDirty(); + + t.setCursorPos(1, 1); + try t.print('A'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } + + { + 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.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 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: π¨βπ©βπ§ (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // We should have one cell with graphemes + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + // Move back and overwrite wide + t.setCursorPos(1, 1); + t.clearDirty(); + try t.print('X'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X", str); + } +} + +test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + // https://github.com/mitchellh/ghostty/issues/289 + // This is: π¨βπ©βπ§ (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have 2 cells taken up. It is one character but "wide". + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // We should have one cell with graphemes + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + // Move back and overwrite wide + t.setCursorPos(1, 2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); +} + +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 }); + defer t.deinit(alloc); + t.modes.set(.grapheme_cluster, true); + + // Make sure we're not at cursor.x == 0 for the next char. + try t.print('_'); + + // U+0600 ARABIC NUMBER SIGN (Prepend) + try t.print(0x0600); + try t.print('1'); + + // We should have 3 cells taken up, each narrow. Note that this is + // **incorrect** grapheme break behavior, since a Prepend code point should + // not break with the one following it per UAX #29 GB9b. However, as an + // optimization we assume a grapheme break when c <= 255, and note that + // this deviation only affects these very uncommon scenarios (e.g. the + // Arabic number sign should precede Arabic-script digits). + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.x); + // This is what we'd expect if we did break correctly: + //try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Assert various properties about our screen to verify + // we have all expected cells. + { + 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.expect(!cell.hasGrapheme()); + // This is what we'd expect if we did break correctly: + //try testing.expect(cell.hasGrapheme()); + //try testing.expectEqualSlices(u21, &.{'1'}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqual(Cell.Wide.narrow, cell.wide); + } + { + 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); + // This is what we'd expect if we did break correctly: + //try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + 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 }); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + t.setCursorPos(0, 0); + + // Make newlines so we create scrollback + // 3 pushes hello off the screen + try t.index(); + try t.index(); + try t.index(); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Scroll to the top + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello", str); + } + + // Type + try t.print('A'); + t.screens.active.scroll(.{ .active = {} }); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA", str); + } + + try testing.expect(t.isDirty(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, + } })); +} + +test "Terminal: print charset" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // G1 should have no effect + t.configureCharset(.G1, .dec_special); + t.configureCharset(.G2, .dec_special); + t.configureCharset(.G3, .dec_special); + + // No dirty to configure charset + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // Basic grid writing + try t.print('`'); + t.configureCharset(.G0, .utf8); + try t.print('`'); + t.configureCharset(.G0, .ascii); + try t.print('`'); + t.configureCharset(.G0, .dec_special); + try t.print('`'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("```β", str); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print charset outside of ASCII" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // G1 should have no effect + t.configureCharset(.G1, .dec_special); + t.configureCharset(.G2, .dec_special); + t.configureCharset(.G3, .dec_special); + + // No dirty to configure charset + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // Basic grid writing + t.configureCharset(.G0, .dec_special); + try t.print('`'); + try t.print(0x1F600); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("β ", str); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print invoke charset" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + t.configureCharset(.G1, .dec_special); + + try t.print('`'); + + // Invokecharset but should not mark dirty on its own + t.clearDirty(); + t.invokeCharset(.GL, .G1, false); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try t.print('`'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try t.print('`'); + t.invokeCharset(.GL, .G0, false); + try t.print('`'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("`ββ`", str); + } +} + +test "Terminal: print invoke charset single" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + t.configureCharset(.G1, .dec_special); + + // Basic grid writing + try t.print('`'); + t.invokeCharset(.GL, .G1, true); + try t.print('`'); + try t.print('`'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("`β`", str); + } +} + +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 }); + defer t.deinit(testing.allocator); + + try t.print(kitty.graphics.unicode.placeholder); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.x); + + { + 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.expect(list_cell.row.kitty_virtual_placeholder); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: soft wrap" { + var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hel\nlo", str); + } +} + +test "Terminal: soft wrap with semantic prompt" { + var t = try init(testing.allocator, .{ .cols = 3, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Mark our prompt. + try t.semanticPrompt(.init(.prompt_start)); + // Should not make anything dirty on its own. + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + // Write and wrap + for ("hello") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } +} + +test "Terminal: disabled wraparound with wide char and one space" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAA"); + t.clearDirty(); + try t.print(0x1F6A8); // Police car light + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAA", str); + } + + // Make sure we printed nothing + { + 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(Cell.Wide.narrow, cell.wide); + } + + // Should not be dirty since we didn't modify anything + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: disabled wraparound with wide char and no space" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAAA"); + t.clearDirty(); + try t.print(0x1F6A8); // Police car light + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAAA", str); + } + + { + 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(Cell.Wide.narrow, cell.wide); + } + + // Should not be dirty since we didn't modify anything + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: disabled wraparound with wide grapheme and half space" { + var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 }); + defer t.deinit(testing.allocator); + + t.modes.set(.grapheme_cluster, true); + t.modes.set(.wraparound, false); + + // This puts our cursor at the end and there is NO SPACE for a + // wide character. + try t.printString("AAAA"); + try t.print(0x2764); // Heart + t.clearDirty(); + try t.print(0xFE0F); // VS16 to make wide + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAAβ€", str); + } + + { + 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(Cell.Wide.narrow, cell.wide); + } + + // Should not be dirty since we didn't modify anything + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print right margin wrap" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + defer t.deinit(testing.allocator); + + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 5); + try t.printString("XY"); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1234X6789\n Y", str); + } + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } +} + +test "Terminal: print right margin wrap dirty tracking" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + defer t.deinit(testing.allocator); + + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 5); + + // Writing our X on the first line should mark only that line dirty. + t.clearDirty(); + try t.print('X'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 2, .y = 1 } })); + + // Writing our Y should wrap. It marks both rows dirty because the + // cursor moved. + t.clearDirty(); + try t.print('Y'); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 2, .y = 1 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1234X6789\n Y", str); + } +} + +test "Terminal: print right margin outside" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + defer t.deinit(testing.allocator); + + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 6); + t.clearDirty(); + try t.printString("XY"); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("12345XY89", str); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 5, .y = 0 } })); +} + +test "Terminal: print right margin outside wrap" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 5 }); + defer t.deinit(testing.allocator); + + try t.printString("123456789"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 10); + try t.printString("XY"); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("123456789X\n Y", str); + } +} + +test "Terminal: print wide char at right margin does not create spacer head" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 5); + try t.print(0x1F600); // Smiley face + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x); + + // Both rows dirty because the cursor moved + try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 4, .y = 1 } })); + + { + 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(Cell.Wide.narrow, cell.wide); + + const row = list_cell.row; + try testing.expect(!row.wrap); + } + { + 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(Cell.Wide.wide, cell.wide); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 3, .y = 1 } }).?; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); + } +} + +test "Terminal: print with hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("123456"); + + // Verify all our cells have a hyperlink + for (0..6) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print over cell with same hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("123456"); + t.setCursorPos(1, 1); + try t.printString("123456"); + + // Verify all our cells have a hyperlink + for (0..6) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print and end hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("123"); + t.screens.active.endHyperlink(); + try t.printString("456"); + + // Verify all our cells have a hyperlink + for (0..3) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + for (3..6) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: print and change hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screens.active.startHyperlink("http://one.example.com", null); + try t.printString("123"); + try t.screens.active.startHyperlink("http://two.example.com", null); + try t.printString("456"); + + // Verify all our cells have a hyperlink + for (0..3) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + for (3..6) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 2), id); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +test "Terminal: overwrite hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Setup our hyperlink and print + try t.screens.active.startHyperlink("http://one.example.com", null); + try t.printString("123"); + t.setCursorPos(1, 1); + t.screens.active.endHyperlink(); + try t.printString("456"); + + // Verify all our cells have a hyperlink + for (0..3) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const page = &list_cell.node.data; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + try testing.expect(page.lookupHyperlink(cell) == null); + try testing.expectEqual(0, page.hyperlink_set.count()); + } + + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); +} + +// Printing a wide char at the right edge with an active hyperlink causes +// printCell to write a spacer_head before printWrap sets the row wrap +// 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 }); + defer t.deinit(testing.allocator); + + try t.screens.active.startHyperlink("http://example.com", null); + + // Move cursor to the last column (1-indexed) + t.setCursorPos(1, 10); + + // Print a wide character; this will call printCell(0, .spacer_head) + // at the right edge before calling printWrap, triggering the + // integrity violation. + try t.print(0x4E2D); // U+4E2D 'δΈ' + + // Cursor wraps to row 2, after the wide char + spacer tail + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); + + // Row 0, col 9: spacer head with hyperlink + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 9, .y = 0 } }).?; + try testing.expectEqual(Cell.Wide.spacer_head, list_cell.cell.wide); + try testing.expect(list_cell.cell.hyperlink); + try testing.expect(list_cell.row.wrap); + } + // 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(Cell.Wide.wide, list_cell.cell.wide); + try testing.expect(list_cell.cell.hyperlink); + } + // Row 1, col 1: spacer tail with hyperlink + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; + try testing.expectEqual(Cell.Wide.spacer_tail, list_cell.cell.wide); + try testing.expect(list_cell.cell.hyperlink); + } +} + +test "Terminal: linefeed and carriage return" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Print and CR. + for ("hello") |c| try t.print(c); + t.clearDirty(); + t.carriageReturn(); + + // CR should not mark row dirty because it doesn't change rendering. + try testing.expect(!t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + + try t.linefeed(); + + // LF marks row dirty due to cursor movement + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); + + for ("world") |c| try t.print(c); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello\nworld", str); + } +} + +test "Terminal: linefeed unsets pending wrap" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap == true); + t.clearDirty(); + try t.linefeed(); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 1 } })); + try testing.expect(t.screens.active.cursor.pending_wrap == false); +} + +test "Terminal: linefeed mode automatic carriage return" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + // Basic grid writing + t.modes.set(.linefeed, true); + try t.printString("123456"); + try t.linefeed(); + try t.print('X'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("123456\nX", str); + } +} + +test "Terminal: carriage return unsets pending wrap" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Basic grid writing + for ("hello") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap == true); + t.carriageReturn(); + try testing.expect(t.screens.active.cursor.pending_wrap == false); +} + +test "Terminal: carriage return origin mode moves to left margin" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + defer t.deinit(testing.allocator); + + t.modes.set(.origin, true); + t.screens.active.cursor.x = 0; + t.scrolling_region.left = 2; + t.carriageReturn(); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); +} + +test "Terminal: carriage return left of left margin moves to zero" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + defer t.deinit(testing.allocator); + + t.screens.active.cursor.x = 1; + t.scrolling_region.left = 2; + t.carriageReturn(); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); +} + +test "Terminal: carriage return right of left margin moves to left margin" { + var t = try init(testing.allocator, .{ .cols = 5, .rows = 80 }); + defer t.deinit(testing.allocator); + + t.screens.active.cursor.x = 3; + t.scrolling_region.left = 2; + t.carriageReturn(); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); +} + +test "Terminal: backspace" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // BS + for ("hello") |c| try t.print(c); + t.backspace(); + try t.print('y'); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("helly", str); + } +} + +test "Terminal: horizontal tabs" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + defer t.deinit(alloc); + + // HT + try t.print('1'); + t.horizontalTab(); + try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x); + + // HT + t.horizontalTab(); + try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); + + // HT at the end + t.horizontalTab(); + try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x); + t.horizontalTab(); + try testing.expectEqual(@as(usize, 19), t.screens.active.cursor.x); +} + +test "Terminal: horizontal tabs starting on tabstop" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(t.screens.active.cursor.y, 9); + try t.print('X'); + t.setCursorPos(t.screens.active.cursor.y, 9); + t.horizontalTab(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X A", str); + } +} + +test "Terminal: horizontal tabs with right margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + defer t.deinit(alloc); + + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + t.setCursorPos(t.screens.active.cursor.y, 1); + try t.print('X'); + t.horizontalTab(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X A", str); + } +} + +test "Terminal: horizontal tabs back" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + defer t.deinit(alloc); + + // Edge of screen + t.setCursorPos(t.screens.active.cursor.y, 20); + + // HT + t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); + + // HT + t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 8), t.screens.active.cursor.x); + + // HT + t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + t.horizontalTabBack(); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); +} + +test "Terminal: horizontal tabs back starting on tabstop" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(t.screens.active.cursor.y, 9); + try t.print('X'); + t.setCursorPos(t.screens.active.cursor.y, 9); + t.horizontalTabBack(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A X", str); + } +} + +test "Terminal: horizontal tabs with left margin in origin mode" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + defer t.deinit(alloc); + + t.modes.set(.origin, true); + t.scrolling_region.left = 2; + t.scrolling_region.right = 5; + t.setCursorPos(1, 2); + try t.print('X'); + t.horizontalTabBack(); + try t.print('A'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" AX", str); + } +} + +test "Terminal: horizontal tab back with cursor before left margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 20, .rows = 5 }); + defer t.deinit(alloc); + + t.modes.set(.origin, true); + t.saveCursor(); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(5, 0); + t.restoreCursor(); + t.horizontalTabBack(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X", str); + } +} + +test "Terminal: cursorPos resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.setCursorPos(1, 1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBCDE", str); + } +} + +test "Terminal: cursorPos off the screen" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(500, 500); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\n\n X", str); + } +} + +test "Terminal: cursorPos relative to origin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.modes.set(.origin, true); + t.setCursorPos(1, 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nX", str); + } +} + +test "Terminal: cursorPos relative to origin with left/right" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.modes.set(.origin, true); + t.setCursorPos(1, 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n X", str); + } +} + +test "Terminal: cursorPos limits with full scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.scrolling_region.top = 2; + t.scrolling_region.bottom = 3; + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.modes.set(.origin, true); + t.setCursorPos(500, 500); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\n X", str); + } +} + +// 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 }); + defer t.deinit(testing.allocator); + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + // Setting it to 0 should keep it zero (1 based) + t.setCursorPos(0, 0); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + + // Should clamp to size + t.setCursorPos(81, 81); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.y); + + // Should reset pending wrap + t.setCursorPos(0, 80); + try t.print('c'); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.setCursorPos(0, 80); + try testing.expect(!t.screens.active.cursor.pending_wrap); + + // Origin mode + t.modes.set(.origin, true); + + // No change without a scroll region + t.setCursorPos(81, 81); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.y); + + // Set the scroll region + t.setTopAndBottomMargin(10, t.rows); + t.setCursorPos(0, 0); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); + + t.setCursorPos(1, 1); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 9), t.screens.active.cursor.y); + + t.setCursorPos(100, 0); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 79), t.screens.active.cursor.y); + + t.setTopAndBottomMargin(10, 11); + t.setCursorPos(2, 0); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 10), t.screens.active.cursor.y); +} + +test "Terminal: setTopAndBottomMargin simple" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(0, 0); + + t.clearDirty(); + t.scrollDown(1); + + // Mark the rows we moved as dirty. + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + +test "Terminal: setTopAndBottomMargin top only" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(2, 0); + + t.clearDirty(); + t.scrollDown(1); + + // This is dirty because the cursor moves from this row + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + } +} + +test "Terminal: setTopAndBottomMargin top and bottom" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(1, 2); + + t.clearDirty(); + t.scrollDown(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nGHI", str); + } +} + +test "Terminal: setTopAndBottomMargin top equal to bottom" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(2, 2); + + t.clearDirty(); + t.scrollDown(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + +test "Terminal: setLeftAndRightMargin simple" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(0, 0); + + t.clearDirty(); + t.eraseChars(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" BC\nDEF\nGHI", str); + } +} + +test "Terminal: setLeftAndRightMargin left only" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 0); + try testing.expectEqual(@as(usize, 1), t.scrolling_region.left); + try testing.expectEqual(@as(usize, t.cols - 1), t.scrolling_region.right); + t.setCursorPos(1, 2); + + t.clearDirty(); + t.insertLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); + } +} + +test "Terminal: setLeftAndRightMargin left and right" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(1, 2); + t.setCursorPos(1, 2); + + t.clearDirty(); + t.insertLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C\nABF\nDEI\nGH", str); + } +} + +test "Terminal: setLeftAndRightMargin left equal right" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 2); + t.setCursorPos(1, 2); + + t.clearDirty(); + t.insertLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + +test "Terminal: setLeftAndRightMargin mode 69 unset" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, false); + t.setLeftAndRightMargin(1, 2); + t.setCursorPos(1, 2); + + t.clearDirty(); + t.insertLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + +test "Terminal: insertLines simple" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + + t.clearDirty(); + t.insertLines(1); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + } +} + +test "Terminal: insertLines colors with bg color" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\nGHI", str); + } + + for (0..t.cols) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 1, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: insertLines handles style refs" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + + // For the line being deleted, create a refcounted style + try t.setAttribute(.{ .bold = {} }); + try t.printString("GHI"); + try t.setAttribute(.{ .unset = {} }); + + // verify we have styles in our style map + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF", str); + } + + // verify we have no styles in our style map + try testing.expectEqual(@as(usize, 0), page.styles.count()); +} + +test "Terminal: insertLines outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(3, 4); + t.setCursorPos(2, 2); + + t.clearDirty(); + t.insertLines(1); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: insertLines top/bottom scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("123"); + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(2, 2); + + t.clearDirty(); + t.insertLines(1); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nDEF\n123", str); + } +} + +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 }); + defer t.deinit(alloc); + + const first_page = t.screens.active.pages.pages.first.?; + const first_page_nrows = first_page.data.capacity.rows; + + // Fill up the first page minus 3 rows + for (0..first_page_nrows - 3) |_| try t.linefeed(); + + // Add content that will cross a page boundary + try t.printString("1AAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("2BBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("3CCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("4DDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("5EEEE"); + + // Verify we now have a second page + try testing.expect(first_page.next != null); + + t.setCursorPos(1, 1); + t.clearDirty(); + t.insertLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n1AAAA\n2BBBB\n3CCCC\n4DDDD", str); + } +} + +test "Terminal: insertLines (legacy test)" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); + + // Move to row 2 + t.setCursorPos(2, 1); + + // Insert two lines + t.insertLines(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\nB\nC", str); + } +} + +test "Terminal: insertLines zero" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + // This should do nothing + t.setCursorPos(1, 1); + t.insertLines(0); +} + +test "Terminal: insertLines with scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 6 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); + + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(1, 1); + + t.clearDirty(); + t.insertLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nA\nC\nD\nE", str); + } +} + +test "Terminal: insertLines more than remaining" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + try t.print('E'); + + // Move to row 2 + t.setCursorPos(2, 1); + + // Insert a bunch of lines + t.clearDirty(); + t.insertLines(20); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } +} + +test "Terminal: insertLines resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.insertLines(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nABCDE", str); + } +} + +test "Terminal: insertLines resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + try t.print('1'); + t.carriageReturn(); + try t.linefeed(); + for ("ABCDEF") |c| try t.print(c); + t.setCursorPos(1, 1); + t.insertLines(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n1\nABC", str); + } + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 2 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } +} + +test "Terminal: insertLines multi-codepoint graphemes" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + + // This is: π¨βπ©βπ§ (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.insertLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\n\nπ¨βπ©βπ§\nGHI", str); + } +} + +test "Terminal: insertLines left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + + t.clearDirty(); + t.insertLines(1); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC123\nD 56\nGEF489\n HI7", str); + } +} + +test "Terminal: scrollUp simple" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + + const cursor = t.screens.active.cursor; + const viewport_before = t.screens.active.pages.getTopLeft(.viewport); + try t.scrollUp(1); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); + + // Viewport should have moved. Our entire page should've scrolled! + // The viewport moving will cause our render state to make the full + // frame as dirty. + const viewport_after = t.screens.active.pages.getTopLeft(.viewport); + try testing.expect(!viewport_before.eql(viewport_after)); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("DEF\nGHI", str); + } +} + +test "Terminal: scrollUp moves hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("DEF"); + t.screens.active.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + try t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("DEF\nGHI", str); + } + + for (0..3) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.node.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (0..3) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: scrollUp clears hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.screens.active.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + try t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("DEF\nGHI", str); + } + + for (0..3) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: scrollUp top/bottom scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(1, 1); + + t.clearDirty(); + try t.scrollUp(1); + + // This is dirty because the cursor moves from this row + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } +} + +test "Terminal: scrollUp left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + + const cursor = t.screens.active.cursor; + t.clearDirty(); + try t.scrollUp(1); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + } +} + +test "Terminal: scrollUp left/right scroll region hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("DEF456"); + t.screens.active.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + try t.scrollUp(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + } + + // First row gets some hyperlinks + { + for (0..1) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (1..4) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.node.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (4..6) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + } + + // Second row preserves hyperlink where we didn't scroll + { + for (0..1) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.node.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (1..4) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (4..6) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.node.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + } +} + +test "Terminal: scrollUp preserves pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.setCursorPos(2, 5); + try t.print('B'); + t.setCursorPos(3, 5); + try t.print('C'); + try t.scrollUp(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" B\n C\n\nX", str); + } +} + +test "Terminal: scrollUp full top/bottom region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("top"); + t.setCursorPos(5, 1); + try t.printString("ABCDE"); + t.setTopAndBottomMargin(2, 5); + + t.clearDirty(); + try t.scrollUp(4); + + // This is dirty because the cursor moves from this row + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("top", str); + } +} + +test "Terminal: scrollUp full top/bottomleft/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("top"); + t.setCursorPos(5, 1); + try t.printString("ABCDE"); + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(2, 5); + t.setLeftAndRightMargin(2, 4); + + t.clearDirty(); + try t.scrollUp(4); + + // This is dirty because the cursor moves from this row + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + for (1..5) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("top\n\n\n\nA E", str); + } +} + +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 }); + defer t.deinit(alloc); + + // Fill the screen with content + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEE"); + + t.clearDirty(); + + // Scroll up by 1, which should push "AAAAA" into scrollback + try t.scrollUp(1); + + // The cursor row (new empty row) should be dirty + try testing.expect(t.screens.active.cursor.page_row.dirty); + + // The active screen should now show BBBBB through EEEEE plus one blank line + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } + + // Now scroll to the top to see scrollback - AAAAA should be there + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // Should see AAAAA in scrollback + try testing.expectEqualStrings("AAAAA\nBBBBB\nCCCCC\nDDDDD\nEEEEE", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero" { + // When max_scrollback 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 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + + try t.scrollUp(1); + + // Active screen should show scrolled content + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } + + // Scroll to top - should be same as active since no scrollback + t.screens.active.scroll(.{ .top = {} }); + { + const str = try t.plainString(alloc); + defer alloc.free(str); + try testing.expectEqualStrings("BBBBB\nCCCCC", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and top margin" { + // When max_scrollback 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 }); + defer t.deinit(alloc); + + try t.printString("AAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("BBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DDDDD"); + + // Set top margin (not at row 0) + t.setTopAndBottomMargin(2, 5); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // First row preserved, rest scrolled + try testing.expectEqualStrings("AAAAA\nCCCCC\nDDDDD", str); + } +} + +test "Terminal: scrollUp with max_scrollback zero and left/right margin" { + // When max_scrollback 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 }); + defer t.deinit(alloc); + + try t.printString("AAAAABBBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("CCCCCDDDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("EEEEEFFFFF"); + + // Set left/right margins (columns 2-6, 1-indexed = indices 1-5) + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 6); + + try t.scrollUp(1); + + { + const str = try t.plainString(alloc); + defer alloc.free(str); + // cols 1-5 scroll, col 0 and cols 6+ preserved + try testing.expectEqualStrings("ACCCCDBBBB\nCEEEEFDDDD\nE FFFF", str); + } +} + +test "Terminal: scrollDown simple" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + + const cursor = t.screens.active.cursor; + t.clearDirty(); + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); + + for (0..5) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } +} + +test "Terminal: scrollDown hyperlink moves" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.screens.active.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nABC\nDEF\nGHI", str); + } + + for (0..3) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.node.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (0..3) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: scrollDown outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setTopAndBottomMargin(3, 4); + t.setCursorPos(2, 2); + + const cursor = t.screens.active.cursor; + t.clearDirty(); + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + // This is dirty because the cursor moves from this row + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\n\nGHI", str); + } +} + +test "Terminal: scrollDown left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + + const cursor = t.screens.active.cursor; + t.clearDirty(); + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); + + for (0..4) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } +} + +test "Terminal: scrollDown left/right scroll region hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("ABC123"); + t.screens.active.endHyperlink(); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } + + // First row preserves hyperlink where we didn't scroll + { + for (0..1) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.node.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (1..4) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (4..6) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.node.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + } + + // Second row gets some hyperlinks + { + for (0..1) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + for (1..4) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + const page = &list_cell.node.data; + try testing.expectEqual(1, page.hyperlink_set.count()); + } + for (4..6) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = @intCast(x), + .y = 1, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } + } +} + +test "Terminal: scrollDown outside of left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(1, 1); + + const cursor = t.screens.active.cursor; + t.clearDirty(); + t.scrollDown(1); + try testing.expectEqual(cursor.x, t.screens.active.cursor.x); + try testing.expectEqual(cursor.y, t.screens.active.cursor.y); + + for (0..4) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 23\nDBC156\nGEF489\n HI7", str); + } +} + +test "Terminal: scrollDown preserves pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.setCursorPos(2, 5); + try t.print('B'); + t.setCursorPos(3, 5); + try t.print('C'); + t.scrollDown(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n A\n B\nX C", str); + } +} + +test "Terminal: eraseChars simple operation" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.clearDirty(); + t.eraseChars(2); + try t.print('X'); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X C", str); + } +} + +test "Terminal: eraseChars minimum one" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.clearDirty(); + t.eraseChars(0); + try t.print('X'); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBC", str); + } +} + +test "Terminal: eraseChars beyond screen edge" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for (" ABC") |c| try t.print(c); + t.setCursorPos(1, 4); + t.eraseChars(10); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); + } +} + +test "Terminal: eraseChars wide character" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('ζ©'); + for ("BC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X BC", str); + } +} + +test "Terminal: eraseChars resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.eraseChars(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: eraseChars resets wrap" { + const alloc = testing.allocator; + var t = try init(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 } }).?; + const row = list_cell.row; + try testing.expect(row.wrap); + } + + t.setCursorPos(1, 1); + t.eraseChars(1); + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } + + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBCDE\n123", str); + } +} + +test "Terminal: eraseChars preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseChars(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 1, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: eraseChars handles refcounted styles" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.print('B'); + try t.setAttribute(.{ .unset = {} }); + try t.print('C'); + + // verify we have styles in our style map + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + + t.setCursorPos(1, 1); + t.eraseChars(2); + + // verify we have no styles in our style map + try testing.expectEqual(@as(usize, 0), page.styles.count()); +} + +test "Terminal: eraseChars protected attributes respected with iso" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +test "Terminal: eraseChars protected attributes ignored with dec most recent" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 1); + t.eraseChars(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + +test "Terminal: eraseChars protected attributes ignored with dec set" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.eraseChars(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + +test "Terminal: eraseChars wide char boundary conditions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 1, .cols = 8 }); + defer t.deinit(alloc); + + try t.printString("πaπbπ"); + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings("πaπbπ", str); + } + + t.setCursorPos(1, 2); + t.eraseChars(3); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" bπ", str); + } +} + +test "Terminal: eraseChars wide char splits proper cell boundaries" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 1, .cols = 30 }); + defer t.deinit(alloc); + + // This is a test for a bug: https://github.com/ghostty-org/ghostty/issues/2817 + // To explain the setup: + // (1) We need our wide characters starting on an even (1-based) column. + // (2) We need our cursor to be in the middle somewhere. + // (3) We need our count to be less than our cursor X and on a split cell. + // The bug was that we split the wrong cell boundaries. + + try t.printString("xι£γΉγ¦δΈγγ"); + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings("xι£γΉγ¦δΈγγ", str); + } + + t.setCursorPos(1, 6); // At: γ¦ + t.eraseChars(4); // Delete: γ¦δΈ + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings("xι£γΉ γγ", str); + } +} + +test "Terminal: eraseChars wide char wrap boundary conditions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 8 }); + defer t.deinit(alloc); + + try t.printString(".......πabcdeπ......"); + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings(".......\nπabcde\nπ......", str); + + const unwrapped = try t.plainStringUnwrapped(alloc); + defer testing.allocator.free(unwrapped); + try testing.expectEqualStrings(".......πabcdeπ......", unwrapped); + } + + t.setCursorPos(2, 2); + t.eraseChars(3); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings(".......\n cde\nπ......", str); + + const unwrapped = try t.plainStringUnwrapped(alloc); + defer testing.allocator.free(unwrapped); + try testing.expectEqualStrings("....... cde\nπ......", unwrapped); + } +} + +test "Terminal: reverseIndex" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.reverseIndex(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + t.carriageReturn(); + try t.linefeed(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nBD\nC", str); + } +} + +test "Terminal: reverseIndex from the top" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + t.carriageReturn(); + try t.linefeed(); + + t.setCursorPos(1, 1); + t.reverseIndex(); + try t.print('D'); + + t.carriageReturn(); + try t.linefeed(); + t.setCursorPos(1, 1); + t.reverseIndex(); + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("E\nD\nA\nB", str); + } +} + +test "Terminal: reverseIndex top of scrolling region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 10 }); + defer t.deinit(alloc); + + // Initial value + t.setCursorPos(2, 1); + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + t.carriageReturn(); + try t.linefeed(); + + // Set our scroll region + t.setTopAndBottomMargin(2, 5); + t.setCursorPos(2, 1); + t.reverseIndex(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nX\nA\nB\nC", str); + } +} + +test "Terminal: reverseIndex top of screen" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setCursorPos(1, 1); + t.reverseIndex(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nA\nB\nC", str); + } +} + +test "Terminal: reverseIndex not top of screen" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setCursorPos(2, 1); + t.reverseIndex(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\nB\nC", str); + } +} + +test "Terminal: reverseIndex top/bottom margins" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(2, 1); + t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\nB", str); + } +} + +test "Terminal: reverseIndex outside top/bottom margins" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.setCursorPos(2, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('C'); + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(1, 1); + t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nB\nC", str); + } +} + +test "Terminal: reverseIndex left/right margins" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.setCursorPos(2, 1); + try t.printString("DEF"); + t.setCursorPos(3, 1); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 3); + t.setCursorPos(1, 2); + t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nDBC\nGEF\n HI", str); + } +} + +test "Terminal: reverseIndex outside left/right margins" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.setCursorPos(2, 1); + try t.printString("DEF"); + t.setCursorPos(3, 1); + try t.printString("GHI"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 3); + t.setCursorPos(1, 1); + t.reverseIndex(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: index" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + try t.index(); + try t.print('A'); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA", str); + } +} + +test "Terminal: index from the bottom" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.print('A'); + t.cursorLeft(1); // undo moving right from 'A' + + t.clearDirty(); + try t.index(); + try t.print('B'); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA\nB", str); + } +} + +test "Terminal: index scrolling with hyperlink" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.screens.active.startHyperlink("http://example.com", null); + try t.print('A'); + t.screens.active.endHyperlink(); + t.cursorLeft(1); // undo moving right from 'A' + try t.index(); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA\nB", str); + } + + { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 3, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 4, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: index outside of scrolling region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + t.setTopAndBottomMargin(2, 5); + try t.index(); + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); +} + +test "Terminal: index from the bottom outside of scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(5, 1); + try t.print('A'); + t.clearDirty(); + try t.index(); + try t.print('B'); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\n\nAB", str); + } +} + +test "Terminal: index no scroll region, top of screen" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.clearDirty(); + try t.index(); + try t.print('X'); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n X", str); + } +} + +test "Terminal: index bottom of primary screen" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.print('A'); + t.clearDirty(); + try t.index(); + try t.print('X'); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA\n X", str); + } +} + +test "Terminal: index bottom of primary screen background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(5, 1); + try t.print('A'); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + try t.index(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\n\nA", str); + for (0..5) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 4, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: index inside scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.clearDirty(); + try t.index(); + try t.print('X'); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n X", str); + } +} + +test "Terminal: index bottom of scroll region with hyperlinks" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 2); + try t.print('A'); + try t.index(); + t.carriageReturn(); + try t.screens.active.startHyperlink("http://example.com", null); + try t.print('B'); + t.screens.active.endHyperlink(); + try t.index(); + t.carriageReturn(); + try t.print('C'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B\nC", str); + } + + { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = 1, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +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 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(2, 3); + t.setCursorPos(2, 1); + try t.screens.active.startHyperlink("http://example.com", null); + try t.print('A'); + t.screens.active.endHyperlink(); + try t.index(); + t.carriageReturn(); + try t.print('B'); + try t.index(); + t.carriageReturn(); + try t.print('C'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nB\nC", str); + } + + for (1..3) |y| { + const list_cell = t.screens.active.pages.getCell(.{ .viewport = .{ + .x = 0, + .y = @intCast(y), + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + const page = &list_cell.node.data; + try testing.expectEqual(0, page.hyperlink_set.count()); + } +} + +test "Terminal: index bottom of scroll region with background SGR" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('A'); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + try t.index(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA\n\nB", str); + } + + for (0..t.cols) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 2, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: index bottom of primary screen with scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(3, 1); + try t.print('A'); + t.setCursorPos(5, 1); + t.clearDirty(); + try t.index(); + try t.index(); + try t.index(); + try t.print('X'); + + for (0..4) |y| try testing.expect(!t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nA\n\nX", str); + } +} + +test "Terminal: index outside left/right margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.scrolling_region.left = 3; + t.scrolling_region.right = 5; + t.setCursorPos(3, 3); + try t.print('A'); + t.setCursorPos(3, 1); + t.clearDirty(); + try t.index(); + try t.print('X'); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nX A", str); + } +} + +test "Terminal: index inside left/right margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + try t.printString("AAAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("AAAAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("AAAAAA"); + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(1, 3); + t.setLeftAndRightMargin(1, 3); + t.setCursorPos(3, 1); + + t.clearDirty(); + try t.index(); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AAAAAA\nAAAAAA\n AAA", str); + } +} + +test "Terminal: index bottom of scroll region creates scrollback" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.printString("1\n2\n3"); + t.setCursorPos(4, 1); + try t.print('X'); + t.setCursorPos(3, 1); + try t.index(); + try t.print('Y'); + + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2\n3\nY\nX", str); + } + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1\n2\n3\nY\nX", str); + } +} + +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 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); + try t.print('B'); + t.setCursorPos(3, 1); + try t.print('A'); + t.clearDirty(); + try t.index(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nA\n X\nB", str); + } +} + +test "Terminal: index bottom of scroll region blank line preserves SGR" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.printString("1\n2\n3"); + t.setCursorPos(4, 1); + try t.print('X'); + t.setCursorPos(3, 1); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + try t.index(); + + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2\n3\n\nX", str); + } + { + const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1\n2\n3\n\nX", str); + } + for (0..t.cols) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 2, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: cursorUp basic" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(3, 1); + try t.print('A'); + t.cursorUp(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X\n\nA", str); + } +} + +test "Terminal: cursorUp below top scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(2, 4); + t.setCursorPos(3, 1); + try t.print('A'); + t.cursorUp(5); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n X\nA", str); + } +} + +test "Terminal: cursorUp above top scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(3, 5); + t.setCursorPos(3, 1); + try t.print('A'); + t.setCursorPos(2, 1); + t.cursorUp(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n\nA", str); + } +} + +test "Terminal: cursorUp resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.cursorUp(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: cursorLeft no wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.cursorLeft(10); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nB", str); + } +} + +test "Terminal: cursorLeft unsets pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCXE", str); + } +} + +test "Terminal: cursorLeft unsets pending wrap state with longer jump" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.cursorLeft(3); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AXCDE", str); + } +} + +test "Terminal: cursorLeft reverse wrap with pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: cursorLeft reverse wrap extended with pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.cursorLeft(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: cursorLeft reverse wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE1") |c| try t.print(c); + t.cursorLeft(2); + try t.print('X'); + try testing.expect(t.screens.active.cursor.pending_wrap); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX\n1", str); + } +} + +test "Terminal: cursorLeft reverse wrap with no soft wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\nX", str); + } +} + +test "Terminal: cursorLeft reverse wrap before left margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + t.setTopAndBottomMargin(3, 0); + t.cursorLeft(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n\nX", str); + } +} + +test "Terminal: cursorLeft extended reverse wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(2); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX\n1", str); + } +} + +test "Terminal: cursorLeft extended reverse wrap bottom wraparound" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(1 + t.cols + 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); + } +} + +test "Terminal: cursorLeft extended reverse wrap is priority if both set" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + t.modes.set(.reverse_wrap_extended, true); + + for ("ABCDE") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + try t.print('1'); + t.cursorLeft(1 + t.cols + 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n1\n X", str); + } +} + +test "Terminal: cursorLeft extended reverse wrap above top scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap_extended, true); + + t.setTopAndBottomMargin(3, 0); + t.setCursorPos(2, 1); + t.cursorLeft(1000); + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); +} + +test "Terminal: cursorLeft reverse wrap on first row" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + t.modes.set(.reverse_wrap, true); + + t.setTopAndBottomMargin(3, 0); + t.setCursorPos(1, 2); + t.cursorLeft(1000); + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); +} + +test "Terminal: cursorDown basic" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.cursorDown(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\n\n X", str); + } +} + +test "Terminal: cursorDown above bottom scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.cursorDown(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n X", str); + } +} + +test "Terminal: cursorDown below bottom scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setTopAndBottomMargin(1, 3); + try t.print('A'); + t.setCursorPos(4, 1); + t.cursorDown(10); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n\n\n\nX", str); + } +} + +test "Terminal: cursorDown resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.cursorDown(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE\n X", str); + } +} + +test "Terminal: cursorRight resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.cursorRight(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: cursorRight to the edge of screen" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: cursorRight left of right margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.scrolling_region.right = 2; + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: cursorRight right of right margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.scrolling_region.right = 2; + t.setCursorPos(1, 4); + t.cursorRight(100); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: deleteLines simple" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + + t.clearDirty(); + t.deleteLines(1); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } +} + +test "Terminal: deleteLines colors with bg color" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI"); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nGHI", str); + } + + for (0..t.cols) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 4, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: 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 }); + defer t.deinit(alloc); + + const first_page = t.screens.active.pages.pages.first.?; + const first_page_nrows = first_page.data.capacity.rows; + + // Fill up the first page minus 3 rows + for (0..first_page_nrows - 3) |_| try t.linefeed(); + + // Add content that will cross a page boundary + try t.printString("1AAAA"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("2BBBB"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("3CCCC"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("4DDDD"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("5EEEE"); + + // Verify we now have a second page + try testing.expect(first_page.next != null); + + t.setCursorPos(1, 1); + t.clearDirty(); + t.deleteLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 4 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("2BBBB\n3CCCC\n4DDDD\n5EEEE", str); + } +} + +test "Terminal: deleteLines (legacy)" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.cursorUp(2); + t.deleteLines(1); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nE\nD", str); + } +} + +test "Terminal: deleteLines with scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(1, 1); + + t.clearDirty(); + t.deleteLines(1); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("E\nC\n\nD", str); + } +} + +test "Terminal: deleteLines with scroll region, large count" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(1, 1); + + t.clearDirty(); + t.deleteLines(5); + + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 3 } })); + + try t.print('E'); + t.carriageReturn(); + try t.linefeed(); + + // We should be + // try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + // try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.y); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("E\n\n\nD", str); + } +} + +test "Terminal: deleteLines with scroll region, cursor outside of region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 80, .rows = 80 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + t.carriageReturn(); + try t.linefeed(); + try t.print('C'); + t.carriageReturn(); + try t.linefeed(); + try t.print('D'); + + t.setTopAndBottomMargin(1, 3); + t.setCursorPos(4, 1); + + t.clearDirty(); + t.deleteLines(1); + + for (0..4) |y| try testing.expect(!t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\nB\nC\nD", str); + } +} + +test "Terminal: deleteLines resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.deleteLines(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B", str); + } +} + +test "Terminal: deleteLines resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 3 }); + defer t.deinit(alloc); + + try t.print('1'); + t.carriageReturn(); + try t.linefeed(); + for ("ABCDEF") |c| try t.print(c); + + t.setTopAndBottomMargin(1, 2); + t.setCursorPos(1, 1); + t.deleteLines(1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XBC\n\nDEF", str); + } + + for (0..t.rows) |y| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } +} + +test "Terminal: deleteLines left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + + t.clearDirty(); + t.deleteLines(1); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + for (1..3) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC123\nDHI756\nG 89", str); + } +} + +test "Terminal: deleteLines left/right scroll region from top" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(1, 2); + + t.clearDirty(); + t.deleteLines(1); + + for (0..3) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AEF423\nDHI756\nG 89", str); + } +} + +test "Terminal: deleteLines left/right scroll region high count" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEF456"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHI789"); + t.scrolling_region.left = 1; + t.scrolling_region.right = 3; + t.setCursorPos(2, 2); + + t.clearDirty(); + t.deleteLines(100); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + for (1..3) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC123\nD 56\nG 89", str); + } +} + +test "Terminal: deleteLines wide character spacer head" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + // Initial value + // +-----+ + // |AAAAA| < Wrapped + // |BBBB*| < Wrapped (continued) + // |WWCCC| < Non-wrapped (continued) + // +-----+ + // where * represents a spacer head cell + // and WW is the wide character. + try t.printString("AAAAABBBB\u{1F600}CCC"); + + // Delete the top line + // +-----+ + // |BBBB | < Non-wrapped + // |WWCCC| < Non-wrapped + // | | < Non-wrapped + // +-----+ + // This should convert the spacer head to + // a regular empty cell, and un-set wrap. + t.setCursorPos(1, 1); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + const unwrapped_str = try t.plainStringUnwrapped(testing.allocator); + defer testing.allocator.free(unwrapped_str); + try testing.expectEqualStrings("BBBB\n\u{1F600}CCC", str); + try testing.expectEqualStrings("BBBB\n\u{1F600}CCC", unwrapped_str); + } +} + +test "Terminal: deleteLines wide character spacer head left scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + // Initial value + // +-----+ + // |AAAAA| < Wrapped + // |BBBB*| < Wrapped (continued) + // |WWCCC| < Non-wrapped (continued) + // +-----+ + // where * represents a spacer head cell + // and WW is the wide character. + try t.printString("AAAAABBBB\u{1F600}CCC"); + + t.scrolling_region.left = 2; + + // Delete the top line + // ### <- scrolling region + // +-----+ + // |AABB | < Wrapped + // |BBCCC| < Wrapped (continued) + // |WW | < Non-wrapped (continued) + // +-----+ + // This should convert the spacer head to + // a regular empty cell, but due to the + // left scrolling margin, wrap state should + // remain. + t.setCursorPos(1, 3); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + const unwrapped_str = try t.plainStringUnwrapped(testing.allocator); + defer testing.allocator.free(unwrapped_str); + try testing.expectEqualStrings("AABB\nBBCCC\n\u{1F600}", str); + try testing.expectEqualStrings("AABB BBCCC\u{1F600}", unwrapped_str); + } +} + +test "Terminal: deleteLines wide character spacer head right scroll margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 3 }); + defer t.deinit(alloc); + + // Initial value + // +-----+ + // |AAAAA| < Wrapped + // |BBBB*| < Wrapped (continued) + // |WWCCC| < Non-wrapped (continued) + // +-----+ + // where * represents a spacer head cell + // and WW is the wide character. + try t.printString("AAAAABBBB\u{1F600}CCC"); + + t.scrolling_region.right = 3; + + // Delete the top line + // #### <- scrolling region + // +-----+ + // |BBBBA| < Wrapped + // |WWCC | < Wrapped (continued) + // | C| < Non-wrapped (continued) + // +-----+ + // This should convert the spacer head to + // a regular empty cell, but due to the + // right scrolling margin, wrap state should + // remain. + t.setCursorPos(1, 1); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + const unwrapped_str = try t.plainStringUnwrapped(testing.allocator); + defer testing.allocator.free(unwrapped_str); + try testing.expectEqualStrings("BBBBA\n\u{1F600}CC\n C", str); + try testing.expectEqualStrings("BBBBA\u{1F600}CC C", unwrapped_str); + } +} + +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 }); + defer t.deinit(alloc); + + // Initial value + // +-----+ + // |AAAAA| < Wrapped + // |BBBB*| < Wrapped (continued) + // |WWCCC| < Non-wrapped (continued) + // +-----+ + // where * represents a spacer head cell + // and WW is the wide character. + try t.printString("AAAAABBBB\u{1F600}CCC"); + + t.scrolling_region.right = 3; + t.scrolling_region.left = 2; + + // Delete the top line + // ## <- scrolling region + // +-----+ + // |AABBA| < Wrapped + // |BBCC*| < Wrapped (continued) + // |WW C| < Non-wrapped (continued) + // +-----+ + // Because there is both a left scrolling + // margin > 1 and a right scrolling margin + // the spacer head should remain, and the + // wrap state should be untouched. + t.setCursorPos(1, 3); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + const unwrapped_str = try t.plainStringUnwrapped(testing.allocator); + defer testing.allocator.free(unwrapped_str); + try testing.expectEqualStrings("AABBA\nBBCC\n\u{1F600} C", str); + try testing.expectEqualStrings("AABBABBCC\u{1F600} C", unwrapped_str); + } +} + +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 }); + defer t.deinit(alloc); + + // Initial value + // +-----+ + // |AAAAA| < Wrapped + // |BBBB*| < Wrapped (continued) + // |WWCCC| < Non-wrapped (continued) + // +-----+ + // where * represents a spacer head cell + // and WW is the wide character. + try t.printString("AAAAABBBB\u{1F600}CCC"); + + t.scrolling_region.right = 3; + t.scrolling_region.left = 1; + + // Delete the top line + // ### <- scrolling region + // +-----+ + // |ABBBA| < Wrapped + // |B CC | < Wrapped (continued) + // | C| < Non-wrapped (continued) + // +-----+ + // Because the left margin is 1, the wide + // char is split, and therefore removed, + // along with the spacer head - however, + // wrap state should be untouched. + t.setCursorPos(1, 2); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + const unwrapped_str = try t.plainStringUnwrapped(testing.allocator); + defer testing.allocator.free(unwrapped_str); + try testing.expectEqualStrings("ABBBA\nB CC\n C", str); + try testing.expectEqualStrings("ABBBAB CC C", unwrapped_str); + } +} + +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 }); + defer t.deinit(alloc); + + // Initial value + // +-----+ + // |AAAAA| + // |WWBWW| + // +-----+ + // where WW represents a wide character + try t.printString("AAAAA\n\u{1F600}B\u{1F600}"); + + t.scrolling_region.right = 3; + t.scrolling_region.left = 1; + + // Delete the top line + // ### <- scrolling region + // +-----+ + // |A B A| + // | | + // +-----+ + // The two wide chars, because they're + // split by the edge of the scrolling + // region, get removed. + t.setCursorPos(1, 2); + t.deleteLines(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A B A", str); + } +} + +test "Terminal: deleteLines zero" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 5 }); + defer t.deinit(alloc); + + // This should do nothing + t.setCursorPos(1, 1); + t.deleteLines(0); +} + +test "Terminal: default style is empty" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + + { + 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(style.Id, 0), cell.style_id); + } +} + +test "Terminal: bold style" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + + { + 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.expect(cell.style_id != 0); + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 1); + } +} + +test "Terminal: garbage collect overwritten" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + t.setCursorPos(1, 1); + try t.setAttribute(.{ .unset = {} }); + try t.print('B'); + + { + 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.expect(cell.style_id == 0); + } + + // verify we have no styles in our style map + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 0), page.styles.count()); +} + +test "Terminal: do not garbage collect old styles in use" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.setAttribute(.{ .unset = {} }); + try t.print('B'); + + { + 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.expect(cell.style_id == 0); + } + + // verify we have no styles in our style map + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); +} + +test "Terminal: print with style marks the row as styled" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + try t.print('A'); + try t.setAttribute(.{ .unset = {} }); + try t.print('B'); + + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.row.styled); + } +} + +test "Terminal: DECALN" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 2 }); + defer t.deinit(alloc); + + // Initial value + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + try t.print('B'); + try t.decaln(); + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + + for (0..t.rows) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("EE\nEE", str); + } +} + +test "Terminal: decaln reset margins" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + // Initial value + t.modes.set(.origin, true); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nEEE\nEEE", str); + } +} + +test "Terminal: decaln preserves color" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + // Initial value + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } }); + t.modes.set(.origin, true); + t.setTopAndBottomMargin(2, 3); + try t.decaln(); + t.scrollDown(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\nEEE\nEEE", str); + } + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: DECALN resets graphemes with protected mode" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + // Add protected mode. A previous version of DECALN accidentally preserved + // protected mode which left dangling managed memory. + t.setProtectedMode(.iso); + + // This is: π¨βπ©βπ§ (which may or may not render correctly) + t.modes.set(.grapheme_cluster, true); + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + try t.decaln(); + + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expect(t.screens.active.cursor.protected); + try testing.expect(t.screens.active.protected_mode == .iso); + + for (0..t.rows) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("EEE\nEEE\nEEE", str); + } +} + +test "Terminal: insertBlanks zero" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + + t.insertBlanks(0); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +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 }); + defer t.deinit(alloc); + + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + + t.clearDirty(); + t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABC", str); + } +} + +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 }); + defer t.deinit(alloc); + + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + + t.clearDirty(); + t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); + } +} + +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 }); + defer t.deinit(alloc); + + try t.print('A'); + try t.print('B'); + try t.print('C'); + t.setCursorPos(1, 1); + + t.clearDirty(); + t.insertBlanks(5); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: insertBlanks no scroll region, fits" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + + t.clearDirty(); + t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABC", str); + } +} + +test "Terminal: insertBlanks preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.insertBlanks(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABC", str); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: insertBlanks shift off screen" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + defer t.deinit(alloc); + + for (" ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + t.clearDirty(); + t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X A", str); + } +} + +test "Terminal: insertBlanks split multi-cell character" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + defer t.deinit(alloc); + + for ("123") |c| try t.print(c); + try t.print('ζ©'); + t.setCursorPos(1, 1); + t.clearDirty(); + t.insertBlanks(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 123", str); + } +} + +test "Terminal: insertBlanks inside left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.setCursorPos(1, 3); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 3); + + t.clearDirty(); + t.insertBlanks(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X A", str); + } +} + +test "Terminal: insertBlanks outside left/right scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + defer t.deinit(alloc); + + t.setCursorPos(1, 4); + for ("ABC") |c| try t.print(c); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + try testing.expect(t.screens.active.cursor.pending_wrap); + t.clearDirty(); + t.insertBlanks(2); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABX", str); + } +} + +test "Terminal: insertBlanks left/right scroll region large count" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + t.modes.set(.origin, true); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setCursorPos(1, 1); + t.clearDirty(); + t.insertBlanks(140); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: insertBlanks deleting graphemes" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Disable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.printString("ABC"); + + // This is: π¨βπ©βπ§ (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have one cell with graphemes + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + t.setCursorPos(1, 1); + t.clearDirty(); + t.insertBlanks(4); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A", str); + } + + // We should have no graphemes + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); +} + +test "Terminal: insertBlanks shift graphemes" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Enable grapheme clustering + t.modes.set(.grapheme_cluster, true); + + try t.printString("A"); + + // This is: π¨βπ©βπ§ (which may or may not render correctly) + try t.print(0x1F468); + try t.print(0x200D); + try t.print(0x1F469); + try t.print(0x200D); + try t.print(0x1F467); + + // We should have one cell with graphemes + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); + + t.setCursorPos(1, 1); + t.clearDirty(); + t.insertBlanks(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" Aπ¨βπ©βπ§", str); + } + + // We should have no graphemes + try testing.expectEqual(@as(usize, 1), page.graphemeCount()); +} + +test "Terminal: insertBlanks split multi-cell character from tail" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ζ©123"); + t.setCursorPos(1, 2); + t.insertBlanks(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 12", str); + } +} + +test "Terminal: insertBlanks shifts hyperlinks" { + // osc "8;;http://example.com" + // printf "link" + // printf "\r" + // csi "3@" + // echo + // + // link should be preserved, blanks should not be linked + + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.setCursorPos(1, 1); + t.insertBlanks(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" ABC", str); + } + + // Verify all our cells have a hyperlink + for (2..5) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(row.hyperlink); + const cell = list_cell.cell; + try testing.expect(cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell).?; + try testing.expectEqual(@as(hyperlink.Id, 1), id); + } + for (0..2) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: insertBlanks pushes hyperlink off end completely" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + defer t.deinit(alloc); + + try t.screens.active.startHyperlink("http://example.com", null); + try t.printString("ABC"); + t.setCursorPos(1, 1); + t.insertBlanks(3); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + for (0..3) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ + .x = @intCast(x), + .y = 0, + } }).?; + const row = list_cell.row; + try testing.expect(!row.hyperlink); + const cell = list_cell.cell; + try testing.expect(!cell.hyperlink); + const id = list_cell.node.data.lookupHyperlink(cell); + try testing.expect(id == null); + } +} + +test "Terminal: insertBlanks wide char straddling right margin" { + // Crash found by AFL++ fuzzer. + // + // When a wide character straddles the right scroll margin (head at the + // margin, spacer_tail just beyond it), insertBlanks shifts the wide head + // 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 }); + defer t.deinit(alloc); + + // Fill row: A B C D ζ© _ _ _ _ _ + // Positions: 0 1 2 3 4W 5T 6 7 8 9 + t.setCursorPos(1, 1); + for ("ABCD") |c| try t.print(c); + try t.print('ζ©'); // wide char: head at 4, spacer_tail at 5 + + // Set right margin so the wide head is AT the boundary and the + // spacer_tail is just outside it. + t.scrolling_region.right = 4; + + // Position cursor at x=2 (1-indexed col 3) and insert one blank. + // This triggers the swap loop which displaces the wide head at + // position 4 without clearing the spacer_tail at position 5. + t.setCursorPos(1, 3); + t.insertBlanks(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB CD", str); + } +} + +test "Terminal: insertBlanks wide char spacer_tail orphaned beyond right margin" { + // Regression test for AFL++ crash. + // + // When insertBlanks clears the entire region from cursor to the right + // margin (scroll_amount == 0), a wide character whose head is AT the + // right margin gets cleared but its spacer_tail just beyond the 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 }); + defer t.deinit(alloc); + + // Fill cols 0β9 with wide chars: δΈδΈδΈδΈδΈ + // Positions: 0W 1T 2W 3T 4W 5T 6W 7T 8W 9T + for (0..5) |_| try t.print(0x4E2D); + + // Set left/right margins so that the last wide char (cols 8β9) + // straddles the boundary: head at col 8 (inside), tail at col 9 (outside). + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(1, 9); // 1-indexed: left=0, right=8 + + // Cursor is now at (0, 0) after DECSLRM. Print a narrow char to + // advance cursor to col 1. + try t.print('a'); + + // ICH 8: insert 8 blanks at cursor x=1. + // rem = right(8) - x(1) + 1 = 8, adjusted_count = 8, scroll_amount = 0. + // The code clears cols 1β8 without noticing the spacer_tail at col 9. + t.insertBlanks(8); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("a", str); + } +} + +test "Terminal: insert mode with space" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 2 }); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hXello", str); + } +} + +test "Terminal: insert mode doesn't wrap pushed characters" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hXell", str); + } +} + +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 }); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + t.modes.set(.insert, true); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hello\nX", str); + } +} + +test "Terminal: insert mode with wide characters" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + for ("hello") |c| try t.print(c); + t.setCursorPos(1, 2); + t.modes.set(.insert, true); + try t.print('π'); // 0x1F600 + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("hπel", str); + } +} + +test "Terminal: insert mode with wide characters at end" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + for ("well") |c| try t.print(c); + t.modes.set(.insert, true); + try t.print('π'); // 0x1F600 + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("well\nπ", str); + } +} + +test "Terminal: insert mode pushing off wide character" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 5, .rows = 2 }); + defer t.deinit(alloc); + + for ("123") |c| try t.print(c); + try t.print('π'); // 0x1F600 + t.modes.set(.insert, true); + t.setCursorPos(1, 1); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X123", str); + } +} + +test "Terminal: deleteChars" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.clearDirty(); + t.deleteChars(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ADE", str); + } +} + +test "Terminal: deleteChars zero count" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.clearDirty(); + t.deleteChars(0); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDE", str); + } +} + +test "Terminal: deleteChars more than half" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.clearDirty(); + t.deleteChars(3); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AE", str); + } +} + +test "Terminal: deleteChars more than line width" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.clearDirty(); + t.deleteChars(10); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } +} + +test "Terminal: deleteChars should shift left" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + + t.clearDirty(); + t.deleteChars(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ACDE", str); + } +} + +test "Terminal: deleteChars resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.deleteChars(1); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDX", str); + } +} + +test "Terminal: deleteChars resets wrap" { + const alloc = testing.allocator; + var t = try init(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 } }).?; + const row = list_cell.row; + try testing.expect(row.wrap); + } + t.setCursorPos(1, 1); + t.deleteChars(1); + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + const row = list_cell.row; + try testing.expect(!row.wrap); + } + + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("XCDE\n123", str); + } +} + +test "Terminal: deleteChars simple operation" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.setCursorPos(1, 3); + + t.clearDirty(); + t.deleteChars(2); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB23", str); + } +} + +test "Terminal: deleteChars preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 10 }); + defer t.deinit(alloc); + + for ("ABC123") |c| try t.print(c); + t.setCursorPos(1, 3); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.deleteChars(2); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB23", str); + } + for (t.cols - 2..t.cols) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 0, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: deleteChars outside scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + try testing.expect(t.screens.active.cursor.pending_wrap); + t.clearDirty(); + t.deleteChars(2); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.screens.active.cursor.pending_wrap); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC123", str); + } +} + +test "Terminal: deleteChars inside scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ABC123"); + t.scrolling_region.left = 2; + t.scrolling_region.right = 4; + t.setCursorPos(1, 4); + + t.clearDirty(); + t.deleteChars(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC2 3", str); + } +} + +test "Terminal: deleteChars split wide character from spacer tail" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("Aζ©123"); + t.setCursorPos(1, 3); + t.deleteChars(1); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A 123", str); + } +} + +test "Terminal: deleteChars split wide character from wide" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 6, .rows = 10 }); + defer t.deinit(alloc); + + try t.printString("ζ©123"); + t.setCursorPos(1, 1); + t.deleteChars(1); + + { + 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(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(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 }); + defer t.deinit(alloc); + + try t.printString("Aζ©123"); + t.setCursorPos(1, 1); + t.deleteChars(1); + + { + 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(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(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 }); + defer t.deinit(alloc); + + try t.printString("0123ζ©123"); + { + const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; + const row = list_cell.row; + const cell = list_cell.cell; + try testing.expectEqual(Cell.Wide.spacer_head, cell.wide); + try testing.expect(row.wrap); + } + + t.setCursorPos(1, 1); + t.deleteChars(1); + + { + 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(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 }); + defer t.deinit(alloc); + + t.setCursorPos(1, t.cols - 1); + try t.print(0x6A4B); // ζ© + t.carriageReturn(); + t.deleteChars(t.cols - 1); + try t.print('0'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("0", str); + } +} + +test "Terminal: deleteChars wide char boundary conditions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 1, .cols = 8 }); + defer t.deinit(alloc); + + // EXPLANATION(qwerasd): + // + // There are 3 or 4 boundaries to be concerned with in deleteChars, + // depending on how you count them. Consider the following terminal: + // + // +--------+ + // 0 |.ABCDEF.| + // : ^ : (^ = cursor) + // +--------+ + // + // if we DCH 3 we get + // + // +--------+ + // 0 |.DEF....| + // +--------+ + // + // The boundaries exist at the following points then: + // + // +--------+ + // 0 |.ABCDEF.| + // :11 22 33: + // +--------+ + // + // I'm counting 2 for double since it's both the end of the deleted + // content and the start of the content that is shifted in to place. + // + // Now consider wide characters (represented as `WW`) at these boundaries: + // + // +--------+ + // 0 |WWaWWbWW| + // : ^ : (^ = cursor) + // : ^^^ : (^ = deleted by DCH 3) + // +--------+ + // + // -> DCH 3 + // -> The first 2 wide characters are split & destroyed (verified in xterm) + // + // +--------+ + // 0 |..bWW...| + // +--------+ + + try t.printString("πaπbπ"); + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings("πaπbπ", str); + } + + t.setCursorPos(1, 2); + t.deleteChars(3); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" bπ", str); + } +} + +test "Terminal: deleteChars wide char wrap boundary conditions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 8 }); + defer t.deinit(alloc); + + // EXPLANATION(qwerasd): + // (cont. from "Terminal: deleteChars wide char boundary conditions") + // + // Additionally consider soft-wrapped wide chars (`H` = spacer head): + // + // +--------+ + // 0 |.......Hβ¦ + // 1 β¦WWabcdeHβ¦ + // : ^ : (^ = cursor) + // : ^^^ : (^ = deleted by DCH 3) + // 2 β¦WW......| + // +--------+ + // + // -> DCH 3 + // -> First wide character split and destroyed, including spacer head, + // second spacer head removed (verified in xterm). + // -> Wrap state of row reset + // + // +--------+ + // 0 |........| + // 1 |.cde....| + // 2 |WW......| + // +--------+ + // + + try t.printString(".......πabcdeπ......"); + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings(".......\nπabcde\nπ......", str); + + const unwrapped = try t.plainStringUnwrapped(alloc); + defer testing.allocator.free(unwrapped); + try testing.expectEqualStrings(".......πabcdeπ......", unwrapped); + } + + t.setCursorPos(2, 2); + t.deleteChars(3); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings(".......\n cde\nπ......", str); + + const unwrapped = try t.plainStringUnwrapped(alloc); + defer testing.allocator.free(unwrapped); + try testing.expectEqualStrings("....... cde\nπ......", unwrapped); + } +} + +test "Terminal: deleteChars wide char across right margin" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 3, .cols = 8 }); + defer t.deinit(alloc); + + // scroll region + // VVVVVV + // +-######-+ + // |.abcdeWW| + // : ^ : (^ = cursor) + // +--------+ + // + // DCH 1 + + try t.printString("123456ζ©"); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(2, 7); + + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings("123456ζ©", str); + } + + t.setCursorPos(1, 2); + t.deleteChars(1); + t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + + // NOTE: This behavior is slightly inconsistent with xterm. xterm + // _visually_ splits the wide character (half the wide character shows + // up in col 6 and half in col 8). In all other wide char split scenarios, + // xterm clears the cell. Therefore, we've chosen to clear the cell here. + // Given we have space, we also could actually preserve it, but I haven't + // yet found a terminal that behaves that way. We should be open to + // revisiting this behavior but for now we're going with the simpler + // impl. + { + const str = try t.plainString(alloc); + defer testing.allocator.free(str); + try testing.expectEqualStrings("13456", str); + } +} + +test "Terminal: saveCursor" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + try t.setAttribute(.{ .bold = {} }); + t.screens.active.charset.gr = .G3; + t.modes.set(.origin, true); + t.saveCursor(); + t.screens.active.charset.gr = .G0; + try t.setAttribute(.{ .unset = {} }); + t.modes.set(.origin, false); + t.restoreCursor(); + try testing.expect(t.screens.active.cursor.style.flags.bold); + try testing.expect(t.screens.active.charset.gr == .G3); + try testing.expect(t.modes.get(.origin)); +} + +test "Terminal: saveCursor position" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); + t.setCursorPos(1, 1); + try t.print('B'); + t.restoreCursor(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B AX", str); + } +} + +test "Terminal: saveCursor pending wrap state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(1, 5); + try t.print('A'); + t.saveCursor(); + t.setCursorPos(1, 1); + try t.print('B'); + t.restoreCursor(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("B A\nX", str); + } +} + +test "Terminal: saveCursor origin mode" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.modes.set(.origin, true); + t.saveCursor(); + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(3, 5); + t.setTopAndBottomMargin(2, 4); + t.restoreCursor(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X", str); + } +} + +test "Terminal: saveCursor resize" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.setCursorPos(1, 10); + t.saveCursor(); + try t.resize(alloc, 5, 5); + t.restoreCursor(); + try t.print('X'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: saveCursor protected pen" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + try testing.expect(t.screens.active.cursor.protected); + t.setCursorPos(1, 10); + t.saveCursor(); + t.setProtectedMode(.off); + try testing.expect(!t.screens.active.cursor.protected); + t.restoreCursor(); + try testing.expect(t.screens.active.cursor.protected); +} + +test "Terminal: saveCursor doesn't modify hyperlink state" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + try t.screens.active.startHyperlink("http://example.com", null); + const id = t.screens.active.cursor.hyperlink_id; + t.saveCursor(); + try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id); + t.restoreCursor(); + try testing.expectEqual(id, t.screens.active.cursor.hyperlink_id); +} + +test "Terminal: restoreCursor uses default style on OutOfSpace" { + // Tests that restoreCursor falls back to default style when + // manualStyleUpdate fails with OutOfSpace (can't split a 1-row page + // and styles are at max capacity). + const alloc = testing.allocator; + + // Use a single row so the page can't be split + var t = try init(alloc, .{ .cols = 10, .rows = 1 }); + defer t.deinit(alloc); + + // Set a style and save the cursor + try t.setAttribute(.{ .bold = {} }); + t.saveCursor(); + + // Clear the style + try t.setAttribute(.{ .unset = {} }); + try testing.expect(!t.screens.active.cursor.style.flags.bold); + + // Fill the style map to max capacity + const max_styles = std.math.maxInt(size.CellCountInt); + while (t.screens.active.cursor.page_pin.node.data.capacity.styles < max_styles) { + _ = t.screens.active.increaseCapacity( + t.screens.active.cursor.page_pin.node, + .styles, + ) catch break; + } + + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(max_styles, page.capacity.styles); + + // Fill all style slots using the StyleSet's layout capacity which accounts + // for the load factor. The capacity in the layout is the actual max number + // of items that can be stored. + { + page.pauseIntegrityChecks(true); + defer page.pauseIntegrityChecks(false); + defer page.assertIntegrity(); + + const max_items = page.styles.layout.cap; + var n: usize = 1; + while (n < max_items) : (n += 1) { + _ = page.styles.add( + page.memory, + .{ .bg_color = .{ .rgb = @bitCast(@as(u24, @intCast(n))) } }, + ) catch break; + } + } + + // Restore cursor - should fall back to default style since page + // can't be split (1 row) and styles are at max capacity + t.restoreCursor(); + + // The style should be reset to default because OutOfSpace occurred + try testing.expect(!t.screens.active.cursor.style.flags.bold); + try testing.expectEqual(style.default_id, t.screens.active.cursor.style_id); +} + +test "Terminal: setProtectedMode" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 3 }); + defer t.deinit(alloc); + + try testing.expect(!t.screens.active.cursor.protected); + t.setProtectedMode(.off); + try testing.expect(!t.screens.active.cursor.protected); + t.setProtectedMode(.iso); + try testing.expect(t.screens.active.cursor.protected); + t.setProtectedMode(.dec); + try testing.expect(t.screens.active.cursor.protected); + t.setProtectedMode(.off); + try testing.expect(!t.screens.active.cursor.protected); +} + +test "Terminal: eraseLine simple erase right" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.clearDirty(); + t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB", str); + } +} + +test "Terminal: eraseLine resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.eraseLine(.right, false); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABCDB", str); + } +} + +test "Terminal: eraseLine resets wrap" { + const alloc = testing.allocator; + var t = try init(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(.right, 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'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("X\n123", str); + } +} + +test "Terminal: eraseLine right preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.right, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + for (1..5) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 0, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: eraseLine right wide character" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + for ("AB") |c| try t.print(c); + try t.print('ζ©'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 4); + t.clearDirty(); + t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AB", str); + } +} + +test "Terminal: eraseLine right protected attributes respected with iso" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.clearDirty(); + t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +test "Terminal: eraseLine right protected attributes ignored with dec most recent" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.clearDirty(); + t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } +} + +test "Terminal: eraseLine right protected attributes ignored with dec set" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.clearDirty(); + t.eraseLine(.right, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A", str); + } +} + +test "Terminal: eraseLine right protected requested" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + for ("12345678") |c| try t.print(c); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screens.active.cursor.y + 1, 4); + t.clearDirty(); + t.eraseLine(.right, true); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("123 X", str); + } +} + +test "Terminal: eraseLine simple erase left" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.clearDirty(); + t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" DE", str); + } +} + +test "Terminal: eraseLine left resets wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + t.clearDirty(); + t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(!t.screens.active.cursor.pending_wrap); + try t.print('B'); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" B", str); + } +} + +test "Terminal: eraseLine left preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.left, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" CDE", str); + for (0..2) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 0, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: eraseLine left wide character" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + for ("AB") |c| try t.print(c); + try t.print('ζ©'); + for ("DE") |c| try t.print(c); + t.setCursorPos(1, 3); + t.clearDirty(); + t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" DE", str); + } +} + +test "Terminal: eraseLine left protected attributes respected with iso" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.clearDirty(); + t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +test "Terminal: eraseLine left protected attributes ignored with dec most recent" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.clearDirty(); + t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + +test "Terminal: eraseLine left protected attributes ignored with dec set" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.clearDirty(); + t.eraseLine(.left, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" C", str); + } +} + +test "Terminal: eraseLine left protected requested" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screens.active.cursor.y + 1, 8); + t.clearDirty(); + t.eraseLine(.left, true); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X 9", str); + } +} + +test "Terminal: eraseLine complete preserves background sgr" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + t.setCursorPos(1, 2); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseLine(.complete, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + for (0..5) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 0, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: eraseLine complete protected attributes respected with iso" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 1); + t.clearDirty(); + t.eraseLine(.complete, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC", str); + } +} + +test "Terminal: eraseLine complete protected attributes ignored with dec most recent" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(1, 2); + t.clearDirty(); + t.eraseLine(.complete, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: eraseLine complete protected attributes ignored with dec set" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.setCursorPos(1, 2); + t.clearDirty(); + t.eraseLine(.complete, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: eraseLine complete protected requested" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screens.active.cursor.y + 1, 8); + t.clearDirty(); + t.eraseLine(.complete, true); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" X", str); + } +} + +test "Terminal: tabClear single" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 30, .rows = 5 }); + defer t.deinit(alloc); + + t.horizontalTab(); + t.tabClear(.current); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + t.setCursorPos(1, 1); + t.horizontalTab(); + try testing.expectEqual(@as(usize, 16), t.screens.active.cursor.x); +} + +test "Terminal: tabClear all" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 30, .rows = 5 }); + defer t.deinit(alloc); + + t.tabClear(.all); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + t.setCursorPos(1, 1); + t.horizontalTab(); + try testing.expectEqual(@as(usize, 29), t.screens.active.cursor.x); +} + +test "Terminal: printRepeat simple" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("A"); + try t.printRepeat(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("AA", str); + } +} + +test "Terminal: printRepeat wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString(" A"); + try t.printRepeat(1); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" A\nA", str); + } +} + +test "Terminal: printRepeat no previous character" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printRepeat(1); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: printAttributes" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + var storage: [64]u8 = undefined; + + { + try t.setAttribute(.{ .direct_color_fg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;38:2::1:2:3", buf); + } + + { + try t.setAttribute(.bold); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 1, .g = 2, .b = 3 } }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;1;48:2::1:2:3", buf); + } + + { + try t.setAttribute(.bold); + try t.setAttribute(.faint); + try t.setAttribute(.italic); + try t.setAttribute(.{ .underline = .single }); + try t.setAttribute(.blink); + try t.setAttribute(.inverse); + try t.setAttribute(.invisible); + try t.setAttribute(.strikethrough); + 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 t.setAttribute(.{ .underline = .single }); + defer t.setAttribute(.unset) catch unreachable; + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0;4", buf); + } + + { + const buf = try t.printAttributes(&storage); + try testing.expectEqualStrings("0", buf); + } +} + +test "Terminal: eraseDisplay simple erase below" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + t.clearDirty(); + t.eraseDisplay(.below, false); + + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + } +} + +test "Terminal: eraseDisplay erase below preserves SGR bg" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + for (1..5) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 1, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: eraseDisplay below split multi-cell" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABζ©C"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEζ©F"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHζ©I"); + t.setCursorPos(2, 4); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABζ©C\nDE", str); + } +} + +test "Terminal: eraseDisplay below protected attributes respected with iso" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: eraseDisplay below protected attributes ignored with dec most recent" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + } +} + +test "Terminal: eraseDisplay below protected attributes ignored with dec set" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nD", str); + } +} + +test "Terminal: eraseDisplay below protected attributes respected with force" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.below, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: eraseDisplay simple erase above" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + t.clearDirty(); + t.eraseDisplay(.above, false); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } })); + try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } })); + try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + } +} + +test "Terminal: eraseDisplay erase above preserves SGR bg" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + for (0..2) |x| { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 1, + } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } + } +} + +test "Terminal: eraseDisplay above split multi-cell" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.printString("ABζ©C"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("DEζ©F"); + t.carriageReturn(); + try t.linefeed(); + try t.printString("GHζ©I"); + t.setCursorPos(2, 3); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHζ©I", str); + } +} + +test "Terminal: eraseDisplay above protected attributes respected with iso" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: eraseDisplay above protected attributes ignored with dec most recent" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.iso); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setProtectedMode(.dec); + t.setProtectedMode(.off); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + } +} + +test "Terminal: eraseDisplay above protected attributes ignored with dec set" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n F\nGHI", str); + } +} + +test "Terminal: eraseDisplay above protected attributes respected with force" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.setProtectedMode(.dec); + for ("ABC") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("DEF") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("GHI") |c| try t.print(c); + t.setCursorPos(2, 2); + t.eraseDisplay(.above, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("ABC\nDEF\nGHI", str); + } +} + +test "Terminal: eraseDisplay protected complete" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screens.active.cursor.y + 1, 4); + + t.clearDirty(); + t.eraseDisplay(.complete, true); + for (0..t.rows) |y| try testing.expect(t.isDirty(.{ .active = .{ + .x = 0, + .y = @intCast(y), + } })); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n X", str); + } +} + +test "Terminal: eraseDisplay protected below" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screens.active.cursor.y + 1, 4); + t.eraseDisplay(.below, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("A\n123 X", str); + } +} + +test "Terminal: eraseDisplay scroll complete" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + t.eraseDisplay(.scroll_complete, false); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: eraseDisplay protected above" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + try t.print('A'); + t.carriageReturn(); + try t.linefeed(); + for ("123456789") |c| try t.print(c); + t.setCursorPos(t.screens.active.cursor.y + 1, 6); + t.setProtectedMode(.dec); + try t.print('X'); + t.setCursorPos(t.screens.active.cursor.y + 1, 8); + t.eraseDisplay(.above, true); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("\n X 9", str); + } +} + +test "Terminal: eraseDisplay complete preserves cursor" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Set our cursur + try t.setAttribute(.{ .bold = {} }); + try t.printString("AAAA"); + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + + // Erasing the display may detect that our style is no longer in use + // and prune our style, which we don't want because its still our + // active cursor. + t.eraseDisplay(.complete, false); + try testing.expect(t.screens.active.cursor.style_id != style.default_id); +} + +test "Terminal: semantic prompt" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Prompt + try t.semanticPrompt(.init(.fresh_line_new_prompt)); + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt, row.semantic_prompt); + } + + // Start input but end it on EOL + try t.semanticPrompt(.init(.end_prompt_start_input_terminate_eol)); + t.carriageReturn(); + try t.linefeed(); + + // Write some output + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + for ("world") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.output, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.none, row.semantic_prompt); + } +} + +test "Terminal: semantic prompt continuations" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Prompt + try t.semanticPrompt(.init(.fresh_line_new_prompt)); + for ("hello") |c| try t.print(c); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt, row.semantic_prompt); + } + + // Start input but end it on EOL + t.carriageReturn(); + try t.linefeed(); + try t.semanticPrompt(.{ + .action = .prompt_start, + .options_unvalidated = "k=c", + }); + + // Write some output + try testing.expectEqual(@as(usize, 1), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + for ("world") |c| try t.print(c); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x - 1, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(.prompt, cell.semantic_content); + + const row = list_cell.row; + try testing.expectEqual(.prompt_continuation, row.semantic_prompt); + } +} + +test "Terminal: index in prompt mode marks new row as prompt continuation" { + // This tests the Fish shell workaround: when in prompt mode and we get + // 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 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("hello") |c| try t.print(c); + + // Verify first row is marked as prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 0, + } }).?; + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); + } + + // Now do a linefeed while still in prompt mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should automatically be marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // The cursor semantic content should still be prompt + try testing.expectEqual(.prompt, t.screens.active.cursor.semantic_content); +} + +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 }); + defer t.deinit(alloc); + + // Start a prompt then switch to input + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + try t.semanticPrompt(.init(.end_prompt_start_input)); + for ("echo \\") |c| try t.print(c); + + // Linefeed while in input mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should be marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Our cursor should still be in input + try testing.expectEqual(.input, t.screens.active.cursor.semantic_content); +} + +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 }); + defer t.deinit(alloc); + + // Complete prompt cycle: prompt -> input -> output + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + try t.semanticPrompt(.init(.end_prompt_start_input)); + for ("ls") |c| try t.print(c); + try t.semanticPrompt(.init(.end_input_start_output)); + + // Linefeed while in output mode + t.carriageReturn(); + try t.linefeed(); + + // The new row should NOT be marked as a prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +test "Terminal: OSC133C at x=0 on prompt row clears prompt mark" { + // This tests the second Fish heuristic: when Fish emits a newline + // 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 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("$ echo \\") |c| try t.print(c); + + // Simulate Fish behavior: newline first (which marks next row as prompt) + t.carriageReturn(); + try t.linefeed(); + + // Verify the new row is marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Now Fish sends OSC133C at column 0 (cursor is still at x=0) + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try t.semanticPrompt(.init(.end_input_start_output)); + + // The prompt continuation should be cleared + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.none, list_cell.row.semantic_prompt); + } +} + +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 }); + defer t.deinit(alloc); + + // Start a prompt on a row + try t.semanticPrompt(.init(.prompt_start)); + for ("$ ") |c| try t.print(c); + + // Move to a new line and mark it as prompt continuation manually + t.carriageReturn(); + try t.linefeed(); + try t.semanticPrompt(.{ + .action = .prompt_start, + .options_unvalidated = "k=c", + }); + for ("> ") |c| try t.print(c); + + // Verify the row is marked as prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + + // Now send OSC133C but cursor is NOT at column 0 + try testing.expect(t.screens.active.cursor.x > 0); + try t.semanticPrompt(.init(.end_input_start_output)); + + // The prompt continuation should NOT be cleared (we're not at x=0) + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } +} + +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 }); + defer t.deinit(alloc); + + // Start a prompt + try t.semanticPrompt(.init(.prompt_start)); + for ("line1") |c| try t.print(c); + + // Multiple newlines + t.carriageReturn(); + try t.linefeed(); + for ("line2") |c| try t.print(c); + t.carriageReturn(); + try t.linefeed(); + for ("line3") |c| try t.print(c); + + // First row should be prompt + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 0, + } }).?; + try testing.expectEqual(.prompt, list_cell.row.semantic_prompt); + } + + // Second and third rows should be prompt continuation + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 1, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = 0, + .y = 2, + } }).?; + try testing.expectEqual(.prompt_continuation, list_cell.row.semantic_prompt); + } +} + +test "Terminal: OSC133A click_events=1 sets click to click_events" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // Verify default state is none + try testing.expectEqual(.none, t.screens.active.semantic_prompt.click); + + // OSC 133;A with click_events=1 + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "click_events=1", + }); + + try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click); +} + +test "Terminal: OSC133A click_events=0 does not set click_events" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // OSC 133;A with click_events=0 + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "click_events=0", + }); + + // Should remain none since click_events=0 doesn't activate anything + try testing.expectEqual(.none, t.screens.active.semantic_prompt.click); +} + +test "Terminal: OSC133A cl option sets click to cl value" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // OSC 133;A with cl=m (multiple) + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "cl=m", + }); + + try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .multiple }, t.screens.active.semantic_prompt.click); +} + +test "Terminal: OSC133A cl=line sets click to line" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "cl=line", + }); + + try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .line }, t.screens.active.semantic_prompt.click); +} + +test "Terminal: OSC133A click_events=1 takes priority over cl" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // OSC 133;A with both click_events=1 and cl=m + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "click_events=1;cl=m", + }); + + // click_events should take priority + try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click); +} + +test "Terminal: OSC133A click_events=0 falls back to cl" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // OSC 133;A with click_events=0 and cl=v + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "click_events=0;cl=v", + }); + + // Should fall back to cl since click_events is disabled + try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .cl = .conservative_vertical }, t.screens.active.semantic_prompt.click); +} + +test "Terminal: OSC133A no click options leaves click as none" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 5 }); + defer t.deinit(alloc); + + // OSC 133;A with no click-related options + try t.semanticPrompt(.{ + .action = .fresh_line_new_prompt, + .options_unvalidated = "aid=123", + }); + + try testing.expectEqual(.none, t.screens.active.semantic_prompt.click); +} + +test "Terminal: cursorIsAtPrompt" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 10, .rows = 3 }); + defer t.deinit(alloc); + + try testing.expect(!t.cursorIsAtPrompt()); + try t.semanticPrompt(.init(.prompt_start)); + try testing.expect(t.cursorIsAtPrompt()); + for ("$ ") |c| try t.print(c); + + // Input is also a prompt + try t.semanticPrompt(.init(.end_prompt_start_input)); + try testing.expect(t.cursorIsAtPrompt()); + for ("ls") |c| try t.print(c); + + // But once we say we're starting output, we're not a prompt + // (cursor is not at x=0, so the Fish heuristic doesn't trigger) + try t.semanticPrompt(.init(.end_input_start_output)); + // Still a prompt because this line has a prompt + try testing.expect(t.cursorIsAtPrompt()); + try t.linefeed(); + try testing.expect(!t.cursorIsAtPrompt()); + + // Until we know we're at a prompt again + try t.linefeed(); + try t.semanticPrompt(.init(.prompt_start)); + try testing.expect(t.cursorIsAtPrompt()); +} + +test "Terminal: cursorIsAtPrompt alternate screen" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 3, .rows = 2 }); + defer t.deinit(alloc); + + try testing.expect(!t.cursorIsAtPrompt()); + try t.semanticPrompt(.init(.prompt_start)); + try testing.expect(t.cursorIsAtPrompt()); + + // Secondary screen is never a prompt + try t.switchScreenMode(.@"1049", true); + try testing.expect(!t.cursorIsAtPrompt()); + try t.semanticPrompt(.init(.prompt_start)); + try testing.expect(!t.cursorIsAtPrompt()); +} + +test "Terminal: fullReset with a non-empty pen" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + t.screens.active.cursor.semantic_content = .input; + t.fullReset(); + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.style_id == 0); + } + + try testing.expectEqual(@as(style.Id, 0), t.screens.active.cursor.style_id); + try testing.expectEqual(.output, t.screens.active.cursor.semantic_content); +} + +test "Terminal: fullReset hyperlink" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.screens.active.startHyperlink("http://example.com", null); + t.fullReset(); + try testing.expectEqual(0, t.screens.active.cursor.hyperlink_id); +} + +test "Terminal: fullReset with a non-empty saved cursor" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + try t.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + t.saveCursor(); + t.fullReset(); + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expect(cell.style_id == 0); + } + + try testing.expectEqual(@as(style.Id, 0), t.screens.active.cursor.style_id); +} + +test "Terminal: fullReset origin mode" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.setCursorPos(3, 5); + t.modes.set(.origin, true); + t.fullReset(); + + // Origin mode should be reset and the cursor should be moved + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y); + try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.x); + try testing.expect(!t.modes.get(.origin)); +} + +test "Terminal: fullReset status display" { + var t = try init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + t.status_display = .status_line; + t.fullReset(); + try testing.expect(t.status_display == .main); +} + +// 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 }); + defer t.deinit(testing.allocator); + + try t.switchScreenMode(.@"1049", true); + t.screens.active.kitty_keyboard.push(.{ + .disambiguate = true, + .report_events = false, + .report_alternates = true, + .report_all = true, + .report_associated = true, + }); + try t.switchScreenMode(.@"1049", false); + + t.fullReset(); + try testing.expect(t.screens.get(.alternate) == null); +} + +test "Terminal: fullReset default modes" { + var t = try init(testing.allocator, .{ + .cols = 10, + .rows = 10, + .default_modes = .{ .grapheme_cluster = true }, + }); + defer t.deinit(testing.allocator); + try testing.expect(t.modes.get(.grapheme_cluster)); + t.fullReset(); + try testing.expect(t.modes.get(.grapheme_cluster)); +} + +test "Terminal: fullReset tracked pins" { + var t = try init(testing.allocator, .{ .cols = 80, .rows = 80 }); + defer t.deinit(testing.allocator); + + // Create a tracked pin + const p = try t.screens.active.pages.trackPin(t.screens.active.cursor.page_pin.*); + t.fullReset(); + try testing.expect(t.screens.active.pages.pinIsValid(p.*)); +} + +// https://github.com/mitchellh/ghostty/issues/272 +// This is also tested in depth in screen resize tests but I want to keep +// 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 }); + defer t.deinit(alloc); + + try t.print('x'); + try t.print('π'); // 0x1F600 + try t.resize(alloc, 2, 3); + t.setCursorPos(1, 2); + try t.print('π'); // 0x1F600 +} + +// https://github.com/mitchellh/ghostty/issues/723 +// This was found via fuzzing so its highly specific. +test "Terminal: resize with left and right margin set" { + const alloc = testing.allocator; + const cols = 70; + const rows = 23; + var t = try init(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); + t.setLeftAndRightMargin(2, 0); + try t.printRepeat(1850); + _ = t.modes.restore(.enable_mode_3); + try t.resize(alloc, cols, rows); +} + +// https://github.com/mitchellh/ghostty/issues/1343 +test "Terminal: resize with wraparound off" { + const alloc = testing.allocator; + const cols = 4; + const rows = 2; + var t = try init(alloc, .{ .cols = cols, .rows = rows }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, false); + try t.print('0'); + try t.print('1'); + try t.print('2'); + try t.print('3'); + const new_cols = 2; + try t.resize(alloc, new_cols, rows); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01", str); +} + +test "Terminal: resize with wraparound on" { + const alloc = testing.allocator; + const cols = 4; + const rows = 2; + var t = try init(alloc, .{ .cols = cols, .rows = rows }); + defer t.deinit(alloc); + + t.modes.set(.wraparound, true); + try t.print('0'); + try t.print('1'); + try t.print('2'); + try t.print('3'); + const new_cols = 2; + try t.resize(alloc, new_cols, rows); + + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("01\n23", str); +} + +test "Terminal: resize with high unique style per cell" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 30, .rows = 30 }); + defer t.deinit(alloc); + + for (0..t.rows) |y| { + for (0..t.cols) |x| { + t.setCursorPos(y, x); + try t.setAttribute(.{ .direct_color_bg = .{ + .r = @intCast(x), + .g = @intCast(y), + .b = 0, + } }); + try t.print('x'); + } + } + + try t.resize(alloc, 60, 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 }); + defer t.deinit(alloc); + + const cell_count: u16 = @intCast(t.rows * t.cols); + for (0..cell_count) |i| { + const r: u8 = @intCast(i >> 8); + const g: u8 = @intCast(i & 0xFF); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = r, + .g = g, + .b = 0, + } }); + try t.print('x'); + } + + try t.resize(alloc, 60, 30); +} + +test "Terminal: resize with reflow and saved cursor" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 3 }); + defer t.deinit(alloc); + try t.printString("1A2B"); + t.setCursorPos(2, 2); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); + } + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A\n2B", str); + } + + t.saveCursor(); + try t.resize(alloc, 5, 3); + t.restoreCursor(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A2B", str); + } + + // Verify our cursor is still in the same place + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); + } +} + +test "Terminal: resize with reflow and saved cursor pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .cols = 2, .rows = 3 }); + defer t.deinit(alloc); + try t.printString("1A2B"); + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ + .x = t.screens.active.cursor.x, + .y = t.screens.active.cursor.y, + } }).?; + const cell = list_cell.cell; + try testing.expectEqual(@as(u32, 'B'), cell.content.codepoint); + } + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A\n2B", str); + } + + t.saveCursor(); + try t.resize(alloc, 5, 3); + t.restoreCursor(); + + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A2B", str); + } + + // Pending wrap should be reset + try t.print('X'); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A2BX", str); + } +} + +test "Terminal: DECCOLM without DEC mode 40" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.@"132_column", true); + try t.deccolm(alloc, .@"132_cols"); + try testing.expectEqual(@as(usize, 5), t.cols); + try testing.expectEqual(@as(usize, 5), t.rows); + try testing.expect(!t.modes.get(.@"132_column")); +} + +test "Terminal: DECCOLM unset" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + try testing.expectEqual(@as(usize, 80), t.cols); + try testing.expectEqual(@as(usize, 5), t.rows); +} + +test "Terminal: DECCOLM resets pending wrap" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + for ("ABCDE") |c| try t.print(c); + try testing.expect(t.screens.active.cursor.pending_wrap); + + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + try testing.expectEqual(@as(usize, 80), t.cols); + try testing.expectEqual(@as(usize, 5), t.rows); + try testing.expect(!t.screens.active.cursor.pending_wrap); +} + +test "Terminal: DECCOLM preserves SGR bg" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + try t.setAttribute(.{ .direct_color_bg = .{ + .r = 0xFF, + .g = 0, + .b = 0, + } }); + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + + { + const list_cell = t.screens.active.pages.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?; + try testing.expect(list_cell.cell.content_tag == .bg_color_rgb); + try testing.expectEqual(Cell.RGB{ + .r = 0xFF, + .g = 0, + .b = 0, + }, list_cell.cell.content.color_rgb); + } +} + +test "Terminal: DECCOLM resets scroll region" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + t.modes.set(.enable_left_and_right_margin, true); + t.setTopAndBottomMargin(2, 3); + t.setLeftAndRightMargin(3, 5); + + t.modes.set(.enable_mode_3, true); + try t.deccolm(alloc, .@"80_cols"); + + try testing.expect(t.modes.get(.enable_left_and_right_margin)); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.top); + try testing.expectEqual(@as(usize, 4), t.scrolling_region.bottom); + try testing.expectEqual(@as(usize, 0), t.scrolling_region.left); + try testing.expectEqual(@as(usize, 79), t.scrolling_region.right); +} + +test "Terminal: mode 47 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + try t.switchScreenMode(.@"47", true); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + try t.switchScreenMode(.@"47", false); + try testing.expectEqual(.primary, t.screens.active_key); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Go back to alt screen with mode 47 + try t.switchScreenMode(.@"47", true); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Screen should retain content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } +} + +test "Terminal: mode 47 copies cursor both directions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Color our cursor red + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + + // Go to alt screen with mode 47 + try t.switchScreenMode(.@"47", true); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Verify that our style is set + { + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); + } + + // Set a new style + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); + + // Go back to primary + try t.switchScreenMode(.@"47", false); + try testing.expectEqual(.primary, t.screens.active_key); + + // Verify that our style is still set + { + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); + } +} + +test "Terminal: mode 1047 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + try t.switchScreenMode(.@"1047", true); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + try t.switchScreenMode(.@"1047", false); + try testing.expectEqual(.primary, t.screens.active_key); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Go back to alt screen with mode 1047 + try t.switchScreenMode(.@"1047", true); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +test "Terminal: mode 1047 copies cursor both directions" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Color our cursor red + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0xFF, .g = 0, .b = 0x7F } }); + + // Go to alt screen with mode 47 + try t.switchScreenMode(.@"1047", true); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Verify that our style is set + { + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); + } + + // Set a new style + try t.setAttribute(.{ .direct_color_fg = .{ .r = 0, .g = 0xFF, .b = 0 } }); + + // Go back to primary + try t.switchScreenMode(.@"1047", false); + try testing.expectEqual(.primary, t.screens.active_key); + + // Verify that our style is still set + { + try testing.expect(t.screens.active.cursor.style_id != style.default_id); + const page = &t.screens.active.cursor.page_pin.node.data; + try testing.expectEqual(@as(usize, 1), page.styles.count()); + try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); + } +} + +test "Terminal: mode 1049 alt screen plain" { + const alloc = testing.allocator; + var t = try init(alloc, .{ .rows = 5, .cols = 5 }); + defer t.deinit(alloc); + + // Print on primary screen + try t.printString("1A"); + + // Go to alt screen with mode 47 + try t.switchScreenMode(.@"1049", true); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } + + // Print on alt screen. This should be off center because + // we copy the cursor over from the primary screen + try t.printString("2B"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings(" 2B", str); + } + + // Go back to primary + try t.switchScreenMode(.@"1049", false); + try testing.expectEqual(.primary, t.screens.active_key); + + // Primary screen should still have the original content + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1A", str); + } + + // Write, our cursor should be restored back. + try t.printString("C"); + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("1AC", str); + } + + // Go back to alt screen with mode 1049 + try t.switchScreenMode(.@"1049", true); + try testing.expectEqual(.alternate, t.screens.active_key); + + // Screen should be empty + { + const str = try t.plainString(testing.allocator); + defer testing.allocator.free(str); + try testing.expectEqualStrings("", str); + } +} + +// Reproduces a crash found by AFL++ fuzzer (afl-out/stream/default/crashes/ +// id:000007,sig:06,src:004522). The crash is a page integrity violation +// "spacer tail not following wide" triggered during scrollUp -> deleteLines +// -> clearCells. When deleteLines count >= scroll region height, all rows +// are cleared (no shifting), so rowWillBeShifted is never called and wide +// 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 }); + defer t.deinit(alloc); + + // Place a wide character at col 39 (1-indexed) on several rows. + // The wide cell lands at col 38 (0-indexed) with spacer_tail at col 39. + t.setCursorPos(10, 39); + try t.print(0x4E2D); // 'δΈ' + + // Set left/right scroll margins so scrolling_region.right = 38. + // clearCells will clear cells[4..39], which includes the wide cell + // at col 38 but NOT the spacer_tail at col 39. + t.modes.set(.enable_left_and_right_margin, true); + t.setLeftAndRightMargin(5, 39); + + // scrollUp with count >= region height causes deleteLines to clear + // ALL rows without any shifting, so rowWillBeShifted is never called + // and the orphaned spacer_tail at col 39 triggers a page integrity + // violation in clearCells. + try t.scrollUp(t.rows); +} |
