From 17f2dc59fa8bc03ed6686b67fc7034504894b427 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Tue, 28 Oct 2025 07:23:00 -0700 Subject: [PATCH] terminal: formatter that can emit VT sequences (#9374) This adds a new formatter that can be used with standard Zig `{f}` formatting that emits any portion of the terminal screen as VT sequences. In addition to simply styling, this can emit the entire terminal/screen state such as cursor positions, active style, terminal modes, etc. To do this, I've extracted all formatting to a dedicated `formatter` package within `terminal`. This handles all formatting types (currently plaintext and VT formatting, but can imagine things like HTML in the future). Presently, we have "formatting" split out across a variety of places in Terminal, Screen, PageList, and Page. I didn't remove this code yet but I intend to unify it all on formatter in the future. This also doesn't expose this functionality in any user-facing way yet. This PR just adds it to the ghostty-vt Zig module and unit tests it. Ghostty app changes will come later. **This also improves the readonly stream** to handle OSC color operations for _setting_ but it doesn't emit any responses of course, since its readonly. --- src/terminal/formatter.zig | 2743 ++++++++++++++++++++++++++++++ src/terminal/main.zig | 1 + src/terminal/page.zig | 39 +- src/terminal/stream.zig | 31 +- src/terminal/stream_readonly.zig | 100 +- src/terminal/style.zig | 378 ++++ 6 files changed, 3256 insertions(+), 36 deletions(-) create mode 100644 src/terminal/formatter.zig diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig new file mode 100644 index 000000000..9e6632dca --- /dev/null +++ b/src/terminal/formatter.zig @@ -0,0 +1,2743 @@ +const std = @import("std"); +const size = @import("size.zig"); +const charsets = @import("charsets.zig"); +const kitty = @import("kitty.zig"); +const modespkg = @import("modes.zig"); +const Screen = @import("Screen.zig"); +const Terminal = @import("Terminal.zig"); +const Cell = @import("page.zig").Cell; +const Page = @import("page.zig").Page; +const PageList = @import("PageList.zig"); +const Row = @import("page.zig").Row; +const Selection = @import("Selection.zig"); +const Style = @import("style.zig").Style; + +// TODO: +// - Rectangular selection + +/// Formats available. +pub const Format = enum { + /// Plain text + plain, + + /// Include VT sequences to preserve colors, styles, URLs, etc. + /// This is predominantly SGR sequences but may contain others as needed. + /// + /// Note that for reference colors, like palette indices, this will + /// vary based on the formatter and you should see the docs. For example, + /// PageFormatter with VT will emit SGR sequences with palette indices, + /// not the color itself. + vt, + + pub fn styled(self: Format) bool { + return switch (self) { + .plain => false, + .vt => true, + }; + } +}; + +/// Common encoding options regardless of what exact formatter is used. +pub const Options = struct { + /// The format to emit. + emit: Format, + + /// Whether to unwrap soft-wrapped lines. If false, this will emit the + /// screen contents as it is rendered on the page in the given size. + unwrap: bool = false, + + pub const plain: Options = .{ .emit = .plain }; + pub const vt: Options = .{ .emit = .vt }; +}; + +/// Terminal formatter formats the active terminal screen. +/// +/// This will always only emit data related to the currently active screen. +/// If you want to emit data for a specific screen (e.g. primary vs alt), then +/// switch to that screen in the terminal prior to using this. +/// +/// If you want to emit data for all screens (a less common operation), then +/// you must create a no-content TerminalFormatter followed by multiple +/// explicit ScreenFormatter calls. This isn't a common operation so this +/// little extra work should be acceptable. +/// +/// For styled formatting, this will emit the palette colors at the +/// beginning so that the output can be rendered properly according to +/// the current terminal state. +pub const TerminalFormatter = struct { + /// The terminal to format. + terminal: *const Terminal, + + /// The common options + opts: Options, + + /// The content to include. + content: ScreenFormatter.Content, + + /// Extra stuff to emit, such as terminal modes, palette, cursor, etc. + /// This information is ONLY emitted when the format is "vt". + extra: Extra, + + pub const Extra = packed struct { + /// Emit the palette using OSC 4 sequences. + palette: bool, + + /// Emit terminal modes that differ from their defaults using CSI h/l + /// sequences. Defaults are according to the Ghostty defaults which + /// are generally match most terminal defaults. This will include + /// things like current screen, bracketed mode, mouse event reporting, + /// etc. + modes: bool, + + /// Emit scrolling region state using DECSTBM and DECSLRM sequences. + scrolling_region: bool, + + /// Emit tabstop positions by clearing all tabs (CSI 3 g) and setting + /// each configured tabstop with HTS. + tabstops: bool, + + /// Emit the present working directory using OSC 7. + pwd: bool, + + /// Emit keyboard modes such as ModifyOtherKeys using CSI > 4 m + /// sequences. + keyboard: bool, + + /// The screen extras to emit. TerminalFormatter always only + /// emits data for the currently active screen. If you want to emit + /// data for all screens, you should manually construct a no-content + /// terminal formatter, followed by screen formatters. + screen: ScreenFormatter.Extra, + + /// Emit nothing. + pub const none: Extra = .{ + .palette = false, + .modes = false, + .scrolling_region = false, + .tabstops = false, + .pwd = false, + .keyboard = false, + .screen = .none, + }; + + /// Emit style-relevant information only such as palettes. + pub const styles: Extra = .{ + .palette = true, + .modes = false, + .scrolling_region = false, + .tabstops = false, + .pwd = false, + .keyboard = false, + .screen = .styles, + }; + + /// Emit everything. This reconstructs the terminal state as closely + /// as possible. + pub const all: Extra = .{ + .palette = true, + .modes = true, + .scrolling_region = true, + .tabstops = true, + .pwd = true, + .keyboard = true, + .screen = .all, + }; + }; + + pub fn init( + terminal: *const Terminal, + opts: Options, + ) TerminalFormatter { + return .{ + .terminal = terminal, + .opts = opts, + .content = .{ .selection = null }, + .extra = .styles, + }; + } + + pub fn format( + self: TerminalFormatter, + writer: *std.Io.Writer, + ) !void { + // Emit palette before screen content if using VT format. Technically + // we could do this after but this way if replay is slow for whatever + // reason the colors will be right right away. + if (self.opts.emit == .vt and self.extra.palette) { + for (self.terminal.color_palette.colors, 0..) |rgb, i| { + try writer.print( + "\x1b]4;{d};rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", + .{ i, rgb.r, rgb.g, rgb.b }, + ); + } + } + + // Emit terminal modes that differ from defaults. We probably have + // some modes we want to emit before and some after, but for now for + // simplicity we just emit them all before. If we make this more complex + // later we should add test cases for it. + if (self.opts.emit == .vt and self.extra.modes) { + inline for (@typeInfo(modespkg.Mode).@"enum".fields) |field| { + const mode: modespkg.Mode = @enumFromInt(field.value); + const current = self.terminal.modes.get(mode); + const default_val = @field(self.terminal.modes.default, field.name); + + if (current != default_val) { + const tag: modespkg.ModeTag = @bitCast(@intFromEnum(mode)); + const prefix = if (tag.ansi) "" else "?"; + const suffix = if (current) "h" else "l"; + try writer.print("\x1b[{s}{d}{s}", .{ prefix, tag.value, suffix }); + } + } + } + + var screen_formatter: ScreenFormatter = .init(&self.terminal.screen, self.opts); + screen_formatter.content = self.content; + screen_formatter.extra = self.extra.screen; + try screen_formatter.format(writer); + + // Extra terminal state to emit after the screen contents so that + // it doesn't impact the emitted contents. + if (self.opts.emit == .vt) { + // Emit scrolling region using DECSTBM and DECSLRM + if (self.extra.scrolling_region) { + const region = &self.terminal.scrolling_region; + + // DECSTBM: top and bottom margins (1-indexed) + // Only emit if not the full screen + if (region.top != 0 or region.bottom != self.terminal.rows - 1) { + try writer.print("\x1b[{d};{d}r", .{ region.top + 1, region.bottom + 1 }); + } + + // DECSLRM: left and right margins (1-indexed) + // Only emit if not the full width + if (region.left != 0 or region.right != self.terminal.cols - 1) { + try writer.print("\x1b[{d};{d}s", .{ region.left + 1, region.right + 1 }); + } + } + + // Emit tabstop positions + if (self.extra.tabstops) { + // Clear all tabs (CSI 3 g) + try writer.print("\x1b[3g", .{}); + + // Set each configured tabstop by moving cursor and using HTS + for (0..self.terminal.cols) |col| { + if (self.terminal.tabstops.get(col)) { + // Move cursor to the column (1-indexed) + try writer.print("\x1b[{d}G", .{col + 1}); + // Set tab (HTS) + try writer.print("\x1bH", .{}); + } + } + } + + // Emit keyboard modes such as ModifyOtherKeys + if (self.extra.keyboard) { + // Only emit if modify_other_keys_2 is true + if (self.terminal.flags.modify_other_keys_2) { + try writer.print("\x1b[>4;2m", .{}); + } + } + + // Emit present working directory using OSC 7 + if (self.extra.pwd) { + const pwd = self.terminal.pwd.items; + if (pwd.len > 0) try writer.print("\x1b]7;{s}\x1b\\", .{pwd}); + } + } + } +}; + +/// Screen formatter formats a single terminal screen (e.g. primary vs alt). +pub const ScreenFormatter = struct { + /// The screen to format. + screen: *const Screen, + + /// The common options + opts: Options, + + /// The content to include. + content: Content, + + /// Extra stuff to emit, such as cursor, style, hyperlinks, etc. + /// This information is ONLY emitted when the format is "vt". + extra: Extra, + + pub const Content = union(enum) { + /// Emit no content, only terminal state such as modes, palette, etc. + /// via extra. + none, + + /// Emit the content specified by the selection. Null for all. + selection: ?Selection, + }; + + pub const Extra = packed struct { + /// Emit cursor position using CUP (CSI H). + cursor: bool, + + /// Emit current SGR style state based on the cursor's active style_id. + /// This reconstructs the SGR attributes (bold, italic, colors, etc.) at + /// the cursor position. + style: bool, + + /// Emit current hyperlink state using OSC 8 sequences. + /// This sets the active hyperlink based on cursor.hyperlink_id. + hyperlink: bool, + + /// Emit character protection mode using DECSCA. + protection: bool, + + /// Emit Kitty keyboard protocol state using CSI > u and CSI = sequences. + kitty_keyboard: bool, + + /// Emit character set designations and invocations. + /// This includes G0-G3 designations (ESC ( ) * +) and GL/GR invocations. + charsets: bool, + + /// Emit nothing. + pub const none: Extra = .{ + .cursor = false, + .style = false, + .hyperlink = false, + .protection = false, + .kitty_keyboard = false, + .charsets = false, + }; + + /// Emit style-relevant information only. + pub const styles: Extra = .{ + .cursor = false, + .style = true, + .hyperlink = true, + .protection = false, + .kitty_keyboard = false, + .charsets = false, + }; + + /// Emit everything. This reconstructs the screen state as closely + /// as possible. + pub const all: Extra = .{ + .cursor = true, + .style = true, + .hyperlink = true, + .protection = true, + .kitty_keyboard = true, + .charsets = true, + }; + }; + + pub fn init( + screen: *const Screen, + opts: Options, + ) ScreenFormatter { + return .{ + .screen = screen, + .opts = opts, + .content = .{ .selection = null }, + .extra = .none, + }; + } + + pub fn format( + self: ScreenFormatter, + writer: *std.Io.Writer, + ) !void { + switch (self.content) { + .none => {}, + + .selection => |selection_| { + // Emit our pagelist contents according to our selection. + var list_formatter: PageListFormatter = .init(&self.screen.pages, self.opts); + if (selection_) |sel| { + list_formatter.top_left = sel.topLeft(self.screen); + list_formatter.bottom_right = sel.bottomRight(self.screen); + } + try list_formatter.format(writer); + }, + } + + // Emit extra screen state after content if we care. The state has + // to be emitted after since some state such as cursor position and + // style are impacted by content rendering. + switch (self.opts.emit) { + .plain => return, + .vt => {}, + } + + // Emit current SGR style state + if (self.extra.style) { + const cursor = &self.screen.cursor; + try writer.print("{f}", .{cursor.style.formatterVt()}); + } + + // Emit current hyperlink state using OSC 8 + if (self.extra.hyperlink) { + const cursor = &self.screen.cursor; + if (cursor.hyperlink) |link| { + // Start hyperlink with uri (and explicit id if present) + switch (link.id) { + .explicit => |id| try writer.print( + "\x1b]8;id={s};{s}\x1b\\", + .{ id, link.uri }, + ), + .implicit => try writer.print( + "\x1b]8;;{s}\x1b\\", + .{link.uri}, + ), + } + } + } + + // Emit character protection mode using DECSCA + if (self.extra.protection) { + const cursor = &self.screen.cursor; + if (cursor.protected) { + // DEC protected mode + try writer.print("\x1b[1\"q", .{}); + } + } + + // Emit Kitty keyboard protocol state using CSI = u + if (self.extra.kitty_keyboard) { + const current_flags = self.screen.kitty_keyboard.current(); + if (current_flags.int() != kitty.KeyFlags.disabled.int()) { + const flags = current_flags.int(); + try writer.print("\x1b[={d};1u", .{flags}); + } + } + + // Emit character set designations and invocations + if (self.extra.charsets) { + const charset = &self.screen.charset; + + // Emit G0-G3 designations + for (std.enums.values(charsets.Slots)) |slot| { + const cs = charset.charsets.get(slot); + if (cs != .utf8) { // Only emit non-default charsets + const intermediate: u8 = switch (slot) { + .G0 => '(', + .G1 => ')', + .G2 => '*', + .G3 => '+', + }; + const final: u8 = switch (cs) { + .ascii => 'B', + .british => 'A', + .dec_special => '0', + else => continue, + }; + try writer.print("\x1b{c}{c}", .{ intermediate, final }); + } + } + + // Emit GL invocation if not G0 + if (charset.gl != .G0) { + const seq = switch (charset.gl) { + .G0 => unreachable, + .G1 => "\x0e", // SO - Shift Out + .G2 => "\x1bn", // LS2 + .G3 => "\x1bo", // LS3 + }; + try writer.print("{s}", .{seq}); + } + + // Emit GR invocation if not G2 + if (charset.gr != .G2) { + const seq = switch (charset.gr) { + .G0 => unreachable, // GR can't be G0 + .G1 => "\x1b~", // LS1R + .G2 => unreachable, + .G3 => "\x1b|", // LS3R + }; + try writer.print("{s}", .{seq}); + } + } + + // Emit cursor position using CUP (CSI H) + if (self.extra.cursor) { + const cursor = &self.screen.cursor; + // CUP is 1-indexed + try writer.print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 }); + } + } +}; + +/// PageList formatter formats multiple pages as represented by a PageList. +pub const PageListFormatter = struct { + /// The pagelist to format. + list: *const PageList, + + /// The common options + opts: Options, + + /// The bounds of the PageList to format. The top left and bottom right + /// MUST be ordered properly. + top_left: ?PageList.Pin, + bottom_right: ?PageList.Pin, + + pub fn init( + list: *const PageList, + opts: Options, + ) PageListFormatter { + return PageListFormatter{ + .list = list, + .opts = opts, + .top_left = null, + .bottom_right = null, + }; + } + + pub fn format( + self: PageListFormatter, + writer: *std.Io.Writer, + ) !void { + const tl: PageList.Pin = self.top_left orelse self.list.getTopLeft(.screen); + const br: PageList.Pin = self.bottom_right orelse self.list.getBottomRight(.screen).?; + + var page_state: ?PageFormatter.TrailingState = null; + var iter = tl.pageIterator(.right_down, br); + while (iter.next()) |chunk| { + var formatter: PageFormatter = .init(&chunk.node.data, self.opts); + formatter.start_y = chunk.start; + formatter.end_y = chunk.end; + formatter.trailing_state = page_state; + + // Apply start_x if this is the first chunk + if (chunk.node == tl.node) formatter.start_x = tl.x; + + // Apply end_x if this is the last chunk and it ends at br.y + if (chunk.node == br.node and + formatter.end_y == br.y + 1) formatter.end_x = br.x + 1; + + page_state = try formatter.formatWithState(writer); + } + } +}; + +/// Page formatter. +/// +/// For styled formatting such as VT, this will emit references for palette +/// colors. If you want to capture the palette as-is at the type of formatting, +/// you'll have to emit the sequences for setting up the palette prior to +/// this formatting. (TODO: A function to do this) +pub const PageFormatter = struct { + /// The page to format. + page: *const Page, + + /// The common options + opts: Options, + + /// Start and end points within the page to format. If end x is not given + /// then it will be the full width. If end y is not given then it will be + /// the full height. + /// + /// The start x is considered the X in the first row and end X is + /// X in the final row. This isn't a rectangle selection by default. + start_x: size.CellCountInt, + start_y: size.CellCountInt, + end_x: ?size.CellCountInt, + end_y: ?size.CellCountInt, + + /// The previous trailing state from the prior page. If you're iterating + /// over multiple pages this helps ensure that unwrapping and other + /// accounting works properly. + trailing_state: ?TrailingState, + + /// Trailing state. This is used to ensure that rows wrapped across + /// multiple pages are unwrapped properly, as well as other accounting + /// we may do in the future. + pub const TrailingState = struct { + rows: usize = 0, + cells: usize = 0, + + pub const empty: TrailingState = .{ .rows = 0, .cells = 0 }; + }; + + /// Initializes a page formatter. Other options can be set directly on the + /// struct after initialization and before calling `format()`. + pub fn init(page: *const Page, opts: Options) PageFormatter { + return PageFormatter{ + .page = page, + .opts = opts, + .start_x = 0, + .start_y = 0, + .end_x = null, + .end_y = null, + .trailing_state = null, + }; + } + + pub fn format( + self: PageFormatter, + writer: *std.Io.Writer, + ) !void { + _ = try self.formatWithState(writer); + } + + pub fn formatWithState( + self: PageFormatter, + writer: *std.Io.Writer, + ) !TrailingState { + var blank_rows: usize = 0; + var blank_cells: usize = 0; + + // Continue our prior trailing state if we have it, but only if we're + // starting from the beginning (start_y and start_x are both 0). + // If a non-zero start position is specified, ignore trailing state. + if (self.trailing_state) |state| { + if (self.start_y == 0 and self.start_x == 0) { + blank_rows = state.rows; + blank_cells = state.cells; + } + } + + // Setup our starting row and perform some validation for overflows. + const start_y: size.CellCountInt = self.start_y; + if (start_y >= self.page.size.rows) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_y_unclamped: size.CellCountInt = self.end_y orelse self.page.size.rows; + if (start_y >= end_y_unclamped) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_y = @min(end_y_unclamped, self.page.size.rows); + + // Setup our starting column and perform some validation for overflows. + // Note: start_x only applies to the first row, end_x only applies to the last row. + const start_x: size.CellCountInt = self.start_x; + if (start_x >= self.page.size.cols) return .{ .rows = blank_rows, .cells = blank_cells }; + const end_x_unclamped: size.CellCountInt = self.end_x orelse self.page.size.cols; + const end_x = @min(end_x_unclamped, self.page.size.cols); + + // If we only have a single row, validate that start_x < end_x + if (start_y + 1 == end_y and start_x >= end_x) { + return .{ .rows = blank_rows, .cells = blank_cells }; + } + + // Our style for non-plain formats + var style: Style = .{}; + + for (start_y..end_y) |y_usize| { + const y: size.CellCountInt = @intCast(y_usize); + const row: *Row = self.page.getRow(y); + const cells: []const Cell = self.page.getCells(row); + + // Determine the x range for this row + // - First row: start_x to end of row (or end_x if single row) + // - Last row: start of row to end_x + // - Middle rows: full width + const is_first_row = (y == start_y); + const is_last_row = (y == end_y - 1); + const row_start_x: size.CellCountInt = if (is_first_row) start_x else 0; + const row_end_x: size.CellCountInt = if (is_last_row) end_x else self.page.size.cols; + const cells_subset = cells[row_start_x..row_end_x]; + + // If this row is blank, accumulate to avoid a bunch of extra + // work later. If it isn't blank, make sure we dump all our + // blanks. + if (!Cell.hasTextAny(cells_subset)) { + blank_rows += 1; + continue; + } + + for (1..blank_rows + 1) |_| { + try writer.writeAll("\r\n"); + } + blank_rows = 0; + + // If we're not wrapped, we always add a newline so after + // the row is printed we can add a newline. + if (!row.wrap or !self.opts.unwrap) blank_rows += 1; + + // If the row doesn't continue a wrap then we need to reset + // our blank cell count. + if (!row.wrap_continuation or !self.opts.unwrap) blank_cells = 0; + + // Go through each cell and print it + for (cells_subset) |*cell| { + // Skip spacers. These happen naturally when wide characters + // are printed again on the screen (for well-behaved terminals!) + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } + + // If we have a zero value, then we accumulate a counter. We + // only want to turn zero values into spaces if we have a non-zero + // char sometime later. + if (!cell.hasText() or cell.codepoint() == ' ') { + blank_cells += 1; + continue; + } + + // This cell is not blank. If we have accumulated blank cells + // then we want to emit them now. + if (blank_cells > 0) { + try writer.splatByteAll(' ', blank_cells); + blank_cells = 0; + } + + switch (cell.content_tag) { + // We combine codepoint and graphemes because both have + // shared style handling. We use comptime to dup it. + inline .codepoint, .codepoint_grapheme => |tag| { + // If we're emitting styling and we have styles, then + // we need to load the style and emit any sequences + // as necessary. + if (self.opts.emit.styled() and cell.hasStyling()) style: { + // Get the style. + const cell_style = self.page.styles.get( + self.page.memory, + cell.style_id, + ); + + // If the style hasn't changed since our last + // emitted style, don't bloat the output. + if (cell_style.eql(style)) break :style; + + // New style, emit it. + style = cell_style.*; + try writer.print("{f}", .{style.formatterVt()}); + } + + try writer.print("{u}", .{cell.content.codepoint}); + if (comptime tag == .codepoint_grapheme) { + for (self.page.lookupGrapheme(cell).?) |cp| { + try writer.print("{u}", .{cp}); + } + } + }, + + // Unreachable since we do hasText() above + .bg_color_palette, + .bg_color_rgb, + => unreachable, + } + } + } + + return .{ .rows = blank_rows, .cells = blank_cells }; + } +}; + +test "Page plain single line" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + // Verify we have only a single page + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello, world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells); +} + +test "Page plain multiline" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + // Verify we have only a single page + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain multi blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\n\r\n\r\nworld"); + + // Verify we have only a single page + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\n\r\n\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 3), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain trailing blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\n\r\n"); + + // Verify we have only a single page + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output. We expect there to be no trailing newlines because + // we can't differentiate trailing blank lines as being meaningful because + // the page formatter can't see the cursor position. + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain trailing whitespace" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello \r\nworld "); + + // Verify we have only a single page + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output. We expect there to be no trailing newlines because + // we can't differentiate trailing blank lines as being meaningful because + // the page formatter can't see the cursor position. + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain with prior trailing state rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + formatter.trailing_state = .{ .rows = 2, .cells = 0 }; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("\r\n\r\nhello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain with prior trailing state cells no wrapped line" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + var formatter: PageFormatter = .init(page, .plain); + formatter.trailing_state = .{ .rows = 0, .cells = 3 }; + + const state = try formatter.formatWithState(&builder.writer); + // Blank cells are reset when row is not a wrap continuation + try testing.expectEqualStrings("hello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain with prior trailing state cells with wrap continuation" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("world"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + + // Surgically modify the first row to be a wrap continuation + const row = page.getRow(0); + row.wrap_continuation = true; + + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + formatter.trailing_state = .{ .rows = 0, .cells = 3 }; + + const state = try formatter.formatWithState(&builder.writer); + // Blank cells are preserved when row is a wrap continuation with unwrap enabled + try testing.expectEqualStrings(" world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain soft-wrapped without unwrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world test"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + const state = try formatter.formatWithState(&builder.writer); + // Without unwrap, wrapped lines show as separate lines + try testing.expectEqualStrings("hello worl\r\nd test", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); +} + +test "Page plain soft-wrapped with unwrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world test"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + + const state = try formatter.formatWithState(&builder.writer); + // With unwrap, wrapped lines are joined together + try testing.expectEqualStrings("hello world test", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); +} + +test "Page plain soft-wrapped 3 lines without unwrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world this is a test"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + const state = try formatter.formatWithState(&builder.writer); + // Without unwrap, wrapped lines show as separate lines + try testing.expectEqualStrings("hello worl\r\nd this is\r\na test", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); +} + +test "Page plain soft-wrapped 3 lines with unwrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world this is a test"); + + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); + + const state = try formatter.formatWithState(&builder.writer); + // With unwrap, wrapped lines are joined together + try testing.expectEqualStrings("hello world this is a test", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells); +} + +test "Page plain start_y subset" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\ntest"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 1; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("world\r\ntest", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); +} + +test "Page plain end_y subset" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\ntest"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_y = 2; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain start_y and end_y range" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 1; + formatter.end_y = 3; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("world\r\ntest", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells); +} + +test "Page plain start_y out of bounds" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 30; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); +} + +test "Page plain end_y greater than rows" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_y = 30; + + const state = try formatter.formatWithState(&builder.writer); + // Should clamp to page.size.rows and work normally + try testing.expectEqualStrings("hello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain end_y less than start_y" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 5; + formatter.end_y = 2; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); +} + +test "Page plain start_x on first row only" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 6; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells); +} + +test "Page plain end_x on last row only" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("first line\r\nsecond line\r\nthird line"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_y = 3; + formatter.end_x = 6; + + const state = try formatter.formatWithState(&builder.writer); + // First two rows: full width, last row: up to end_x=6 + try testing.expectEqualStrings("first line\r\nsecond line\r\nthird", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, 1), state.cells); +} + +test "Page plain start_x and end_x multiline" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 6; + formatter.end_y = 3; + formatter.end_x = 4; + + const state = try formatter.formatWithState(&builder.writer); + // First row: "world" (start_x=6 to end of row) + // Second row: "test case" (full row) + // Third row: "foo " (start to end_x=4) + try testing.expectEqualStrings("world\r\ntest case\r\nfoo", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 1), state.rows); + try testing.expectEqual(@as(usize, 1), state.cells); +} + +test "Page plain start_x out of bounds" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 100; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); +} + +test "Page plain end_x greater than cols" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.end_x = 100; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain end_x less than start_x single row" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 10; + formatter.end_y = 1; + formatter.end_x = 5; + + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("", builder.writer.buffered()); + try testing.expectEqual(@as(usize, 0), state.rows); + try testing.expectEqual(@as(usize, 0), state.cells); +} + +test "Page plain start_y non-zero ignores trailing state" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 1; + formatter.trailing_state = .{ .rows = 5, .cells = 10 }; + + const state = try formatter.formatWithState(&builder.writer); + // Should NOT output the 5 newlines from trailing_state because start_y is non-zero + try testing.expectEqualStrings("world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain start_x non-zero ignores trailing state" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_x = 6; + formatter.trailing_state = .{ .rows = 2, .cells = 8 }; + + const state = try formatter.formatWithState(&builder.writer); + // Should NOT output the 2 newlines or 8 spaces from trailing_state because start_x is non-zero + try testing.expectEqualStrings("world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells); +} + +test "Page plain start_y and start_x zero uses trailing state" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + var formatter: PageFormatter = .init(page, .plain); + formatter.start_y = 0; + formatter.start_x = 0; + formatter.trailing_state = .{ .rows = 2, .cells = 0 }; + + const state = try formatter.formatWithState(&builder.writer); + // SHOULD output the 2 newlines from trailing_state because both start_y and start_x are 0 + try testing.expectEqualStrings("\r\n\r\nhello", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells); +} + +test "Page plain single line with styling" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, \x1b[1mworld\x1b[0m"); + + // Verify we have only a single page + const pages = &t.screen.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + // Create the formatter + const page = &pages.pages.last.?.data; + const formatter: PageFormatter = .init(page, .plain); + + // Verify output + const state = try formatter.formatWithState(&builder.writer); + try testing.expectEqualStrings("hello, world", builder.writer.buffered()); + try testing.expectEqual(@as(usize, page.size.rows), state.rows); + try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells); +} + +test "Page VT single line plain text" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello", builder.writer.buffered()); +} + +test "Page VT single line with bold" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mhello\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", builder.writer.buffered()); +} + +test "Page VT multiple styles" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld", builder.writer.buffered()); +} + +test "Page VT with foreground color" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[31mred\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred", builder.writer.buffered()); +} + +test "Page VT multi-line with styles" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\r\n\x1b[0m\x1b[3msecond", builder.writer.buffered()); +} + +test "Page VT duplicate style not emitted twice" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); + + const pages = &t.screen.pages; + const page = &pages.pages.last.?.data; + + const formatter: PageFormatter = .init(page, .vt); + try formatter.format(&builder.writer); + try testing.expectEqualStrings("\x1b[0m\x1b[1mhello", builder.writer.buffered()); +} + +test "PageList plain single line" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + const formatter: PageListFormatter = .init(&t.screen.pages, .plain); + try builder.writer.print("{f}", .{formatter}); + try testing.expectEqualStrings("hello, world", builder.writer.buffered()); +} + +test "PageList plain spanning two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page almost completely + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("page one"); + + // Verify we're still on one page + try testing.expect(pages.pages.first == pages.pages.last); + + // Add one more newline to push content to a second page + try s.nextSlice("\r\n"); + try testing.expect(pages.pages.first != pages.pages.last); + + // Write content on the second page + try s.nextSlice("page two"); + + // Format the entire PageList + var formatter: PageListFormatter = .init(pages, .plain); + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("page one\r\npage two", output); +} + +test "PageList soft-wrapped line spanning two pages without unwrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page with soft-wrapped content + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello world test"); + + // Verify we're on two pages due to wrapping + try testing.expect(pages.pages.first != pages.pages.last); + + // Format without unwrap - should show line breaks + var formatter: PageListFormatter = .init(pages, .plain); + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("hello worl\r\nd test", output); +} + +test "PageList soft-wrapped line spanning two pages with unwrap" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 10, + .rows = 3, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page with soft-wrapped content + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello world test"); + + // Verify we're on two pages due to wrapping + try testing.expect(pages.pages.first != pages.pages.last); + + // Format with unwrap - should join the wrapped lines + var formatter: PageListFormatter = .init(pages, .{ .emit = .plain, .unwrap = true }); + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("hello world test", output); +} + +test "PageList VT spanning two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill the first page almost completely + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("\x1b[1mpage one"); + + // Verify we're still on one page + try testing.expect(pages.pages.first == pages.pages.last); + + // Add one more newline to push content to a second page + try s.nextSlice("\r\n"); + try testing.expect(pages.pages.first != pages.pages.last); + + // New content is still styled + try s.nextSlice("page two"); + + // Format the entire PageList with VT + var formatter: PageListFormatter = .init(pages, .vt); + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\r\n\x1b[0m\x1b[1mpage two", output); +} + +test "PageList plain with x offset on single page" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world\r\ntest case\r\nfoo bar"); + + const pages = &t.screen.pages; + const node = pages.pages.first.?; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; + formatter.bottom_right = .{ .node = node, .y = 2, .x = 3 }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("world\r\ntest case\r\nfoo", builder.writer.buffered()); +} + +test "PageList plain with x offset spanning two pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + const pages = &t.screen.pages; + const first_page_rows = pages.pages.first.?.data.capacity.rows; + + // Fill first page almost completely + for (0..first_page_rows - 1) |_| try s.nextSlice("\r\n"); + try s.nextSlice("hello world"); + + // Verify we're still on one page + try testing.expect(pages.pages.first == pages.pages.last); + + // Push to second page + try s.nextSlice("\r\n"); + try testing.expect(pages.pages.first != pages.pages.last); + + try s.nextSlice("foo bar test"); + + const first_node = pages.pages.first.?; + const last_node = pages.pages.last.?; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 }; + formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 3 }; + + try formatter.format(&builder.writer); + const output = std.mem.trimStart(u8, builder.writer.buffered(), "\r\n"); + try testing.expectEqualStrings("world\r\nfoo", output); +} + +test "PageList plain with start_x only" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world"); + + const pages = &t.screen.pages; + const node = pages.pages.first.?; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.top_left = .{ .node = node, .y = 0, .x = 6 }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("world", builder.writer.buffered()); +} + +test "PageList plain with end_x only" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello world\r\ntest"); + + const pages = &t.screen.pages; + const node = pages.pages.first.?; + + var formatter: PageListFormatter = .init(pages, .plain); + formatter.bottom_right = .{ .node = node, .y = 1, .x = 2 }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello world\r\ntes", builder.writer.buffered()); +} + +test "TerminalFormatter plain no selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + const formatter: TerminalFormatter = .init(&t, .plain); + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); +} + +test "TerminalFormatter vt with palette" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Modify some palette colors using VT sequences + try s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\"); + try s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\"); + try s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\"); + try s.nextSlice("test"); + + const formatter: TerminalFormatter = .init(&t, .vt); + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify the palettes match + try testing.expectEqual(t.color_palette.colors[0], t2.color_palette.colors[0]); + try testing.expectEqual(t.color_palette.colors[1], t2.color_palette.colors[1]); + try testing.expectEqual(t.color_palette.colors[255], t2.color_palette.colors[255]); +} + +test "TerminalFormatter with selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("line1\r\nline2\r\nline3"); + + var formatter: TerminalFormatter = .init(&t, .plain); + formatter.content = .{ .selection = .init( + t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + ) }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("line2", builder.writer.buffered()); +} + +test "Screen plain single line" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello, world"); + + const formatter: ScreenFormatter = .init(&t.screen, .plain); + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello, world", builder.writer.buffered()); +} + +test "Screen plain multiline" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("hello\r\nworld"); + + const formatter: ScreenFormatter = .init(&t.screen, .plain); + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("hello\r\nworld", builder.writer.buffered()); +} + +test "Screen plain with selection" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + try s.nextSlice("line1\r\nline2\r\nline3"); + + var formatter: ScreenFormatter = .init(&t.screen, .plain); + formatter.content = .{ .selection = .init( + t.screen.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?, + t.screen.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?, + false, + ) }; + + try formatter.format(&builder.writer); + try testing.expectEqualStrings("line2", builder.writer.buffered()); +} + +test "Screen vt with cursor position" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Position cursor at a specific location + try s.nextSlice("hello\r\nworld"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.cursor = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify cursor positions match + try testing.expectEqual(t.screen.cursor.x, t2.screen.cursor.x); + try testing.expectEqual(t.screen.cursor.y, t2.screen.cursor.y); +} + +test "Screen vt with style" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set some style attributes + try s.nextSlice("\x1b[1;31mhello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.style = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify styles match + try testing.expect(t.screen.cursor.style.eql(t2.screen.cursor.style)); +} + +test "Screen vt with hyperlink" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set a hyperlink + try s.nextSlice("\x1b]8;;http://example.com\x1b\\hello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.hyperlink = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify hyperlinks match + const has_link1 = t.screen.cursor.hyperlink != null; + const has_link2 = t2.screen.cursor.hyperlink != null; + try testing.expectEqual(has_link1, has_link2); + + if (has_link1) { + const link1 = t.screen.cursor.hyperlink.?; + const link2 = t2.screen.cursor.hyperlink.?; + try testing.expectEqualStrings(link1.uri, link2.uri); + } +} + +test "Screen vt with protection" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Enable protection mode + try s.nextSlice("\x1b[1\"qhello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.protection = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify protection state matches + try testing.expectEqual(t.screen.cursor.protected, t2.screen.cursor.protected); +} + +test "Screen vt with kitty keyboard" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set kitty keyboard flags (disambiguate + report_events = 3) + try s.nextSlice("\x1b[=3;1uhello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.kitty_keyboard = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify kitty keyboard state matches + const flags1 = t.screen.kitty_keyboard.current().int(); + const flags2 = t2.screen.kitty_keyboard.current().int(); + try testing.expectEqual(flags1, flags2); +} + +test "Screen vt with charsets" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set G0 to DEC special and shift to G1 + try s.nextSlice("\x1b(0\x0ehello"); + + var formatter: ScreenFormatter = .init(&t.screen, .vt); + formatter.extra.charsets = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify charset state matches + try testing.expectEqual(t.screen.charset.gl, t2.screen.charset.gl); + try testing.expectEqual(t.screen.charset.gr, t2.screen.charset.gr); + try testing.expectEqual( + t.screen.charset.charsets.get(.G0), + t2.screen.charset.charsets.get(.G0), + ); +} + +test "Terminal vt with scrolling region" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set scrolling region: top=5, bottom=20 + try s.nextSlice("\x1b[6;21rhello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.scrolling_region = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify scrolling regions match + try testing.expectEqual(t.scrolling_region.top, t2.scrolling_region.top); + try testing.expectEqual(t.scrolling_region.bottom, t2.scrolling_region.bottom); + try testing.expectEqual(t.scrolling_region.left, t2.scrolling_region.left); + try testing.expectEqual(t.scrolling_region.right, t2.scrolling_region.right); +} + +test "Terminal vt with modes" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Enable some modes that differ from defaults + try s.nextSlice("\x1b[?2004h"); // Bracketed paste + try s.nextSlice("\x1b[?1000h"); // Mouse event normal + try s.nextSlice("\x1b[?7l"); // Disable wraparound (default is true) + try s.nextSlice("hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.modes = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify modes match + try testing.expectEqual(t.modes.get(.bracketed_paste), t2.modes.get(.bracketed_paste)); + try testing.expectEqual(t.modes.get(.mouse_event_normal), t2.modes.get(.mouse_event_normal)); + try testing.expectEqual(t.modes.get(.wraparound), t2.modes.get(.wraparound)); +} + +test "Terminal vt with tabstops" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Clear all tabs and set custom tabstops + try s.nextSlice("\x1b[3g"); // Clear all tabs + try s.nextSlice("\x1b[5G\x1bH"); // Set tab at column 5 + try s.nextSlice("\x1b[15G\x1bH"); // Set tab at column 15 + try s.nextSlice("\x1b[30G\x1bH"); // Set tab at column 30 + try s.nextSlice("hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.tabstops = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify tabstops match (columns are 0-indexed in the API) + try testing.expectEqual(t.tabstops.get(4), t2.tabstops.get(4)); + try testing.expectEqual(t.tabstops.get(14), t2.tabstops.get(14)); + try testing.expectEqual(t.tabstops.get(29), t2.tabstops.get(29)); + try testing.expect(t2.tabstops.get(4)); // Column 5 (1-indexed) + try testing.expect(t2.tabstops.get(14)); // Column 15 (1-indexed) + try testing.expect(t2.tabstops.get(29)); // Column 30 (1-indexed) + try testing.expect(!t2.tabstops.get(8)); // Not a tab +} + +test "Terminal vt with keyboard modes" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set modify other keys mode 2 + try s.nextSlice("\x1b[>4;2m"); + try s.nextSlice("hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.keyboard = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify keyboard mode matches + try testing.expectEqual(t.flags.modify_other_keys_2, t2.flags.modify_other_keys_2); + try testing.expect(t2.flags.modify_other_keys_2); +} + +test "Terminal vt with pwd" { + const testing = std.testing; + const alloc = testing.allocator; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + // Set pwd using OSC 7 + try s.nextSlice("\x1b]7;file://host/home/user\x1b\\hello"); + + var formatter: TerminalFormatter = .init(&t, .vt); + formatter.extra.pwd = true; + + try formatter.format(&builder.writer); + const output = builder.writer.buffered(); + + // Create a second terminal and apply the output + var t2 = try Terminal.init(alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t2.deinit(alloc); + + var s2 = t2.vtStream(); + defer s2.deinit(); + + try s2.nextSlice(output); + + // Verify pwd matches + try testing.expectEqualStrings(t.pwd.items, t2.pwd.items); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index c2d6e8cb4..bdcbfe77f 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -13,6 +13,7 @@ pub const osc = @import("osc.zig"); pub const point = @import("point.zig"); pub const color = @import("color.zig"); pub const device_status = @import("device_status.zig"); +pub const formatter = @import("formatter.zig"); pub const kitty = @import("kitty.zig"); pub const modes = @import("modes.zig"); pub const page = @import("page.zig"); diff --git a/src/terminal/page.zig b/src/terminal/page.zig index 331168a27..e38e96e92 100644 --- a/src/terminal/page.zig +++ b/src/terminal/page.zig @@ -11,7 +11,10 @@ const color = @import("color.zig"); const hyperlink = @import("hyperlink.zig"); const kitty = @import("kitty.zig"); const sgr = @import("sgr.zig"); -const style = @import("style.zig"); +const stylepkg = @import("style.zig"); +const Style = stylepkg.Style; +const StyleId = stylepkg.Id; +const StyleSet = stylepkg.Set; const size = @import("size.zig"); const getOffset = size.getOffset; const Offset = size.Offset; @@ -86,7 +89,7 @@ pub const Page = struct { assert(std.heap.page_size_min % @max( @alignOf(Row), @alignOf(Cell), - style.Set.base_align.toByteUnits(), + StyleSet.base_align.toByteUnits(), ) == 0); } @@ -124,7 +127,7 @@ pub const Page = struct { grapheme_map: GraphemeMap, /// The available set of styles in use on this page. - styles: style.Set, + styles: StyleSet, /// The structures used for tracking hyperlinks within the page. /// The map maps cell offsets to hyperlink IDs and the IDs are in @@ -236,7 +239,7 @@ pub const Page = struct { .rows = rows, .cells = cells, .dirty = buf.member(usize, l.dirty_start), - .styles = style.Set.init( + .styles = StyleSet.init( buf.add(l.styles_start), l.styles_layout, .{}, @@ -372,7 +375,7 @@ pub const Page = struct { const alloc = arena.allocator(); var graphemes_seen: usize = 0; - var styles_seen = std.AutoHashMap(style.Id, usize).init(alloc); + var styles_seen = std.AutoHashMap(StyleId, usize).init(alloc); defer styles_seen.deinit(); var hyperlinks_seen = std.AutoHashMap(hyperlink.Id, usize).init(alloc); defer hyperlinks_seen.deinit(); @@ -409,7 +412,7 @@ pub const Page = struct { } } - if (cell.style_id != style.default_id) { + if (cell.style_id != stylepkg.default_id) { // If a cell has a style, it must be present in the styles // set. Accessing it with `get` asserts that. _ = self.styles.get( @@ -767,7 +770,7 @@ pub const Page = struct { for (other_cells) |cell| { assert(!cell.hasGrapheme()); assert(!cell.hyperlink); - assert(cell.style_id == style.default_id); + assert(cell.style_id == stylepkg.default_id); } } @@ -782,7 +785,7 @@ pub const Page = struct { // hit an integrity check if we have to return an error because // the page can't fit the new memory. dst_cell.hyperlink = false; - dst_cell.style_id = style.default_id; + dst_cell.style_id = stylepkg.default_id; if (dst_cell.content_tag == .codepoint_grapheme) { dst_cell.content_tag = .codepoint; } @@ -791,7 +794,7 @@ pub const Page = struct { // To prevent integrity checks flipping. This will // get fixed up when we check the style id below. if (build_options.slow_runtime_safety) { - dst_cell.style_id = style.default_id; + dst_cell.style_id = stylepkg.default_id; } // Copy the grapheme codepoints @@ -867,7 +870,7 @@ pub const Page = struct { try self.setHyperlink(dst_row, dst_cell, dst_id); } - if (src_cell.style_id != style.default_id) style: { + if (src_cell.style_id != stylepkg.default_id) style: { dst_row.styled = true; if (other == self) { @@ -995,7 +998,7 @@ pub const Page = struct { // The destination row has styles if any of the cells are styled if (!dst_row.styled) dst_row.styled = styled: for (dst_cells) |c| { - if (c.style_id != style.default_id) break :styled true; + if (c.style_id != stylepkg.default_id) break :styled true; } else false; // Clear our source row now that the copy is complete. We can NOT @@ -1101,7 +1104,7 @@ pub const Page = struct { if (row.styled) { for (cells) |*cell| { - if (cell.style_id == style.default_id) continue; + if (cell.style_id == stylepkg.default_id) continue; self.styles.release(self.memory, cell.style_id); } @@ -1720,7 +1723,7 @@ pub const Page = struct { dirty_start: usize, dirty_size: usize, styles_start: usize, - styles_layout: style.Set.Layout, + styles_layout: StyleSet.Layout, grapheme_alloc_start: usize, grapheme_alloc_layout: GraphemeAlloc.Layout, grapheme_map_start: usize, @@ -1756,8 +1759,8 @@ pub const Page = struct { const dirty_start = alignForward(usize, cells_end, @alignOf(usize)); const dirty_end: usize = dirty_start + (dirty_usize_length * @sizeOf(usize)); - const styles_layout: style.Set.Layout = .init(cap.styles); - const styles_start = alignForward(usize, dirty_end, style.Set.base_align.toByteUnits()); + const styles_layout: StyleSet.Layout = .init(cap.styles); + const styles_start = alignForward(usize, dirty_end, StyleSet.base_align.toByteUnits()); const styles_end = styles_start + styles_layout.total_size; const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes); @@ -1886,7 +1889,7 @@ pub const Capacity = struct { const string_alloc_start = alignBackward(usize, hyperlink_set_start - layout.string_alloc_layout.total_size, StringAlloc.base_align.toByteUnits()); const grapheme_map_start = alignBackward(usize, string_alloc_start - layout.grapheme_map_layout.total_size, GraphemeMap.base_align.toByteUnits()); const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - layout.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align.toByteUnits()); - const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, style.Set.base_align.toByteUnits()); + const styles_start = alignBackward(usize, grapheme_alloc_start - layout.styles_layout.total_size, StyleSet.base_align.toByteUnits()); // The size per row is: // - The row metadata itself @@ -2014,7 +2017,7 @@ pub const Cell = packed struct(u64) { /// The style ID to use for this cell within the style map. Zero /// is always the default style so no lookup is required. - style_id: style.Id = 0, + style_id: StyleId = 0, /// The wide property of this cell, for wide characters. Characters in /// a terminal grid can only be 1 or 2 cells wide. A wide character @@ -2123,7 +2126,7 @@ pub const Cell = packed struct(u64) { } pub fn hasStyling(self: Cell) bool { - return self.style_id != style.default_id; + return self.style_id != stylepkg.default_id; } /// Returns true if the cell has no text or styling. diff --git a/src/terminal/stream.zig b/src/terminal/stream.zig index a38e5ce9e..23211fa80 100644 --- a/src/terminal/stream.zig +++ b/src/terminal/stream.zig @@ -1992,8 +1992,6 @@ pub fn Stream(comptime Handler: type) type { log.warn("invalid OSC, should never happen", .{}); }, } - - log.warn("unimplemented OSC command: {s}", .{@tagName(cmd)}); } inline fn configureCharset( @@ -2255,8 +2253,8 @@ test "stream: print" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .print => self.c = value.cp, @@ -2276,8 +2274,8 @@ test "simd: print invalid utf-8" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .print => self.c = value.cp, @@ -2297,8 +2295,8 @@ test "simd: complete incomplete utf-8" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .print => self.c = value.cp, @@ -2322,8 +2320,8 @@ test "stream: cursor right (CUF)" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .cursor_right => self.amount = value.value, @@ -2354,8 +2352,8 @@ test "stream: dec set mode (SM) and reset mode (RM)" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .set_mode => self.mode = value.mode, @@ -2383,8 +2381,8 @@ test "stream: ansi set mode (SM) and reset mode (RM)" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { switch (action) { .set_mode => self.mode = value.mode, @@ -2417,11 +2415,10 @@ test "stream: ansi set mode (SM) and reset mode (RM) with unknown value" { pub fn vt( self: *@This(), - comptime action: anytype, - value: anytype, + comptime action: Action.Tag, + value: Action.Value(action), ) !void { _ = self; - _ = action; _ = value; } }; diff --git a/src/terminal/stream_readonly.zig b/src/terminal/stream_readonly.zig index 4d284d990..86a525284 100644 --- a/src/terminal/stream_readonly.zig +++ b/src/terminal/stream_readonly.zig @@ -160,6 +160,7 @@ pub const Handler = struct { .end_of_input => self.terminal.markSemanticPrompt(.command), .end_of_command => self.terminal.screen.cursor.page_row.semantic_prompt = .input, .mouse_shape => self.terminal.mouse_shape = value, + .color_operation => try self.colorOperation(value.op, &value.requests), // No supported DCS commands have any terminal-modifying effects, // but they may in the future. For now we just ignore it. @@ -186,7 +187,6 @@ pub const Handler = struct { .device_status, .kitty_keyboard_query, .kitty_color_report, - .color_operation, .window_title, .report_pwd, .show_desktop_notification, @@ -291,6 +291,56 @@ pub const Handler = struct { else => {}, } } + + fn colorOperation( + self: *Handler, + op: @import("osc/color.zig").Operation, + requests: *const @import("osc/color.zig").List, + ) !void { + _ = op; + if (requests.count() == 0) return; + + var it = requests.constIterator(0); + while (it.next()) |req| { + switch (req.*) { + .set => |set| { + switch (set.target) { + .palette => |i| { + self.terminal.color_palette.colors[i] = set.color; + self.terminal.color_palette.mask.set(i); + }, + .dynamic, + .special, + => {}, + } + }, + + .reset => |target| switch (target) { + .palette => |i| { + const mask = &self.terminal.color_palette.mask; + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + mask.unset(i); + }, + .dynamic, + .special, + => {}, + }, + + .reset_palette => { + const mask = &self.terminal.color_palette.mask; + var mask_iterator = mask.iterator(.{}); + while (mask_iterator.next()) |i| { + self.terminal.color_palette.colors[i] = self.terminal.default_palette[i]; + } + mask.* = .initEmpty(); + }, + + .query, + .reset_special, + => {}, + } + } + } }; test "basic print" { @@ -540,3 +590,51 @@ test "ignores query actions" { defer testing.allocator.free(str); try testing.expectEqualStrings("Test", str); } + +test "OSC 4 set and reset palette" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Save default color + const default_color_0 = t.default_palette[0]; + + // Set color 0 to red + try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try testing.expectEqual(@as(u8, 0xff), t.color_palette.colors[0].r); + try testing.expectEqual(@as(u8, 0x00), t.color_palette.colors[0].g); + try testing.expectEqual(@as(u8, 0x00), t.color_palette.colors[0].b); + try testing.expect(t.color_palette.mask.isSet(0)); + + // Reset color 0 + try s.nextSlice("\x1b]104;0\x1b\\"); + try testing.expectEqual(default_color_0, t.color_palette.colors[0]); + try testing.expect(!t.color_palette.mask.isSet(0)); +} + +test "OSC 104 reset all palette colors" { + var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 }); + defer t.deinit(testing.allocator); + + var s: Stream = .initAlloc(testing.allocator, .init(&t)); + defer s.deinit(); + + // Set multiple colors + try s.nextSlice("\x1b]4;0;rgb:ff/00/00\x1b\\"); + try s.nextSlice("\x1b]4;1;rgb:00/ff/00\x1b\\"); + try s.nextSlice("\x1b]4;2;rgb:00/00/ff\x1b\\"); + try testing.expect(t.color_palette.mask.isSet(0)); + try testing.expect(t.color_palette.mask.isSet(1)); + try testing.expect(t.color_palette.mask.isSet(2)); + + // Reset all palette colors + try s.nextSlice("\x1b]104\x1b\\"); + try testing.expectEqual(t.default_palette[0], t.color_palette.colors[0]); + try testing.expectEqual(t.default_palette[1], t.color_palette.colors[1]); + try testing.expectEqual(t.default_palette[2], t.color_palette.colors[2]); + try testing.expect(!t.color_palette.mask.isSet(0)); + try testing.expect(!t.color_palette.mask.isSet(1)); + try testing.expect(!t.color_palette.mask.isSet(2)); +} diff --git a/src/terminal/style.zig b/src/terminal/style.zig index eac577a53..fea4666b8 100644 --- a/src/terminal/style.zig +++ b/src/terminal/style.zig @@ -293,6 +293,74 @@ pub const Style = struct { _ = try writer.write(" }"); } + /// Returns a formatter that renders this style as VT sequences, + /// to be used with `{f}`. This always resets the style first `\x1b[0m` + /// since a style is meant to be fully self-contained. + /// + /// For individual styles, this always emits multiple SGR sequences + /// (i.e. an individual `\x1b[m` for each attribute) rather than + /// trying to combine them into a single sequence. We do this because + /// terminals have varying levels of support for combined sequences + /// especially with mixed separators (e.g. `:` vs `;`). + pub fn formatterVt(self: *const Style) VTFormatter { + return .{ .style = self }; + } + + const VTFormatter = struct { + style: *const Style, + + pub fn format( + self: VTFormatter, + writer: *std.Io.Writer, + ) !void { + // Always reset the style. Styles are fully self-contained. + // Even if this style is empty, then that means we want to go + // back to the default. + try writer.writeAll("\x1b[0m"); + + // Our flags + if (self.style.flags.bold) try writer.writeAll("\x1b[1m"); + if (self.style.flags.faint) try writer.writeAll("\x1b[2m"); + if (self.style.flags.italic) try writer.writeAll("\x1b[3m"); + if (self.style.flags.blink) try writer.writeAll("\x1b[5m"); + if (self.style.flags.inverse) try writer.writeAll("\x1b[7m"); + if (self.style.flags.invisible) try writer.writeAll("\x1b[8m"); + if (self.style.flags.strikethrough) try writer.writeAll("\x1b[9m"); + if (self.style.flags.overline) try writer.writeAll("\x1b[53m"); + switch (self.style.flags.underline) { + .none => {}, + .single => try writer.writeAll("\x1b[4m"), + .double => try writer.writeAll("\x1b[4:2m"), + .curly => try writer.writeAll("\x1b[4:3m"), + .dotted => try writer.writeAll("\x1b[4:4m"), + .dashed => try writer.writeAll("\x1b[4:5m"), + } + + // Various RGB colors. + try formatColor(writer, 38, self.style.fg_color); + try formatColor(writer, 48, self.style.bg_color); + try formatColor(writer, 58, self.style.underline_color); + } + + fn formatColor( + writer: *std.Io.Writer, + prefix: u8, + value: Color, + ) !void { + switch (value) { + .none => {}, + .palette => |idx| try writer.print( + "\x1b[{d};5;{}m", + .{ prefix, idx }, + ), + .rgb => |rgb| try writer.print( + "\x1b[{d};2;{};{};{}m", + .{ prefix, rgb.r, rgb.g, rgb.b }, + ), + } + } + }; + /// `PackedStyle` represents the same data as `Style` but without padding, /// which is necessary for hashing via re-interpretation of the underlying /// bytes. @@ -394,6 +462,316 @@ pub const Set = RefCountedSet( }, ); +test "Style VT formatting empty" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{}; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m", builder.writer.buffered()); +} + +test "Style VT formatting bold" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .bold = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[1m", builder.writer.buffered()); +} + +test "Style VT formatting faint" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .faint = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[2m", builder.writer.buffered()); +} + +test "Style VT formatting italic" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .italic = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[3m", builder.writer.buffered()); +} + +test "Style VT formatting blink" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .blink = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[5m", builder.writer.buffered()); +} + +test "Style VT formatting inverse" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .inverse = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[7m", builder.writer.buffered()); +} + +test "Style VT formatting invisible" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .invisible = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[8m", builder.writer.buffered()); +} + +test "Style VT formatting strikethrough" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .strikethrough = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[9m", builder.writer.buffered()); +} + +test "Style VT formatting overline" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .overline = true } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[53m", builder.writer.buffered()); +} + +test "Style VT formatting underline single" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .single } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4m", builder.writer.buffered()); +} + +test "Style VT formatting underline double" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .double } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:2m", builder.writer.buffered()); +} + +test "Style VT formatting underline curly" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .curly } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:3m", builder.writer.buffered()); +} + +test "Style VT formatting underline dotted" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .dotted } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:4m", builder.writer.buffered()); +} + +test "Style VT formatting underline dashed" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .underline = .dashed } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[4:5m", builder.writer.buffered()); +} + +test "Style VT formatting fg palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .palette = 42 } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[38;5;42m", builder.writer.buffered()); +} + +test "Style VT formatting fg rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .fg_color = .{ .rgb = .{ .r = 255, .g = 128, .b = 64 } } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[38;2;255;128;64m", builder.writer.buffered()); +} + +test "Style VT formatting bg palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .palette = 7 } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[48;5;7m", builder.writer.buffered()); +} + +test "Style VT formatting bg rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .bg_color = .{ .rgb = .{ .r = 32, .g = 64, .b = 96 } } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[48;2;32;64;96m", builder.writer.buffered()); +} + +test "Style VT formatting underline_color palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .underline_color = .{ .palette = 15 } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[58;5;15m", builder.writer.buffered()); +} + +test "Style VT formatting underline_color rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .underline_color = .{ .rgb = .{ .r = 200, .g = 100, .b = 50 } } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[58;2;200;100;50m", builder.writer.buffered()); +} + +test "Style VT formatting multiple flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ .bold = true, .italic = true, .underline = .single } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings("\x1b[0m\x1b[1m\x1b[3m\x1b[4m", builder.writer.buffered()); +} + +test "Style VT formatting all flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ .flags = .{ + .bold = true, + .faint = true, + .italic = true, + .blink = true, + .inverse = true, + .invisible = true, + .strikethrough = true, + .overline = true, + .underline = .curly, + } }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[1m\x1b[2m\x1b[3m\x1b[5m\x1b[7m\x1b[8m\x1b[9m\x1b[53m\x1b[4:3m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting combined colors and flags" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .rgb = .{ .r = 255, .g = 0, .b = 0 } }, + .bg_color = .{ .palette = 8 }, + .underline_color = .{ .rgb = .{ .r = 0, .g = 255, .b = 0 } }, + .flags = .{ .bold = true, .italic = true, .underline = .double }, + }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[1m\x1b[3m\x1b[4:2m\x1b[38;2;255;0;0m\x1b[48;5;8m\x1b[58;2;0;255;0m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting all colors rgb" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .rgb = .{ .r = 10, .g = 20, .b = 30 } }, + .bg_color = .{ .rgb = .{ .r = 40, .g = 50, .b = 60 } }, + .underline_color = .{ .rgb = .{ .r = 70, .g = 80, .b = 90 } }, + }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[38;2;10;20;30m\x1b[48;2;40;50;60m\x1b[58;2;70;80;90m", + builder.writer.buffered(), + ); +} + +test "Style VT formatting all colors palette" { + const testing = std.testing; + const alloc = testing.allocator; + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var style: Style = .{ + .fg_color = .{ .palette = 1 }, + .bg_color = .{ .palette = 2 }, + .underline_color = .{ .palette = 3 }, + }; + try builder.writer.print("{f}", .{style.formatterVt()}); + try testing.expectEqualStrings( + "\x1b[0m\x1b[38;5;1m\x1b[48;5;2m\x1b[58;5;3m", + builder.writer.buffered(), + ); +} + test "Set basic usage" { const testing = std.testing; const alloc = testing.allocator;