aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/formatter.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/formatter.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/formatter.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/formatter.zig6279
1 files changed, 6279 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/formatter.zig b/vendor/libghostty-vt/src/terminal/formatter.zig
new file mode 100644
index 00000000..a375b4dd
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/formatter.zig
@@ -0,0 +1,6279 @@
+const std = @import("std");
+const assert = @import("../quirks.zig").inlineAssert;
+const lib = @import("lib.zig");
+const Allocator = std.mem.Allocator;
+const color = @import("color.zig");
+const size = @import("size.zig");
+const charsets = @import("charsets.zig");
+const hyperlink = @import("hyperlink.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 Coordinate = @import("point.zig").Coordinate;
+const Page = @import("page.zig").Page;
+const PageList = @import("PageList.zig");
+const Pin = PageList.Pin;
+const Row = @import("page.zig").Row;
+const Selection = @import("Selection.zig");
+const Style = @import("style.zig").Style;
+
+/// Formats available.
+pub const Format = lib.Enum(lib.target, &.{
+ // 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.
+ //
+ // For VT, newlines will be emitted as `\r\n` so that the cursor properly
+ // moves back to the beginning prior emitting follow-up lines.
+ "vt",
+
+ // HTML output.
+ //
+ // This will emit inline styles for as much styling as possible,
+ // in the interest of simplicity and ease of editing. This isn't meant
+ // to build the most beautiful or efficient HTML, but rather to be
+ // stylistically correct.
+ //
+ // For colors, RGB values are emitted as inline CSS (#RRGGBB) while palette
+ // indices use CSS variables (var(--vt-palette-N)). The palette colors are
+ // emitted by TerminalFormatter.Extra.palette as a <style> block if you
+ // want to also include that. But if you only format a screen or lower,
+ // the formatter doesn't have access to the current palette to render it.
+ //
+ // Newlines are emitted as actual '\n' characters. Consumers should use
+ // CSS white-space: pre or pre-wrap to preserve spacing and alignment.
+ "html",
+});
+
+/// Returns true if the format emits styled output (not plaintext).
+pub fn formatStyled(fmt: Format) bool {
+ return switch (fmt) {
+ .plain => false,
+ .html, .vt => true,
+ };
+}
+
+pub const CodepointMap = struct {
+ /// Unicode codepoint range to replace.
+ /// Asserts: range[0] <= range[1]
+ range: [2]u21,
+
+ /// Replacement value for this range.
+ replacement: Replacement,
+
+ pub const Replacement = union(enum) {
+ /// A single replacement codepoint.
+ codepoint: u21,
+
+ /// A UTF-8 encoded string to replace with. Asserts the
+ /// UTF-8 encoding (must be valid).
+ string: []const u8,
+ };
+};
+
+/// 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,
+
+ /// Trim trailing whitespace on lines with other text. Trailing blank
+ /// lines are always trimmed. This only affects trailing whitespace
+ /// on rows that have at least one other cell with text. Whitespace
+ /// is currently only space characters (0x20).
+ trim: bool = true,
+
+ /// Replace matching Unicode codepoints with some other values.
+ /// This will use the last matching range found in the list.
+ codepoint_map: ?std.MultiArrayList(CodepointMap) = .{},
+
+ /// Set a background and foreground color to use for the "screen".
+ /// For styled formats, this will emit the proper sequences or styles.
+ background: ?color.RGB = null,
+ foreground: ?color.RGB = null,
+
+ /// If set, then styled formats in `emit` will use this palette to
+ /// emit colors directly as RGB. If this is null, styled formats will
+ /// still work but will use deferred palette styling (e.g. CSS variables
+ /// for HTML or the actual palette indexes for VT).
+ palette: ?*const color.Palette = null,
+
+ pub const plain: Options = .{ .emit = .plain };
+ pub const vt: Options = .{ .emit = .vt };
+ pub const html: Options = .{ .emit = .html };
+};
+
+/// Maps byte positions in formatted output to PageList pins.
+///
+/// Used by formatters that operate on PageLists to track the source position
+/// of each byte written. The caller is responsible for freeing the map.
+pub const PinMap = struct {
+ alloc: Allocator,
+ map: *std.ArrayList(Pin),
+};
+
+/// 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,
+
+ /// If non-null, then `map` will contain the Pin of every byte
+ /// byte written to the writer offset by the byte index. It is the
+ /// caller's responsibility to free the map.
+ ///
+ /// Note that some emitted bytes may not correspond to any Pin, such as
+ /// the extra data around terminal state (palette, modes, etc.). For these,
+ /// we'll map it to the most previous pin so there is some continuity but
+ /// its an arbitrary choice.
+ ///
+ /// Warning: there is a significant performance hit to track this
+ pin_map: ?PinMap,
+
+ 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,
+ .pin_map = null,
+ };
+ }
+
+ pub fn format(
+ self: TerminalFormatter,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!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.extra.palette) palette: {
+ switch (self.opts.emit) {
+ .plain => break :palette,
+
+ .vt => {
+ for (self.terminal.colors.palette.current, 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 },
+ );
+ }
+ },
+
+ // For HTML, we emit CSS to setup our palette variables.
+ .html => {
+ try writer.writeAll("<style>:root{");
+ for (self.terminal.colors.palette.current, 0..) |rgb, i| {
+ try writer.print(
+ "--vt-palette-{d}: #{x:0>2}{x:0>2}{x:0>2};",
+ .{ i, rgb.r, rgb.g, rgb.b },
+ );
+ }
+ try writer.writeAll("}</style>");
+ },
+ }
+
+ // If we have a pin_map, add the bytes we wrote to map.
+ if (self.pin_map) |*m| {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ var extra_formatter: TerminalFormatter = self;
+ extra_formatter.content = .none;
+ extra_formatter.pin_map = null;
+ extra_formatter.extra = .none;
+ extra_formatter.extra.palette = true;
+ try extra_formatter.format(&discarding.writer);
+
+ // Map all those bytes to the same pin. Use the top left to ensure
+ // the node pointer is always properly initialized.
+ m.map.appendNTimes(
+ m.alloc,
+ self.terminal.screens.active.pages.getTopLeft(.screen),
+ std.math.cast(usize, discarding.count) orelse return error.WriteFailed,
+ ) catch return error.WriteFailed;
+ }
+ }
+
+ // 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 });
+ }
+ }
+
+ // If we have a pin_map, add the bytes we wrote to map.
+ if (self.pin_map) |*m| {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ var extra_formatter: TerminalFormatter = self;
+ extra_formatter.content = .none;
+ extra_formatter.pin_map = null;
+ extra_formatter.extra = .none;
+ extra_formatter.extra.modes = true;
+ try extra_formatter.format(&discarding.writer);
+
+ // Map all those bytes to the same pin. Use the top left to ensure
+ // the node pointer is always properly initialized.
+ m.map.appendNTimes(
+ m.alloc,
+ self.terminal.screens.active.pages.getTopLeft(.screen),
+ std.math.cast(usize, discarding.count) orelse return error.WriteFailed,
+ ) catch return error.WriteFailed;
+ }
+ }
+
+ var screen_formatter: ScreenFormatter = .init(self.terminal.screens.active, self.opts);
+ screen_formatter.content = self.content;
+ screen_formatter.extra = self.extra.screen;
+ screen_formatter.pin_map = self.pin_map;
+ 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});
+ }
+
+ // If we have a pin_map, add the bytes we wrote to map.
+ if (self.pin_map) |*m| {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ var extra_formatter: TerminalFormatter = self;
+ extra_formatter.content = .none;
+ extra_formatter.pin_map = null;
+ extra_formatter.extra = .none;
+ extra_formatter.extra.scrolling_region = self.extra.scrolling_region;
+ extra_formatter.extra.tabstops = self.extra.tabstops;
+ extra_formatter.extra.keyboard = self.extra.keyboard;
+ extra_formatter.extra.pwd = self.extra.pwd;
+ try extra_formatter.format(&discarding.writer);
+
+ m.map.appendNTimes(
+ m.alloc,
+ if (m.map.items.len > 0) pin: {
+ const last = m.map.items[m.map.items.len - 1];
+ break :pin .{
+ .node = last.node,
+ .x = last.x,
+ .y = last.y,
+ };
+ } else self.terminal.screens.active.pages.getTopLeft(.screen),
+ std.math.cast(usize, discarding.count) orelse return error.WriteFailed,
+ ) catch return error.WriteFailed;
+ }
+ }
+ }
+};
+
+/// 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,
+
+ /// If non-null, then `map` will contain the Pin of every byte
+ /// byte written to the writer offset by the byte index. It is the
+ /// caller's responsibility to free the map.
+ ///
+ /// Note that some emitted bytes may not correspond to any Pin, such as
+ /// the extra data around screen state. For these, we'll map it to the
+ /// most previous pin so there is some continuity but its an arbitrary
+ /// choice.
+ ///
+ /// Warning: there is a significant performance hit to track this
+ pin_map: ?PinMap,
+
+ 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.
+ /// The selection is inclusive on both ends.
+ 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,
+ };
+
+ fn isSet(self: Extra) bool {
+ const Int = @typeInfo(Extra).@"struct".backing_integer.?;
+ const v: Int = @bitCast(self);
+ return v != 0;
+ }
+ };
+
+ pub fn init(
+ screen: *const Screen,
+ opts: Options,
+ ) ScreenFormatter {
+ return .{
+ .screen = screen,
+ .opts = opts,
+ .content = .{ .selection = null },
+ .extra = .none,
+ .pin_map = null,
+ };
+ }
+
+ pub fn format(
+ self: ScreenFormatter,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!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);
+ list_formatter.pin_map = self.pin_map;
+ if (selection_) |sel| {
+ list_formatter.top_left = sel.topLeft(self.screen);
+ list_formatter.bottom_right = sel.bottomRight(self.screen);
+ list_formatter.rectangle = sel.rectangle;
+ }
+ 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 => if (!self.extra.isSet()) return,
+
+ // HTML doesn't preserve any screen state because it has
+ // nothing to do with rendering.
+ .html => return,
+ }
+
+ // 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 });
+ }
+
+ // If we have a pin_map, we need to count how many bytes the extras
+ // will emit so we can map them all to the same pin. We do this by
+ // formatting to a discarding writer with content=none.
+ if (self.pin_map) |*m| {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ var extra_formatter: ScreenFormatter = self;
+ extra_formatter.content = .none;
+ extra_formatter.pin_map = null;
+ try extra_formatter.format(&discarding.writer);
+
+ // Map all those bytes to the same pin. Use the first page node
+ // to ensure the node pointer is always properly initialized.
+ m.map.appendNTimes(
+ m.alloc,
+ if (m.map.items.len > 0) pin: {
+ // There is a weird Zig miscompilation here on 0.15.2.
+ // If I return the m.map.items value directly then we
+ // get undefined memory (even though we're copying a
+ // Pin struct). If we duplicate here like this we do
+ // not.
+ const last = m.map.items[m.map.items.len - 1];
+ break :pin .{
+ .node = last.node,
+ .x = last.x,
+ .y = last.y,
+ };
+ } else self.screen.pages.getTopLeft(.screen),
+ std.math.cast(usize, discarding.count) orelse return error.WriteFailed,
+ ) catch return error.WriteFailed;
+ }
+ }
+};
+
+/// 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,
+
+ /// If true, the boundaries define a rectangle selection where start_x
+ /// and end_x apply to every row, not just the first and last.
+ rectangle: bool,
+
+ /// If non-null, then `map` will contain the Pin of every byte
+ /// byte written to the writer offset by the byte index. It is the
+ /// caller's responsibility to free the map.
+ ///
+ /// Warning: there is a significant performance hit to track this
+ pin_map: ?PinMap,
+
+ pub fn init(
+ list: *const PageList,
+ opts: Options,
+ ) PageListFormatter {
+ return PageListFormatter{
+ .list = list,
+ .opts = opts,
+ .top_left = null,
+ .bottom_right = null,
+ .rectangle = false,
+ .pin_map = null,
+ };
+ }
+
+ pub fn format(
+ self: PageListFormatter,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!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).?;
+
+ // If we keep track of pins, we'll need this.
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer if (self.pin_map) |*m| point_map.deinit(m.alloc);
+
+ var page_state: ?PageFormatter.TrailingState = null;
+ var iter = tl.pageIterator(.right_down, br);
+ while (iter.next()) |chunk| {
+ assert(chunk.start < chunk.end);
+ assert(chunk.end > 0);
+
+ var formatter: PageFormatter = .init(&chunk.node.data, self.opts);
+ formatter.start_y = chunk.start;
+ formatter.end_y = chunk.end - 1;
+ formatter.trailing_state = page_state;
+ formatter.rectangle = self.rectangle;
+
+ // For rectangle selection, apply start_x and end_x to all chunks
+ if (self.rectangle) {
+ formatter.start_x = tl.x;
+ formatter.end_x = br.x;
+ } else {
+ // Otherwise only on the first/last, respectively.
+ if (chunk.node == tl.node) formatter.start_x = tl.x;
+ if (chunk.node == br.node) formatter.end_x = br.x;
+ }
+
+ // If we're tracking pins, then we setup a point map for the
+ // page formatter (cause it can't track pins). And then we convert
+ // this to pins later.
+ if (self.pin_map) |*m| {
+ point_map.clearRetainingCapacity();
+ formatter.point_map = .{ .alloc = m.alloc, .map = &point_map };
+ }
+
+ page_state = try formatter.formatWithState(writer);
+
+ // If we're tracking pins then grab our points and write them
+ // to our pin map.
+ if (self.pin_map) |*m| {
+ for (point_map.items) |coord| {
+ m.map.append(m.alloc, .{
+ .node = chunk.node,
+ .x = coord.x,
+ .y = @intCast(coord.y),
+ }) catch return error.WriteFailed;
+ }
+ }
+ }
+ }
+};
+
+/// 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 and end are both inclusive, so equal values will still
+ /// return a non-empty result (i.e. a single cell or row).
+ ///
+ /// 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.
+ ///
+ /// If start X falls on the second column of a wide character, then
+ /// the entire character will be included (as if you specified the
+ /// previous column).
+ start_x: size.CellCountInt,
+ start_y: size.CellCountInt,
+ end_x: ?size.CellCountInt,
+ end_y: ?size.CellCountInt,
+
+ /// If true, the start x/y and end x/y define a rectangle selection.
+ /// In this case, the boundaries will apply to every row, not just
+ /// the first and last.
+ rectangle: bool,
+
+ /// If non-null, then `map` will contain the x/y coordinate of every
+ /// byte written to the writer offset by the byte index. It is the
+ /// caller's responsibility to free the map.
+ ///
+ /// The x/y coordinate will be the coordinates within the page.
+ ///
+ /// Warning: there is a significant performance hit to track this
+ point_map: ?struct {
+ alloc: Allocator,
+ map: *std.ArrayList(Coordinate),
+ },
+
+ /// 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 .{
+ .page = page,
+ .opts = opts,
+ .start_x = 0,
+ .start_y = 0,
+ .end_x = null,
+ .end_y = null,
+ .rectangle = false,
+ .point_map = null,
+ .trailing_state = null,
+ };
+ }
+
+ pub fn format(
+ self: PageFormatter,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!void {
+ _ = try self.formatWithState(writer);
+ }
+
+ pub fn formatWithState(
+ self: PageFormatter,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!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 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 - 1;
+ var end_x = @min(end_x_unclamped, self.page.size.cols - 1);
+
+ // 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 - 1;
+ if (start_y > end_y_unclamped) return .{ .rows = blank_rows, .cells = blank_cells };
+ var end_y = @min(end_y_unclamped, self.page.size.rows - 1);
+
+ // Edge case: if our end x/y falls on a spacer head AND we're unwrapping,
+ // then we move the x/y to the start of the next row (if available).
+ if (self.opts.unwrap and !self.rectangle) {
+ const final_row = self.page.getRow(end_y);
+ const cells = self.page.getCells(final_row);
+ switch (cells[end_x].wide) {
+ .spacer_head => {
+ // Move to next row if available
+ //
+ // TODO: if unavailable, we should add to our trailing state
+ //
+ // so the pagelist formatter can be aware and maybe add
+ // another page
+ if (end_y < self.page.size.rows - 1) {
+ end_y += 1;
+ end_x = 0;
+ }
+ },
+
+ else => {},
+ }
+ }
+
+ // If we only have a single row, validate that start_x <= end_x
+ if (start_y == end_y and start_x > end_x) {
+ return .{ .rows = blank_rows, .cells = blank_cells };
+ }
+
+ // Wrap HTML output in monospace font styling
+ switch (self.opts.emit) {
+ .plain => {},
+
+ .html => {
+ // Setup our div. We use a buffer here that should always
+ // fit the stuff we need, in order to make counting bytes easier.
+ var buf: [1024]u8 = undefined;
+ var stream = std.io.fixedBufferStream(&buf);
+ const buf_writer = stream.writer();
+
+ // Monospace and whitespace preserving
+ buf_writer.writeAll("<div style=\"font-family: monospace; white-space: pre;") catch return error.WriteFailed;
+
+ // Background/foreground colors
+ if (self.opts.background) |bg| buf_writer.print(
+ "background-color: #{x:0>2}{x:0>2}{x:0>2};",
+ .{ bg.r, bg.g, bg.b },
+ ) catch return error.WriteFailed;
+ if (self.opts.foreground) |fg| buf_writer.print(
+ "color: #{x:0>2}{x:0>2}{x:0>2};",
+ .{ fg.r, fg.g, fg.b },
+ ) catch return error.WriteFailed;
+
+ buf_writer.writeAll("\">") catch return error.WriteFailed;
+
+ const header = stream.getWritten();
+ try writer.writeAll(header);
+ if (self.point_map) |*map| map.map.appendNTimes(
+ map.alloc,
+ .{ .x = 0, .y = 0 },
+ header.len,
+ ) catch return error.WriteFailed;
+ },
+
+ .vt => {
+ // OSC 10 sets foreground color, OSC 11 sets background color
+ var buf: [512]u8 = undefined;
+ var stream = std.io.fixedBufferStream(&buf);
+ const buf_writer = stream.writer();
+ if (self.opts.foreground) |fg| {
+ buf_writer.print(
+ "\x1b]10;rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\",
+ .{ fg.r, fg.g, fg.b },
+ ) catch return error.WriteFailed;
+ }
+ if (self.opts.background) |bg| {
+ buf_writer.print(
+ "\x1b]11;rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\",
+ .{ bg.r, bg.g, bg.b },
+ ) catch return error.WriteFailed;
+ }
+
+ const header = stream.getWritten();
+ try writer.writeAll(header);
+ if (self.point_map) |*map| map.map.appendNTimes(
+ map.alloc,
+ .{ .x = 0, .y = 0 },
+ header.len,
+ ) catch return error.WriteFailed;
+ },
+ }
+
+ // Our style for non-plain formats
+ var style: Style = .{};
+
+ // Track hyperlink state for HTML output. We need to close </a> tags
+ // when the hyperlink changes or ends.
+ var current_hyperlink_id: ?hyperlink.Id = null;
+
+ for (start_y..end_y + 1) |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 cells_subset, const row_start_x = cells_subset: {
+ // The end is always straightforward
+ const row_end_x: size.CellCountInt = if (self.rectangle or y == end_y)
+ end_x + 1
+ else
+ self.page.size.cols;
+
+ // The first we have to check if our start X falls on the
+ // tail of a wide character.
+ const row_start_x: size.CellCountInt = if (start_x > 0 and
+ (self.rectangle or y == start_y))
+ start_x: {
+ break :start_x switch (cells[start_x].wide) {
+ // Include the prior cell to get the full wide char
+ .spacer_tail => start_x - 1,
+
+ // If we're a spacer head on our first row then we
+ // skip this whole row.
+ .spacer_head => continue,
+
+ .narrow, .wide => start_x,
+ };
+ } else 0;
+
+ const subset = cells[row_start_x..row_end_x];
+ break :cells_subset .{ subset, row_start_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;
+ }
+
+ if (blank_rows > 0) {
+ // Reset style before emitting newlines to prevent background
+ // colors from bleeding into the next line's leading cells.
+ if (!style.default()) {
+ try self.formatStyleClose(writer);
+ style = .{};
+ }
+
+ const sequence: []const u8 = switch (self.opts.emit) {
+ // Plaintext just uses standard newlines because newlines
+ // on their own usually move the cursor back in anywhere
+ // you type plaintext.
+ .plain => "\n",
+
+ // VT uses \r\n because in a raw pty, \n alone doesn't
+ // guarantee moving the cursor back to column 0. \r
+ // makes it work for sure.
+ .vt => "\r\n",
+
+ // HTML uses just \n because HTML rendering will move
+ // the cursor back.
+ .html => "\n",
+ };
+
+ for (0..blank_rows) |_| try writer.writeAll(sequence);
+
+ // \r and \n map to the row that ends with this newline.
+ // If we're continuing (trailing state) then this will be
+ // in a prior page, so we just map to the first row of this
+ // page.
+ if (self.point_map) |*map| {
+ const start: Coordinate = if (map.map.items.len > 0)
+ map.map.items[map.map.items.len - 1]
+ else
+ .{ .x = 0, .y = 0 };
+
+ // The first one inherits the x value.
+ map.map.appendNTimes(
+ map.alloc,
+ .{ .x = start.x, .y = start.y },
+ sequence.len,
+ ) catch return error.WriteFailed;
+
+ // All others have x = 0 since they reference their prior
+ // blank line.
+ for (1..blank_rows) |y_offset_usize| {
+ const y_offset: size.CellCountInt = @intCast(y_offset_usize);
+ map.map.appendNTimes(
+ map.alloc,
+ .{ .x = 0, .y = start.y + y_offset },
+ sequence.len,
+ ) catch return error.WriteFailed;
+ }
+ }
+
+ 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, row_start_x..) |*cell, x_usize| {
+ const x: size.CellCountInt = @intCast(x_usize);
+
+ // 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.
+ blank: {
+ // If we're emitting styled output (not plaintext) and
+ // the cell has some kind of styling or is not empty
+ // then this isn't blank.
+ if (formatStyled(self.opts.emit) and
+ (!cell.isEmpty() or cell.hasStyling())) break :blank;
+
+ // Cells with no text are blank
+ if (!cell.hasText()) {
+ blank_cells += 1;
+ continue;
+ }
+
+ // Trailing spaces are blank. We know it is trailing
+ // because if we get a non-empty cell later we'll
+ // fill the blanks.
+ if (cell.codepoint() == ' ' and self.opts.trim) {
+ 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);
+
+ if (self.point_map) |*map| {
+ // Map each blank cell to its coordinate. Blank cells can span
+ // multiple rows if they carry over from wrap continuation.
+ var remaining_blanks = blank_cells;
+ var blank_x = x;
+ var blank_y = y;
+ while (remaining_blanks > 0) : (remaining_blanks -= 1) {
+ if (blank_x > 0) {
+ // We have space in this row
+ blank_x -= 1;
+ } else if (blank_y > 0) {
+ // Wrap to previous row
+ blank_y -= 1;
+ blank_x = self.page.size.cols - 1;
+ } else {
+ // Can't go back further, just use (0, 0)
+ blank_x = 0;
+ blank_y = 0;
+ }
+
+ map.map.append(
+ map.alloc,
+ .{ .x = blank_x, .y = blank_y },
+ ) catch return error.WriteFailed;
+ }
+ }
+
+ blank_cells = 0;
+ }
+
+ style: {
+ // If we aren't emitting styled output then we don't
+ // have to worry about styles.
+ if (!formatStyled(self.opts.emit)) break :style;
+
+ // Get our cell style.
+ const cell_style = self.cellStyle(cell);
+
+ // If the style hasn't changed, don't bloat output.
+ if (cell_style.eql(style)) break :style;
+
+ // If we had a previous style, we need to close it,
+ // because we've confirmed we have some new style
+ // (which is maybe default).
+ if (!style.default()) switch (self.opts.emit) {
+ .html => try self.formatStyleClose(writer),
+
+ // For VT, we only close if we're switching to a default
+ // style because any non-default style will emit
+ // a \x1b[0m as the start of a VT coloring sequence.
+ .vt => if (cell_style.default()) try self.formatStyleClose(writer),
+
+ // Unreachable because of the styled() check at the
+ // top of this block.
+ .plain => unreachable,
+ };
+
+ // At this point, we can copy our style over
+ style = cell_style;
+
+ // If we're just the default style now, we're done.
+ if (cell_style.default()) break :style;
+
+ // New style, emit it.
+ try self.formatStyleOpen(
+ writer,
+ &style,
+ );
+
+ // If we have a point map, we map the style to
+ // this cell.
+ if (self.point_map) |*map| {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ try self.formatStyleOpen(
+ &discarding.writer,
+ &style,
+ );
+ for (0..std.math.cast(
+ usize,
+ discarding.count,
+ ) orelse return error.WriteFailed) |_| map.map.append(map.alloc, .{
+ .x = x,
+ .y = y,
+ }) catch return error.WriteFailed;
+ }
+ }
+
+ // Hyperlink state
+ hyperlink: {
+ // We currently only emit hyperlinks for HTML. In the
+ // future we can support emitting OSC 8 hyperlinks for
+ // VT output as well.
+ if (self.opts.emit != .html) break :hyperlink;
+
+ // Get the hyperlink ID. This ID is our internal ID,
+ // not necessarily the OSC8 ID.
+ const link_id_: ?u16 = if (cell.hyperlink)
+ self.page.lookupHyperlink(cell)
+ else
+ null;
+
+ // If our hyperlink IDs match (even null) then we have
+ // identical hyperlink state and we do nothing.
+ if (current_hyperlink_id == link_id_) break :hyperlink;
+
+ // If our prior hyperlink ID was non-null, we need to
+ // close it because the ID has changed.
+ if (current_hyperlink_id != null) {
+ try self.formatHyperlinkClose(writer);
+ current_hyperlink_id = null;
+ }
+
+ // Set our current hyperlink ID
+ const link_id = link_id_ orelse break :hyperlink;
+ current_hyperlink_id = link_id;
+
+ // Emit the opening hyperlink tag
+ const uri = uri: {
+ const link = self.page.hyperlink_set.get(
+ self.page.memory,
+ link_id,
+ );
+ break :uri link.uri.offset.ptr(self.page.memory)[0..link.uri.len];
+ };
+ try self.formatHyperlinkOpen(
+ writer,
+ uri,
+ );
+
+ // If we have a point map, we map the hyperlink to
+ // this cell.
+ if (self.point_map) |*map| {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ try self.formatHyperlinkOpen(
+ &discarding.writer,
+ uri,
+ );
+ for (0..std.math.cast(
+ usize,
+ discarding.count,
+ ) orelse return error.WriteFailed) |_| map.map.append(map.alloc, .{
+ .x = x,
+ .y = y,
+ }) catch return error.WriteFailed;
+ }
+ }
+
+ 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| {
+ try self.writeCell(tag, writer, cell);
+
+ // If we have a point map, all codepoints map to this
+ // cell.
+ if (self.point_map) |*map| {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ try self.writeCell(tag, &discarding.writer, cell);
+ for (0..std.math.cast(
+ usize,
+ discarding.count,
+ ) orelse return error.WriteFailed) |_| map.map.append(map.alloc, .{
+ .x = x,
+ .y = y,
+ }) catch return error.WriteFailed;
+ }
+ },
+
+ // Cells with only background color (no text). Emit a space
+ // with the appropriate background color SGR sequence.
+ .bg_color_palette, .bg_color_rgb => {
+ try writer.writeByte(' ');
+ if (self.point_map) |*map| map.map.append(
+ map.alloc,
+ .{ .x = x, .y = y },
+ ) catch return error.WriteFailed;
+ },
+ }
+ }
+ }
+
+ // If the style is non-default, we need to close our style tag.
+ if (!style.default()) try self.formatStyleClose(writer);
+
+ // Close any open hyperlink for HTML output
+ if (current_hyperlink_id != null) try self.formatHyperlinkClose(writer);
+
+ // Close the monospace wrapper for HTML output
+ if (self.opts.emit == .html) {
+ const closing = "</div>";
+ try writer.writeAll(closing);
+ if (self.point_map) |*map| {
+ map.map.ensureUnusedCapacity(
+ map.alloc,
+ closing.len,
+ ) catch return error.WriteFailed;
+ map.map.appendNTimesAssumeCapacity(
+ map.map.items[map.map.items.len - 1],
+ closing.len,
+ );
+ }
+ }
+
+ return .{ .rows = blank_rows, .cells = blank_cells };
+ }
+
+ fn writeCell(
+ self: PageFormatter,
+ comptime tag: Cell.ContentTag,
+ writer: *std.Io.Writer,
+ cell: *const Cell,
+ ) !void {
+ // Blank cells get an empty space that isn't replaced by anything
+ // because it isn't really a space. We do this so that formatting
+ // is preserved if we're emitting styles.
+ if (!cell.hasText()) {
+ try writer.writeByte(' ');
+ return;
+ }
+
+ try self.writeCodepointWithReplacement(writer, cell.content.codepoint);
+ if (comptime tag == .codepoint_grapheme) {
+ for (self.page.lookupGrapheme(cell).?) |cp| {
+ try self.writeCodepointWithReplacement(writer, cp);
+ }
+ }
+ }
+
+ fn writeCodepointWithReplacement(
+ self: PageFormatter,
+ writer: *std.Io.Writer,
+ codepoint: u21,
+ ) !void {
+ // Search for our replacement
+ const r_: ?CodepointMap.Replacement = replacement: {
+ const map = self.opts.codepoint_map orelse break :replacement null;
+ const items = map.items(.range);
+ for (0..items.len) |forward_i| {
+ const i = items.len - forward_i - 1;
+ const range = items[i];
+ if (range[0] <= codepoint and codepoint <= range[1]) {
+ const replacements = map.items(.replacement);
+ break :replacement replacements[i];
+ }
+ }
+
+ break :replacement null;
+ };
+
+ // If no replacement, write it directly.
+ const r = r_ orelse return try self.writeCodepoint(
+ writer,
+ codepoint,
+ );
+
+ switch (r) {
+ .codepoint => |v| try self.writeCodepoint(
+ writer,
+ v,
+ ),
+
+ .string => |s| {
+ const view = std.unicode.Utf8View.init(s) catch unreachable;
+ var it = view.iterator();
+ while (it.nextCodepoint()) |cp| try self.writeCodepoint(
+ writer,
+ cp,
+ );
+ },
+ }
+ }
+
+ fn writeCodepoint(
+ self: PageFormatter,
+ writer: *std.Io.Writer,
+ codepoint: u21,
+ ) !void {
+ switch (self.opts.emit) {
+ .plain, .vt => try writer.print("{u}", .{codepoint}),
+ .html => {
+ switch (codepoint) {
+ '<' => try writer.writeAll("&lt;"),
+ '>' => try writer.writeAll("&gt;"),
+ '&' => try writer.writeAll("&amp;"),
+ '"' => try writer.writeAll("&quot;"),
+ '\'' => try writer.writeAll("&#39;"),
+ else => {
+ // For HTML, emit ASCII (< 0x80) directly, but encode
+ // all non-ASCII as numeric entities to avoid encoding
+ // detection issues (fixes #9426). We can't set the
+ // meta tag because we emit partial HTML so this ensures
+ // proper unicode handling.
+ if (codepoint < 0x80) {
+ try writer.print("{u}", .{codepoint});
+ } else {
+ try writer.print("&#{d};", .{codepoint});
+ }
+ },
+ }
+ },
+ }
+ }
+
+ /// Returns the style for the given cell. If there is no styling this
+ /// will return the default style.
+ fn cellStyle(
+ self: *const PageFormatter,
+ cell: *const Cell,
+ ) Style {
+ return switch (cell.content_tag) {
+ inline .codepoint, .codepoint_grapheme => if (!cell.hasStyling())
+ .{}
+ else
+ self.page.styles.get(
+ self.page.memory,
+ cell.style_id,
+ ).*,
+
+ .bg_color_palette => .{
+ .bg_color = .{
+ .palette = cell.content.color_palette,
+ },
+ },
+
+ .bg_color_rgb => .{
+ .bg_color = .{
+ .rgb = .{
+ .r = cell.content.color_rgb.r,
+ .g = cell.content.color_rgb.g,
+ .b = cell.content.color_rgb.b,
+ },
+ },
+ },
+ };
+ }
+
+ /// Write a string with HTML escaping. Used for escaping href attributes
+ /// and other HTML attribute values.
+ fn formatStyleOpen(
+ self: PageFormatter,
+ writer: *std.Io.Writer,
+ style: *const Style,
+ ) std.Io.Writer.Error!void {
+ switch (self.opts.emit) {
+ .plain => unreachable,
+
+ .vt => {
+ var formatter = style.formatterVt();
+ formatter.palette = self.opts.palette;
+ try writer.print("{f}", .{formatter});
+ },
+
+ // We use `display: inline` so that the div doesn't impact
+ // layout since we're primarily using it as a CSS wrapper.
+ .html => {
+ var formatter = style.formatterHtml();
+ formatter.palette = self.opts.palette;
+ try writer.print(
+ "<div style=\"display: inline;{f}\">",
+ .{formatter},
+ );
+ },
+ }
+ }
+
+ fn formatStyleClose(
+ self: PageFormatter,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!void {
+ const str: []const u8 = switch (self.opts.emit) {
+ .plain => return,
+ .vt => "\x1b[0m",
+ .html => "</div>",
+ };
+
+ try writer.writeAll(str);
+ if (self.point_map) |*m| {
+ assert(m.map.items.len > 0);
+ m.map.ensureUnusedCapacity(
+ m.alloc,
+ str.len,
+ ) catch return error.WriteFailed;
+ m.map.appendNTimesAssumeCapacity(
+ m.map.items[m.map.items.len - 1],
+ str.len,
+ );
+ }
+ }
+
+ fn formatHyperlinkOpen(
+ self: PageFormatter,
+ writer: *std.Io.Writer,
+ uri: []const u8,
+ ) std.Io.Writer.Error!void {
+ switch (self.opts.emit) {
+ .plain, .vt => unreachable,
+
+ // layout since we're primarily using it as a CSS wrapper.
+ .html => {
+ try writer.writeAll("<a href=\"");
+ for (uri) |byte| try self.writeCodepoint(
+ writer,
+ byte,
+ );
+ try writer.writeAll("\">");
+ },
+ }
+ }
+
+ fn formatHyperlinkClose(
+ self: PageFormatter,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!void {
+ const str: []const u8 = switch (self.opts.emit) {
+ .html => "</a>",
+ .plain, .vt => return,
+ };
+
+ try writer.writeAll(str);
+ if (self.point_map) |*m| {
+ assert(m.map.items.len > 0);
+ m.map.ensureUnusedCapacity(
+ m.alloc,
+ str.len,
+ ) catch return error.WriteFailed;
+ m.map.appendNTimesAssumeCapacity(
+ m.map.items[m.map.items.len - 1],
+ str.len,
+ );
+ }
+ }
+};
+
+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();
+
+ s.nextSlice("hello, world");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .plain);
+
+ // Test our point map.
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // Verify output
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello, world", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells);
+
+ // Verify our point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..output.len) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+}
+
+test "Page plain single line soft-wrapped unwrapped" {
+ 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 = 3,
+ .rows = 5,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ s.nextSlice("hello!");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .{
+ .emit = .plain,
+ .unwrap = true,
+ });
+
+ // Test our point map.
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // Verify output
+ // Note: we don't test the trailing state, which may have bugs
+ // with unwrap...
+ _ = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello!", output);
+
+ // Verify our point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ try testing.expectEqual(
+ Coordinate{ .x = 0, .y = 0 },
+ point_map.items[0],
+ );
+ try testing.expectEqual(
+ Coordinate{ .x = 1, .y = 0 },
+ point_map.items[1],
+ );
+ try testing.expectEqual(
+ Coordinate{ .x = 2, .y = 0 },
+ point_map.items[2],
+ );
+ try testing.expectEqual(
+ Coordinate{ .x = 0, .y = 1 },
+ point_map.items[3],
+ );
+ try testing.expectEqual(
+ Coordinate{ .x = 1, .y = 1 },
+ point_map.items[4],
+ );
+ try testing.expectEqual(
+ Coordinate{ .x = 2, .y = 1 },
+ point_map.items[5],
+ );
+}
+
+test "Page plain single wide char" {
+ 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();
+
+ s.nextSlice("1A⚡");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .plain);
+
+ // Test our point map.
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // Full string
+ {
+ builder.clearRetainingCapacity();
+ point_map.clearRetainingCapacity();
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("1A⚡", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells);
+
+ // Verify our point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (2..output.len) |i| try testing.expectEqual(
+ Coordinate{ .x = 2, .y = 0 },
+ point_map.items[i],
+ );
+ }
+
+ // Wide only (from start)
+ {
+ builder.clearRetainingCapacity();
+ point_map.clearRetainingCapacity();
+
+ formatter.start_x = 2;
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("⚡", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells);
+
+ // Verify our point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..output.len) |i| try testing.expectEqual(
+ Coordinate{ .x = 2, .y = 0 },
+ point_map.items[i],
+ );
+ }
+
+ // Wide only (from tail)
+ {
+ builder.clearRetainingCapacity();
+ point_map.clearRetainingCapacity();
+
+ formatter.start_x = 3;
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("⚡", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells);
+
+ // Verify our point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..output.len) |i| try testing.expectEqual(
+ Coordinate{ .x = 2, .y = 0 },
+ point_map.items[i],
+ );
+ }
+}
+
+test "Page plain single wide char soft-wrapped unwrapped" {
+ 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 = 3,
+ .rows = 24,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ s.nextSlice("1A⚡");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.opts.unwrap = true;
+
+ // Test our point map.
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // Full string
+ {
+ builder.clearRetainingCapacity();
+ point_map.clearRetainingCapacity();
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("1A⚡", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells);
+
+ // Verify our point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (2..output.len) |i| try testing.expectEqual(
+ Coordinate{ .x = 0, .y = 1 },
+ point_map.items[i],
+ );
+ }
+
+ // Full string (ending on spacer head)
+ {
+ builder.clearRetainingCapacity();
+ point_map.clearRetainingCapacity();
+
+ formatter.end_x = 2;
+ formatter.end_y = 0;
+ defer {
+ formatter.end_x = null;
+ formatter.end_y = null;
+ }
+
+ _ = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("1A⚡", output);
+
+ // Verify our point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (2..output.len) |i| try testing.expectEqual(
+ Coordinate{ .x = 0, .y = 1 },
+ point_map.items[i],
+ );
+ }
+
+ // Wide only (from start)
+ {
+ builder.clearRetainingCapacity();
+ point_map.clearRetainingCapacity();
+
+ formatter.start_x = 2;
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("⚡", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells);
+
+ // Verify our point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..output.len) |i| try testing.expectEqual(
+ Coordinate{ .x = 0, .y = 1 },
+ point_map.items[i],
+ );
+ }
+
+ // Wide only (from tail)
+ {
+ builder.clearRetainingCapacity();
+ point_map.clearRetainingCapacity();
+
+ formatter.start_y = 1;
+ formatter.start_x = 1;
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("⚡", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 2), state.cells);
+
+ // Verify our point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..output.len) |i| try testing.expectEqual(
+ Coordinate{ .x = 0, .y = 1 },
+ point_map.items[i],
+ );
+ }
+}
+
+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();
+
+ s.nextSlice("hello\r\nworld");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .plain);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // Verify output
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello\nworld", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[6 + i],
+ );
+}
+
+test "Page plain multiline rectangle" {
+ 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();
+
+ s.nextSlice("hello\r\nworld");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_x = 1;
+ formatter.end_x = 3;
+ formatter.rectangle = true;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // Verify output
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("ell\norl", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, 0), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..3) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i + 1), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // \n
+ for (0..3) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i + 1), .y = 1 },
+ point_map.items[4 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello\r\n\r\n\r\nworld");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .plain);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // Verify output
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello\n\n\nworld", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 3), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n after row 0
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[6]); // \n after blank row 1
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 2 }, point_map.items[7]); // \n after blank row 2
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 3 },
+ point_map.items[8 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello\r\nworld\r\n\r\n");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .plain);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // 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);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello\nworld", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[6 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello \r\nworld ");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .plain);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // 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);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello\nworld", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[6 + i],
+ );
+}
+
+test "Page plain trailing whitespace no trim" {
+ 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();
+
+ s.nextSlice("hello \r\nworld ");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .{
+ .emit = .plain,
+ .trim = false,
+ });
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // 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);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello \nworld ", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 7), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..8) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[8]); // \n
+ for (0..7) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[9 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.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 };
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("\n\nhello", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \n first blank row
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[1]); // \n second blank row
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[2 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.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 };
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // Blank cells are reset when row is not a wrap continuation
+ try testing.expectEqualStrings("hello", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+}
+
+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();
+
+ s.nextSlice("world");
+
+ const pages = &t.screens.active.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 };
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // Blank cells are preserved when row is a wrap continuation with unwrap enabled
+ try testing.expectEqualStrings(" world", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map - 3 spaces from prior trailing state + "world"
+ try testing.expectEqual(output.len, point_map.items.len);
+ // The 3 blank cells can't go back beyond (0,0) so they all map to (0,0)
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // space
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[1]); // space
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[2]); // space
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[3 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello world test");
+
+ const pages = &t.screens.active.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);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // Without unwrap, wrapped lines show as separate lines
+ try testing.expectEqualStrings("hello worl\nd test", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..10) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n
+ for (0..6) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[11 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello world test");
+
+ const pages = &t.screens.active.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, .{ .emit = .plain, .unwrap = true });
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // With unwrap, wrapped lines are joined together
+ try testing.expectEqualStrings("hello world test", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..10) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ for (0..6) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[10 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello world this is a test");
+
+ const pages = &t.screens.active.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);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // Without unwrap, wrapped lines show as separate lines
+ try testing.expectEqualStrings("hello worl\nd this is\na test", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..10) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n
+ for (0..9) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[11 + i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[20]); // \n
+ for (0..6) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 2 },
+ point_map.items[21 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello world this is a test");
+
+ const pages = &t.screens.active.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, .{ .emit = .plain, .unwrap = true });
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // With unwrap, wrapped lines are joined together
+ try testing.expectEqualStrings("hello world this is a test", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 6), state.cells);
+
+ // Verify point map - unwrapped text spans 3 rows
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..10) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ for (0..10) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[10 + i],
+ );
+ for (0..6) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 2 },
+ point_map.items[20 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello\r\nworld\r\ntest");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_y = 1;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("world\ntest", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \n
+ for (0..4) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 2 },
+ point_map.items[6 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello\r\nworld\r\ntest");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.end_y = 1;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello\nworld", output);
+ try testing.expectEqual(@as(usize, 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // \n
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[6 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello\r\nworld\r\ntest\r\nfoo");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_y = 1;
+ formatter.end_y = 2;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("world\ntest", output);
+ try testing.expectEqual(@as(usize, 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 4), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 1 }, point_map.items[5]); // \n
+ for (0..4) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 2 },
+ point_map.items[6 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_y = 30;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("", output);
+ try testing.expectEqual(@as(usize, 0), state.rows);
+ try testing.expectEqual(@as(usize, 0), state.cells);
+
+ // Verify point map is empty
+ try testing.expectEqual(@as(usize, 0), point_map.items.len);
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.end_y = 30;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // Should clamp to page.size.rows and work normally
+ try testing.expectEqualStrings("hello", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_y = 5;
+ formatter.end_y = 2;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("", output);
+ try testing.expectEqual(@as(usize, 0), state.rows);
+ try testing.expectEqual(@as(usize, 0), state.cells);
+
+ // Verify point map is empty
+ try testing.expectEqual(@as(usize, 0), point_map.items.len);
+}
+
+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();
+
+ s.nextSlice("hello world");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_x = 6;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("world", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i + 6), .y = 0 },
+ point_map.items[i],
+ );
+}
+
+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();
+
+ s.nextSlice("first line\r\nsecond line\r\nthird line");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.end_y = 2;
+ formatter.end_x = 4;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("first line\nsecond line\nthird", output);
+ try testing.expectEqual(@as(usize, 1), state.rows);
+ try testing.expectEqual(@as(usize, 0), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..10) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[10]); // \n
+ for (0..11) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[11 + i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 10, .y = 1 }, point_map.items[22]); // \n
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 2 },
+ point_map.items[23 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello world\r\ntest case\r\nfoo bar");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_x = 6;
+ formatter.end_y = 2;
+ formatter.end_x = 2;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // First row: "world" (start_x=6 to end of row)
+ // Second row: "test case" (full row)
+ // Third row: "foo" (start to end_x=2, inclusive)
+ try testing.expectEqualStrings("world\ntest case\nfoo", output);
+ try testing.expectEqual(@as(usize, 1), state.rows);
+ try testing.expectEqual(@as(usize, 0), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i + 6), .y = 0 },
+ point_map.items[i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[5]); // \n
+ for (0..9) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[6 + i],
+ );
+ try testing.expectEqual(Coordinate{ .x = 8, .y = 1 }, point_map.items[15]); // \n
+ for (0..3) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 2 },
+ point_map.items[16 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_x = 100;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("", output);
+ try testing.expectEqual(@as(usize, 0), state.rows);
+ try testing.expectEqual(@as(usize, 0), state.cells);
+
+ // Verify point map is empty
+ try testing.expectEqual(@as(usize, 0), point_map.items.len);
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.end_x = 100;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_x = 10;
+ formatter.end_y = 0;
+ formatter.end_x = 5;
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("", output);
+ try testing.expectEqual(@as(usize, 0), state.rows);
+ try testing.expectEqual(@as(usize, 0), state.cells);
+
+ // Verify point map is empty
+ try testing.expectEqual(@as(usize, 0), point_map.items.len);
+}
+
+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();
+
+ s.nextSlice("hello\r\nworld");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_y = 1;
+ formatter.trailing_state = .{ .rows = 5, .cells = 10 };
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // Should NOT output the 5 newlines from trailing_state because start_y is non-zero
+ try testing.expectEqualStrings("world", output);
+ try testing.expectEqual(@as(usize, page.size.rows - 1), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 1 },
+ point_map.items[i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello world");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .plain);
+ formatter.start_x = 6;
+ formatter.trailing_state = .{ .rows = 2, .cells = 8 };
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // Should NOT output the 2 newlines or 8 spaces from trailing_state because start_x is non-zero
+ try testing.expectEqualStrings("world", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 11), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i + 6), .y = 0 },
+ point_map.items[i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.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 };
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ // SHOULD output the 2 newlines from trailing_state because both start_y and start_x are 0
+ try testing.expectEqualStrings("\n\nhello", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 5), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // \n first blank row
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 1 }, point_map.items[1]); // \n second blank row
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[2 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello, \x1b[1mworld\x1b[0m");
+
+ // Verify we have only a single page
+ const pages = &t.screens.active.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;
+ var formatter: PageFormatter = .init(page, .plain);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ // Verify output
+ const state = try formatter.formatWithState(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello, world", output);
+ try testing.expectEqual(@as(usize, page.size.rows), state.rows);
+ try testing.expectEqual(@as(usize, page.size.cols - 12), state.cells);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..12) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .vt);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello", output);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+}
+
+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();
+
+ s.nextSlice("\x1b[1mhello\x1b[0m");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .vt);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("\x1b[0m\x1b[1mhello\x1b[0m", output);
+
+ // Verify point map - style sequences should point to first character they style
+ try testing.expectEqual(output.len, point_map.items.len);
+ // \x1b[0m = 4 bytes, \x1b[1m = 4 bytes, total 8 bytes of style sequences
+ // All style bytes should map to the first styled character at (0, 0)
+ for (0..8) |i| try testing.expectEqual(
+ Coordinate{ .x = 0, .y = 0 },
+ point_map.items[i],
+ );
+ // Then "hello" maps to its respective positions
+ for (0..5) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[8 + i],
+ );
+}
+
+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();
+
+ s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .vt);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld\x1b[0m", output);
+
+ // Verify point map matches output length
+ try testing.expectEqual(output.len, point_map.items.len);
+}
+
+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();
+
+ s.nextSlice("\x1b[31mred\x1b[0m");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .vt);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred\x1b[0m", output);
+
+ // Verify point map - style sequences should point to first character they style
+ try testing.expectEqual(output.len, point_map.items.len);
+ // \x1b[0m = 4 bytes, \x1b[38;5;1m = 9 bytes, total 13 bytes of style sequences
+ // All style bytes should map to the first styled character at (0, 0)
+ for (0..13) |i| try testing.expectEqual(
+ Coordinate{ .x = 0, .y = 0 },
+ point_map.items[i],
+ );
+ // Then "red" maps to its respective positions
+ for (0..3) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[13 + i],
+ );
+}
+
+test "Page VT with background and foreground colors" {
+ 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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .{
+ .emit = .vt,
+ .background = .{ .r = 0x12, .g = 0x34, .b = 0x56 },
+ .foreground = .{ .r = 0xab, .g = 0xcd, .b = 0xef },
+ });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ // Should emit OSC 10 for foreground, OSC 11 for background, then the text
+ try testing.expectEqualStrings(
+ "\x1b]10;rgb:ab/cd/ef\x1b\\\x1b]11;rgb:12/34/56\x1b\\hello",
+ output,
+ );
+}
+
+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();
+
+ s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .vt);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ // Note: style is reset before newline to prevent background colors from
+ // bleeding to the next line's leading cells.
+ try testing.expectEqualStrings("\x1b[0m\x1b[1mfirst\x1b[0m\r\n\x1b[0m\x1b[3msecond\x1b[0m", output);
+
+ // Verify point map matches output length
+ try testing.expectEqual(output.len, point_map.items.len);
+}
+
+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();
+
+ s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .vt);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("\x1b[0m\x1b[1mhello\x1b[0m", output);
+
+ // Verify point map matches output length
+ try testing.expectEqual(output.len, point_map.items.len);
+}
+
+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();
+
+ s.nextSlice("hello, world");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: PageListFormatter = .init(&t.screens.active.pages, .plain);
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello, world", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ for (0..output.len) |i| try testing.expectEqual(
+ Pin{ .node = node, .x = @intCast(i), .y = 0 },
+ pin_map.items[i],
+ );
+}
+
+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.screens.active.pages;
+ const first_page_rows = pages.pages.first.?.data.capacity.rows;
+
+ // Fill the first page almost completely
+ for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
+ 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
+ s.nextSlice("\r\n");
+ try testing.expect(pages.pages.first != pages.pages.last);
+
+ // Write content on the second page
+ s.nextSlice("page two");
+
+ // Format the entire PageList
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: PageListFormatter = .init(pages, .plain);
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+ try formatter.format(&builder.writer);
+ const full_output = builder.writer.buffered();
+ const output = std.mem.trimStart(u8, full_output, "\n");
+ try testing.expectEqualStrings("page one\npage two", output);
+
+ // Verify pin map
+ try testing.expectEqual(full_output.len, pin_map.items.len);
+ const first_node = pages.pages.first.?;
+ const last_node = pages.pages.last.?;
+ const trimmed_count = full_output.len - output.len;
+
+ // First part (trimmed blank lines) maps to first node
+ for (0..trimmed_count) |i| {
+ try testing.expectEqual(first_node, pin_map.items[i].node);
+ }
+
+ // "page one" (8 chars) maps to first node
+ for (0..8) |i| {
+ const idx = trimmed_count + i;
+ try testing.expectEqual(first_node, pin_map.items[idx].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x);
+ }
+
+ // \n - maps to last node as it represents the transition to new page
+ try testing.expectEqual(last_node, pin_map.items[trimmed_count + 8].node);
+
+ // "page two" (8 chars) maps to last node
+ for (0..8) |i| {
+ const idx = trimmed_count + 9 + i;
+ try testing.expectEqual(last_node, pin_map.items[idx].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x);
+ }
+}
+
+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.screens.active.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) |_| s.nextSlice("\r\n");
+ 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 pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: PageListFormatter = .init(pages, .plain);
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+ try formatter.format(&builder.writer);
+ const full_output = builder.writer.buffered();
+ const output = std.mem.trimStart(u8, full_output, "\n");
+ try testing.expectEqualStrings("hello worl\nd test", output);
+
+ // Verify pin map
+ try testing.expectEqual(full_output.len, pin_map.items.len);
+ const first_node = pages.pages.first.?;
+ const last_node = pages.pages.last.?;
+ const trimmed_count = full_output.len - output.len;
+
+ // First part (trimmed blank lines) maps to first node
+ for (0..trimmed_count) |i| {
+ try testing.expectEqual(first_node, pin_map.items[i].node);
+ }
+
+ // First line maps to first node
+ for (0..10) |i| {
+ const idx = trimmed_count + i;
+ try testing.expectEqual(first_node, pin_map.items[idx].node);
+ }
+
+ // \n - maps to last node as it represents the transition to new page
+ try testing.expectEqual(last_node, pin_map.items[trimmed_count + 10].node);
+
+ // "d test" (6 chars) maps to last node
+ for (0..6) |i| {
+ const idx = trimmed_count + 11 + i;
+ try testing.expectEqual(last_node, pin_map.items[idx].node);
+ }
+}
+
+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.screens.active.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) |_| s.nextSlice("\r\n");
+ 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 pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: PageListFormatter = .init(pages, .{ .emit = .plain, .unwrap = true });
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+ try formatter.format(&builder.writer);
+ const full_output = builder.writer.buffered();
+ const output = std.mem.trimStart(u8, full_output, "\r\n");
+ try testing.expectEqualStrings("hello world test", output);
+
+ // Verify pin map
+ try testing.expectEqual(full_output.len, pin_map.items.len);
+ const first_node = pages.pages.first.?;
+ const last_node = pages.pages.last.?;
+ const trimmed_count = full_output.len - output.len;
+
+ // First part (trimmed blank lines) maps to first node
+ for (0..trimmed_count) |i| {
+ try testing.expectEqual(first_node, pin_map.items[i].node);
+ }
+
+ // First line from first page
+ for (0..10) |i| {
+ const idx = trimmed_count + i;
+ try testing.expectEqual(first_node, pin_map.items[idx].node);
+ }
+
+ // "d test" (6 chars) from last page
+ for (0..6) |i| {
+ const idx = trimmed_count + 10 + i;
+ try testing.expectEqual(last_node, pin_map.items[idx].node);
+ }
+}
+
+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.screens.active.pages;
+ const first_page_rows = pages.pages.first.?.data.capacity.rows;
+
+ // Fill the first page almost completely
+ for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
+ 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
+ s.nextSlice("\r\n");
+ try testing.expect(pages.pages.first != pages.pages.last);
+
+ // New content is still styled
+ s.nextSlice("page two");
+
+ // Format the entire PageList with VT
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: PageListFormatter = .init(pages, .vt);
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+ try formatter.format(&builder.writer);
+ const full_output = builder.writer.buffered();
+ const output = std.mem.trimStart(u8, full_output, "\r\n");
+ try testing.expectEqualStrings("\x1b[0m\x1b[1mpage one\x1b[0m\r\n\x1b[0m\x1b[1mpage two\x1b[0m", output);
+
+ // Verify pin map
+ try testing.expectEqual(full_output.len, pin_map.items.len);
+ const first_node = pages.pages.first.?;
+ const last_node = pages.pages.last.?;
+
+ // Just verify we have entries for both pages in the pin map
+ var first_count: usize = 0;
+ var last_count: usize = 0;
+ for (pin_map.items) |pin| {
+ if (pin.node == first_node) first_count += 1;
+ if (pin.node == last_node) last_count += 1;
+ }
+ try testing.expect(first_count > 0);
+ try testing.expect(last_count > 0);
+}
+
+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();
+
+ s.nextSlice("hello world\r\ntest case\r\nfoo bar");
+
+ const pages = &t.screens.active.pages;
+ const node = pages.pages.first.?;
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: PageListFormatter = .init(pages, .plain);
+ formatter.top_left = .{ .node = node, .y = 0, .x = 6 };
+ formatter.bottom_right = .{ .node = node, .y = 2, .x = 2 };
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("world\ntest case\nfoo", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ for (pin_map.items) |pin| {
+ try testing.expectEqual(node, pin.node);
+ }
+
+ // "world" starts at x=6, y=0
+ for (0..5) |i| {
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[i].x);
+ try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y);
+ }
+}
+
+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.screens.active.pages;
+ const first_page_rows = pages.pages.first.?.data.capacity.rows;
+
+ // Fill first page almost completely
+ for (0..first_page_rows - 1) |_| s.nextSlice("\r\n");
+ s.nextSlice("hello world");
+
+ // Verify we're still on one page
+ try testing.expect(pages.pages.first == pages.pages.last);
+
+ // Push to second page
+ s.nextSlice("\r\n");
+ try testing.expect(pages.pages.first != pages.pages.last);
+
+ s.nextSlice("foo bar test");
+
+ const first_node = pages.pages.first.?;
+ const last_node = pages.pages.last.?;
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ 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 = 2 };
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const full_output = builder.writer.buffered();
+ const output = std.mem.trimStart(u8, full_output, "\n");
+ try testing.expectEqualStrings("world\nfoo", output);
+
+ // Verify pin map
+ try testing.expectEqual(full_output.len, pin_map.items.len);
+ const trimmed_count = full_output.len - output.len;
+
+ // "world" (5 chars) from first page
+ for (0..5) |i| {
+ const idx = trimmed_count + i;
+ try testing.expectEqual(first_node, pin_map.items[idx].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[idx].x);
+ }
+
+ // \n - maps to last node as it represents the transition to new page
+ try testing.expectEqual(last_node, pin_map.items[trimmed_count + 5].node);
+
+ // "foo" (3 chars) from last page
+ for (0..3) |i| {
+ const idx = trimmed_count + 6 + i;
+ try testing.expectEqual(last_node, pin_map.items[idx].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x);
+ }
+}
+
+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();
+
+ s.nextSlice("hello world");
+
+ const pages = &t.screens.active.pages;
+ const node = pages.pages.first.?;
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: PageListFormatter = .init(pages, .plain);
+ formatter.top_left = .{ .node = node, .y = 0, .x = 6 };
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("world", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ for (0..5) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.items[i].x);
+ try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y);
+ }
+}
+
+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();
+
+ s.nextSlice("hello world\r\ntest");
+
+ const pages = &t.screens.active.pages;
+ const node = pages.pages.first.?;
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: PageListFormatter = .init(pages, .plain);
+ formatter.bottom_right = .{ .node = node, .y = 1, .x = 2 };
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello world\ntes", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+
+ // "hello world" (11 chars) on y=0
+ for (0..11) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x);
+ try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y);
+ }
+
+ // \n
+ try testing.expectEqual(node, pin_map.items[11].node);
+
+ // "tes" (3 chars) on y=1
+ for (0..3) |i| {
+ try testing.expectEqual(node, pin_map.items[12 + i].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[12 + i].x);
+ try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[12 + i].y);
+ }
+}
+
+test "PageList plain rectangle basic" {
+ 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 = 30,
+ .rows = 5,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ s.nextSlice("Lorem ipsum dolor\r\n");
+ s.nextSlice("sit amet, consectetur\r\n");
+ s.nextSlice("adipiscing elit, sed do\r\n");
+ s.nextSlice("eiusmod tempor incididunt\r\n");
+ s.nextSlice("ut labore et dolore");
+
+ const pages = &t.screens.active.pages;
+
+ var formatter: PageListFormatter = .init(pages, .plain);
+ formatter.top_left = pages.pin(.{ .screen = .{ .x = 2, .y = 1 } }).?;
+ formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 6, .y = 3 } }).?;
+ formatter.rectangle = true;
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ const expected =
+ \\t ame
+ \\ipisc
+ \\usmod
+ ;
+ try testing.expectEqualStrings(expected, output);
+}
+
+test "PageList plain rectangle with EOL" {
+ 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 = 30,
+ .rows = 5,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ s.nextSlice("Lorem ipsum dolor\r\n");
+ s.nextSlice("sit amet, consectetur\r\n");
+ s.nextSlice("adipiscing elit, sed do\r\n");
+ s.nextSlice("eiusmod tempor incididunt\r\n");
+ s.nextSlice("ut labore et dolore");
+
+ const pages = &t.screens.active.pages;
+
+ var formatter: PageListFormatter = .init(pages, .plain);
+ formatter.top_left = pages.pin(.{ .screen = .{ .x = 12, .y = 0 } }).?;
+ formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 26, .y = 4 } }).?;
+ formatter.rectangle = true;
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ const expected =
+ \\dolor
+ \\nsectetur
+ \\lit, sed do
+ \\or incididunt
+ \\ dolore
+ ;
+ try testing.expectEqualStrings(expected, output);
+}
+
+test "PageList plain rectangle more complex with breaks" {
+ 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 = 30,
+ .rows = 8,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ s.nextSlice("Lorem ipsum dolor\r\n");
+ s.nextSlice("sit amet, consectetur\r\n");
+ s.nextSlice("adipiscing elit, sed do\r\n");
+ s.nextSlice("eiusmod tempor incididunt\r\n");
+ s.nextSlice("ut labore et dolore\r\n");
+ s.nextSlice("\r\n");
+ s.nextSlice("magna aliqua. Ut enim\r\n");
+ s.nextSlice("ad minim veniam, quis");
+
+ const pages = &t.screens.active.pages;
+
+ var formatter: PageListFormatter = .init(pages, .plain);
+ formatter.top_left = pages.pin(.{ .screen = .{ .x = 11, .y = 2 } }).?;
+ formatter.bottom_right = pages.pin(.{ .screen = .{ .x = 26, .y = 7 } }).?;
+ formatter.rectangle = true;
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ const expected =
+ \\elit, sed do
+ \\por incididunt
+ \\t dolore
+ \\
+ \\a. Ut enim
+ \\niam, quis
+ ;
+ try testing.expectEqualStrings(expected, output);
+}
+
+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();
+
+ s.nextSlice("hello\r\nworld");
+
+ const formatter: TerminalFormatter = .init(&t, .plain);
+
+ try formatter.format(&builder.writer);
+ try testing.expectEqualStrings("hello\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
+ s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\");
+ s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\");
+ s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\");
+ 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();
+
+ s2.nextSlice(output);
+
+ // Verify the palettes match
+ try testing.expectEqual(t.colors.palette.current[0], t2.colors.palette.current[0]);
+ try testing.expectEqual(t.colors.palette.current[1], t2.colors.palette.current[1]);
+ try testing.expectEqual(t.colors.palette.current[255], t2.colors.palette.current[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();
+
+ s.nextSlice("line1\r\nline2\r\nline3");
+
+ var formatter: TerminalFormatter = .init(&t, .plain);
+ formatter.content = .{ .selection = .init(
+ t.screens.active.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
+ t.screens.active.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
+ false,
+ ) };
+
+ try formatter.format(&builder.writer);
+ try testing.expectEqualStrings("line2", builder.writer.buffered());
+}
+
+test "TerminalFormatter plain with pin_map" {
+ 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();
+
+ s.nextSlice("hello, world");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: TerminalFormatter = .init(&t, .plain);
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello, world", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ for (0..output.len) |i| try testing.expectEqual(
+ Pin{ .node = node, .x = @intCast(i), .y = 0 },
+ pin_map.items[i],
+ );
+}
+
+test "TerminalFormatter plain multiline with pin_map" {
+ 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();
+
+ s.nextSlice("hello\r\nworld");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: TerminalFormatter = .init(&t, .plain);
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello\nworld", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ // "hello" (5 chars)
+ for (0..5) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x);
+ try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y);
+ }
+ // "\n" maps to end of first line
+ try testing.expectEqual(node, pin_map.items[5].node);
+ // "world" (5 chars)
+ for (0..5) |i| {
+ const idx = 6 + i;
+ try testing.expectEqual(node, pin_map.items[idx].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x);
+ try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y);
+ }
+}
+
+test "TerminalFormatter vt with palette and pin_map" {
+ 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
+ s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\");
+ s.nextSlice("test");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: TerminalFormatter = .init(&t, .vt);
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ // Verify pin map - palette bytes should be mapped to top left
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ for (0..output.len) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ }
+}
+
+test "TerminalFormatter with selection and pin_map" {
+ 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();
+
+ s.nextSlice("line1\r\nline2\r\nline3");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: TerminalFormatter = .init(&t, .plain);
+ formatter.content = .{ .selection = .init(
+ t.screens.active.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
+ t.screens.active.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
+ false,
+ ) };
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("line2", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ // "line2" (5 chars) from row 1
+ for (0..5) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x);
+ try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[i].y);
+ }
+}
+
+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();
+
+ s.nextSlice("hello, world");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: ScreenFormatter = .init(t.screens.active, .plain);
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello, world", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ for (0..output.len) |i| try testing.expectEqual(
+ Pin{ .node = node, .x = @intCast(i), .y = 0 },
+ pin_map.items[i],
+ );
+}
+
+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();
+
+ s.nextSlice("hello\r\nworld");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: ScreenFormatter = .init(t.screens.active, .plain);
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello\nworld", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ // "hello" (5 chars)
+ for (0..5) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x);
+ try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.items[i].y);
+ }
+ // "\n" maps to end of first line
+ try testing.expectEqual(node, pin_map.items[5].node);
+ // "world" (5 chars)
+ for (0..5) |i| {
+ const idx = 6 + i;
+ try testing.expectEqual(node, pin_map.items[idx].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[idx].x);
+ try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[idx].y);
+ }
+}
+
+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();
+
+ s.nextSlice("line1\r\nline2\r\nline3");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: ScreenFormatter = .init(t.screens.active, .plain);
+ formatter.content = .{ .selection = .init(
+ t.screens.active.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
+ t.screens.active.pages.pin(.{ .active = .{ .x = 4, .y = 1 } }).?,
+ false,
+ ) };
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("line2", output);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ // "line2" (5 chars) from row 1
+ for (0..5) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.items[i].x);
+ try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.items[i].y);
+ }
+}
+
+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
+ s.nextSlice("hello\r\nworld");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: ScreenFormatter = .init(t.screens.active, .vt);
+ formatter.extra.cursor = true;
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ 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();
+
+ s2.nextSlice(output);
+
+ // Verify cursor positions match
+ try testing.expectEqual(t.screens.active.cursor.x, t2.screens.active.cursor.x);
+ try testing.expectEqual(t.screens.active.cursor.y, t2.screens.active.cursor.y);
+
+ // Verify pin map - the extras should be mapped to the last pin
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ const content_len = "hello\r\nworld".len;
+ // Content bytes map to their positions
+ for (0..content_len) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ }
+ // Extra bytes (cursor position) map to last content pin
+ for (content_len..output.len) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ }
+}
+
+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
+ s.nextSlice("\x1b[1;31mhello");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: ScreenFormatter = .init(t.screens.active, .vt);
+ formatter.extra.style = true;
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ 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();
+
+ s2.nextSlice(output);
+
+ // Verify styles match
+ try testing.expect(t.screens.active.cursor.style.eql(t2.screens.active.cursor.style));
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ for (0..output.len) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ }
+}
+
+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
+ s.nextSlice("\x1b]8;;http://example.com\x1b\\hello");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: ScreenFormatter = .init(t.screens.active, .vt);
+ formatter.extra.hyperlink = true;
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ 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();
+
+ s2.nextSlice(output);
+
+ // Verify hyperlinks match
+ const has_link1 = t.screens.active.cursor.hyperlink != null;
+ const has_link2 = t2.screens.active.cursor.hyperlink != null;
+ try testing.expectEqual(has_link1, has_link2);
+
+ if (has_link1) {
+ const link1 = t.screens.active.cursor.hyperlink.?;
+ const link2 = t2.screens.active.cursor.hyperlink.?;
+ try testing.expectEqualStrings(link1.uri, link2.uri);
+ }
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ for (0..output.len) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ }
+}
+
+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
+ s.nextSlice("\x1b[1\"qhello");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: ScreenFormatter = .init(t.screens.active, .vt);
+ formatter.extra.protection = true;
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ 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();
+
+ s2.nextSlice(output);
+
+ // Verify protection state matches
+ try testing.expectEqual(t.screens.active.cursor.protected, t2.screens.active.cursor.protected);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ for (0..output.len) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ }
+}
+
+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)
+ s.nextSlice("\x1b[=3;1uhello");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: ScreenFormatter = .init(t.screens.active, .vt);
+ formatter.extra.kitty_keyboard = true;
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ 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();
+
+ s2.nextSlice(output);
+
+ // Verify kitty keyboard state matches
+ const flags1 = t.screens.active.kitty_keyboard.current().int();
+ const flags2 = t2.screens.active.kitty_keyboard.current().int();
+ try testing.expectEqual(flags1, flags2);
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ for (0..output.len) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ }
+}
+
+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
+ s.nextSlice("\x1b(0\x0ehello");
+
+ var pin_map: std.ArrayList(Pin) = .empty;
+ defer pin_map.deinit(alloc);
+
+ var formatter: ScreenFormatter = .init(t.screens.active, .vt);
+ formatter.extra.charsets = true;
+ formatter.pin_map = .{ .alloc = alloc, .map = &pin_map };
+
+ 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();
+
+ s2.nextSlice(output);
+
+ // Verify charset state matches
+ try testing.expectEqual(t.screens.active.charset.gl, t2.screens.active.charset.gl);
+ try testing.expectEqual(t.screens.active.charset.gr, t2.screens.active.charset.gr);
+ try testing.expectEqual(
+ t.screens.active.charset.charsets.get(.G0),
+ t2.screens.active.charset.charsets.get(.G0),
+ );
+
+ // Verify pin map
+ try testing.expectEqual(output.len, pin_map.items.len);
+ const node = t.screens.active.pages.pages.first.?;
+ for (0..output.len) |i| {
+ try testing.expectEqual(node, pin_map.items[i].node);
+ }
+}
+
+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
+ 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();
+
+ 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
+ s.nextSlice("\x1b[?2004h"); // Bracketed paste
+ s.nextSlice("\x1b[?1000h"); // Mouse event normal
+ s.nextSlice("\x1b[?7l"); // Disable wraparound (default is true)
+ 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();
+
+ 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
+ s.nextSlice("\x1b[3g"); // Clear all tabs
+ s.nextSlice("\x1b[5G\x1bH"); // Set tab at column 5
+ s.nextSlice("\x1b[15G\x1bH"); // Set tab at column 15
+ s.nextSlice("\x1b[30G\x1bH"); // Set tab at column 30
+ 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();
+
+ 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
+ s.nextSlice("\x1b[>4;2m");
+ 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();
+
+ 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
+ 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();
+
+ s2.nextSlice(output);
+
+ // Verify pwd matches
+ try testing.expectEqualStrings(t.pwd.items, t2.pwd.items);
+}
+
+test "Page html with 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();
+
+ // Set bold, then italic, then reset
+ s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<div style=\"display: inline;font-weight: bold;\">bold</div>" ++
+ "<div style=\"display: inline;font-weight: bold;font-style: italic;\">italic</div>" ++
+ "normal" ++
+ "</div>",
+ output,
+ );
+}
+
+test "Page html 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();
+
+ s.nextSlice("hello, world");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ // Plain text without styles should be wrapped in monospace div
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">hello, world</div>",
+ output,
+ );
+}
+
+test "Page html with colors" {
+ 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 red foreground, blue background
+ s.nextSlice("\x1b[31;44mcolored");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<div style=\"display: inline;color: var(--vt-palette-1);background-color: var(--vt-palette-4);\">colored</div>" ++
+ "</div>",
+ output,
+ );
+}
+
+test "TerminalFormatter html 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
+ s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\");
+ s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\");
+ s.nextSlice("\x1b]4;255;rgb:ff/00/ff\x1b\\");
+ s.nextSlice("test");
+
+ var formatter: TerminalFormatter = .init(&t, .{ .emit = .html });
+ formatter.extra.palette = true;
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ // Verify palette CSS variables are emitted
+ try testing.expect(std.mem.indexOf(u8, output, "<style>:root{") != null);
+ try testing.expect(std.mem.indexOf(u8, output, "--vt-palette-0: #123456;") != null);
+ try testing.expect(std.mem.indexOf(u8, output, "--vt-palette-1: #abcdef;") != null);
+ try testing.expect(std.mem.indexOf(u8, output, "--vt-palette-255: #ff00ff;") != null);
+ try testing.expect(std.mem.indexOf(u8, output, "}</style>") != null);
+ try testing.expect(std.mem.indexOf(u8, output, "test") != null);
+}
+
+test "Page html with background and foreground colors" {
+ 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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{
+ .emit = .html,
+ .background = .{ .r = 0x12, .g = 0x34, .b = 0x56 },
+ .foreground = .{ .r = 0xab, .g = 0xcd, .b = 0xef },
+ });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;background-color: #123456;color: #abcdef;\">hello</div>",
+ output,
+ );
+}
+
+test "Page html with escaping" {
+ 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();
+
+ s.nextSlice("<tag>&\"'text");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">&lt;tag&gt;&amp;&quot;&#39;text</div>",
+ output,
+ );
+
+ // Verify point map length matches output
+ try testing.expectEqual(output.len, point_map.items.len);
+
+ // Opening wrapper div
+ const wrapper_start = "<div style=\"font-family: monospace; white-space: pre;\">";
+ const wrapper_start_len = wrapper_start.len;
+ for (0..wrapper_start_len) |i| try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[i]);
+
+ // Verify each character maps correctly, accounting for escaping
+ const offset = wrapper_start_len;
+ // < (4 bytes: &lt;) -> x=0
+ for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[offset + i]);
+ // t (1 byte) -> x=1
+ try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[offset + 4]);
+ // a (1 byte) -> x=2
+ try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[offset + 5]);
+ // g (1 byte) -> x=3
+ try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[offset + 6]);
+ // > (4 bytes: &gt;) -> x=4
+ for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[offset + 7 + i]);
+ // & (5 bytes: &amp;) -> x=5
+ for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[offset + 11 + i]);
+ // " (6 bytes: &quot;) -> x=6
+ for (0..6) |i| try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[offset + 16 + i]);
+ // ' (5 bytes: &#39;) -> x=7
+ for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[offset + 22 + i]);
+ // t (1 byte) -> x=8
+ try testing.expectEqual(Coordinate{ .x = 8, .y = 0 }, point_map.items[offset + 27]);
+ // e (1 byte) -> x=9
+ try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[offset + 28]);
+ // x (1 byte) -> x=10
+ try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[offset + 29]);
+ // t (1 byte) -> x=11
+ try testing.expectEqual(Coordinate{ .x = 11, .y = 0 }, point_map.items[offset + 30]);
+}
+
+test "Page html with unicode as numeric entities" {
+ 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();
+
+ // Box drawing characters that caused issue #9426
+ s.nextSlice("╰─ ❯");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ // Expected: box drawing chars as numeric entities
+ // ╰ = U+2570 = 9584, ─ = U+2500 = 9472, ❯ = U+276F = 10095
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">&#9584;&#9472; &#10095;</div>",
+ output,
+ );
+}
+
+test "Page html ascii characters unchanged" {
+ 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();
+
+ s.nextSlice("hello world");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ // ASCII should be emitted directly
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">hello world</div>",
+ output,
+ );
+}
+
+test "Page html mixed ascii and unicode" {
+ 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();
+
+ s.nextSlice("test ╰─❯ ok");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ // Mix of ASCII and Unicode entities
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">test &#9584;&#9472;&#10095; ok</div>",
+ output,
+ );
+}
+
+test "Page VT with palette option emits RGB" {
+ 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 custom palette color and use it
+ s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\");
+ s.nextSlice("\x1b[31mred");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Without palette option - should emit palette index
+ {
+ builder.clearRetainingCapacity();
+ var formatter: PageFormatter = .init(page, .vt);
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mred\x1b[0m", output);
+ }
+
+ // With palette option - should emit RGB directly
+ {
+ builder.clearRetainingCapacity();
+ var opts: Options = .vt;
+ opts.palette = &t.colors.palette.current;
+ var formatter: PageFormatter = .init(page, opts);
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("\x1b[0m\x1b[38;2;171;205;239mred\x1b[0m", output);
+ }
+}
+
+test "Page html with palette option emits RGB" {
+ 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 custom palette color and use it
+ s.nextSlice("\x1b]4;1;rgb:ab/cd/ef\x1b\\");
+ s.nextSlice("\x1b[31mred");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Without palette option - should emit CSS variable
+ {
+ builder.clearRetainingCapacity();
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<div style=\"display: inline;color: var(--vt-palette-1);\">red</div>" ++
+ "</div>",
+ output,
+ );
+ }
+
+ // With palette option - should emit RGB directly
+ {
+ builder.clearRetainingCapacity();
+ var opts: Options = .{ .emit = .html };
+ opts.palette = &t.colors.palette.current;
+ var formatter: PageFormatter = .init(page, opts);
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<div style=\"display: inline;color: rgb(171, 205, 239);\">red</div>" ++
+ "</div>",
+ output,
+ );
+ }
+}
+
+test "Page VT style reset properly closes 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();
+
+ // Set bold, then reset with SGR 0
+ s.nextSlice("\x1b[1mbold\x1b[0mnormal");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ builder.clearRetainingCapacity();
+ var formatter: PageFormatter = .init(page, .vt);
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ // The reset should properly close the bold style
+ try testing.expectEqualStrings("\x1b[0m\x1b[1mbold\x1b[0mnormal", output);
+}
+
+test "Page codepoint_map single replacement" {
+ 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();
+
+ s.nextSlice("hello world");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Replace 'o' with 'x'
+ var map: std.MultiArrayList(CodepointMap) = .{};
+ defer map.deinit(alloc);
+ try map.append(alloc, .{
+ .range = .{ 'o', 'o' },
+ .replacement = .{ .codepoint = 'x' },
+ });
+
+ var opts: Options = .plain;
+ opts.codepoint_map = map;
+ var formatter: PageFormatter = .init(page, opts);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hellx wxrld", output);
+
+ // Verify point map - each output byte should map to original cell position
+ try testing.expectEqual(output.len, point_map.items.len);
+ // "hello world" -> "hellx wxrld"
+ // h e l l o w o r l d
+ // 0 1 2 3 4 5 6 7 8 9 10
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // h
+ try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[1]); // e
+ try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[2]); // l
+ try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // l
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[4]); // x (was o)
+ try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[5]); // space
+ try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[6]); // w
+ try testing.expectEqual(Coordinate{ .x = 7, .y = 0 }, point_map.items[7]); // x (was o)
+ try testing.expectEqual(Coordinate{ .x = 8, .y = 0 }, point_map.items[8]); // r
+ try testing.expectEqual(Coordinate{ .x = 9, .y = 0 }, point_map.items[9]); // l
+ try testing.expectEqual(Coordinate{ .x = 10, .y = 0 }, point_map.items[10]); // d
+}
+
+test "Page codepoint_map conflicting replacement prefers last" {
+ 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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Replace 'o' with 'x', then with 'y' - should prefer last
+ var map: std.MultiArrayList(CodepointMap) = .{};
+ defer map.deinit(alloc);
+ try map.append(alloc, .{
+ .range = .{ 'o', 'o' },
+ .replacement = .{ .codepoint = 'x' },
+ });
+ try map.append(alloc, .{
+ .range = .{ 'o', 'o' },
+ .replacement = .{ .codepoint = 'y' },
+ });
+
+ var opts: Options = .plain;
+ opts.codepoint_map = map;
+ var formatter: PageFormatter = .init(page, opts);
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("helly", output);
+}
+
+test "Page codepoint_map replace with string" {
+ 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();
+
+ s.nextSlice("hello");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Replace 'o' with a multi-byte string
+ var map: std.MultiArrayList(CodepointMap) = .{};
+ defer map.deinit(alloc);
+ try map.append(alloc, .{
+ .range = .{ 'o', 'o' },
+ .replacement = .{ .string = "XYZ" },
+ });
+
+ var opts: Options = .plain;
+ opts.codepoint_map = map;
+ var formatter: PageFormatter = .init(page, opts);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hellXYZ", output);
+
+ // Verify point map - string replacements should all map to the original cell
+ try testing.expectEqual(output.len, point_map.items.len);
+ // "hello" -> "hellXYZ"
+ // h e l l o
+ // 0 1 2 3 4
+ try testing.expectEqual(Coordinate{ .x = 0, .y = 0 }, point_map.items[0]); // h
+ try testing.expectEqual(Coordinate{ .x = 1, .y = 0 }, point_map.items[1]); // e
+ try testing.expectEqual(Coordinate{ .x = 2, .y = 0 }, point_map.items[2]); // l
+ try testing.expectEqual(Coordinate{ .x = 3, .y = 0 }, point_map.items[3]); // l
+ // All bytes of the replacement string "XYZ" should point to position 4 (where 'o' was)
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[4]); // X
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[5]); // Y
+ try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[6]); // Z
+}
+
+test "Page codepoint_map range replacement" {
+ 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();
+
+ s.nextSlice("abcdefg");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Replace 'b' through 'e' with 'X'
+ var map: std.MultiArrayList(CodepointMap) = .{};
+ defer map.deinit(alloc);
+ try map.append(alloc, .{
+ .range = .{ 'b', 'e' },
+ .replacement = .{ .codepoint = 'X' },
+ });
+
+ var opts: Options = .plain;
+ opts.codepoint_map = map;
+ var formatter: PageFormatter = .init(page, opts);
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("aXXXXfg", output);
+}
+
+test "Page codepoint_map multiple ranges" {
+ 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();
+
+ s.nextSlice("hello world");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z'
+ var map: std.MultiArrayList(CodepointMap) = .{};
+ defer map.deinit(alloc);
+ try map.append(alloc, .{
+ .range = .{ 'a', 'm' },
+ .replacement = .{ .codepoint = 'A' },
+ });
+ try map.append(alloc, .{
+ .range = .{ 'n', 'z' },
+ .replacement = .{ .codepoint = 'Z' },
+ });
+
+ var opts: Options = .plain;
+ opts.codepoint_map = map;
+ var formatter: PageFormatter = .init(page, opts);
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ // h e l l o w o r l d
+ // A A A A Z Z Z Z A A
+ try testing.expectEqualStrings("AAAAZ ZZZAA", output);
+}
+
+test "Page codepoint_map unicode replacement" {
+ 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();
+
+ s.nextSlice("hello ⚡ world");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Replace lightning bolt with fire emoji
+ var map: std.MultiArrayList(CodepointMap) = .{};
+ defer map.deinit(alloc);
+ try map.append(alloc, .{
+ .range = .{ '⚡', '⚡' },
+ .replacement = .{ .string = "🔥" },
+ });
+
+ var opts: Options = .plain;
+ opts.codepoint_map = map;
+ var formatter: PageFormatter = .init(page, opts);
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello 🔥 world", output);
+
+ // Verify point map
+ try testing.expectEqual(output.len, point_map.items.len);
+ // "hello ⚡ world"
+ // h e l l o ⚡ w o r l d
+ // 0 1 2 3 4 5 6 8 9 10 11 12
+ // Note: ⚡ is a wide character occupying cells 6-7
+ for (0..6) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(i), .y = 0 },
+ point_map.items[i],
+ );
+ // 🔥 is 4 UTF-8 bytes, all should map to cell 6 (where ⚡ was)
+ const fire_start = 6; // "hello " is 6 bytes
+ for (0..4) |i| try testing.expectEqual(
+ Coordinate{ .x = 6, .y = 0 },
+ point_map.items[fire_start + i],
+ );
+ // " world" follows
+ const world_start = fire_start + 4;
+ for (0..6) |i| try testing.expectEqual(
+ Coordinate{ .x = @intCast(8 + i), .y = 0 },
+ point_map.items[world_start + i],
+ );
+}
+
+test "Page codepoint_map with styled formats" {
+ 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();
+
+ s.nextSlice("\x1b[31mred text\x1b[0m");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Replace 'e' with 'X' in styled text
+ var map: std.MultiArrayList(CodepointMap) = .{};
+ defer map.deinit(alloc);
+ try map.append(alloc, .{
+ .range = .{ 'e', 'e' },
+ .replacement = .{ .codepoint = 'X' },
+ });
+
+ var opts: Options = .vt;
+ opts.codepoint_map = map;
+ var formatter: PageFormatter = .init(page, opts);
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ // Should preserve styles while replacing text
+ // "red text" becomes "rXd tXxt"
+ // VT format uses \x1b[38;5;1m for palette color 1
+ try testing.expectEqualStrings("\x1b[0m\x1b[38;5;1mrXd tXxt\x1b[0m", output);
+}
+
+test "Page codepoint_map empty map" {
+ 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();
+
+ s.nextSlice("hello world");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ // Empty map should not change anything
+ var map: std.MultiArrayList(CodepointMap) = .{};
+ defer map.deinit(alloc);
+
+ var opts: Options = .plain;
+ opts.codepoint_map = map;
+ var formatter: PageFormatter = .init(page, opts);
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+ try testing.expectEqualStrings("hello world", output);
+}
+
+test "Page VT background color on trailing blank cells" {
+ // This test reproduces a bug where trailing cells with background color
+ // but no text are emitted as plain spaces without SGR sequences.
+ // This causes TUIs like htop to lose background colors on rehydration.
+ 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 = 20,
+ .rows = 5,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ // Simulate a TUI row: "CPU:" with text, then trailing cells with red background
+ // to end of line (no text after the colored region).
+ // \x1b[41m sets red background, then EL fills rest of row with that bg.
+ s.nextSlice("CPU:\x1b[41m\x1b[K");
+ // Reset colors and move to next line with different content
+ s.nextSlice("\x1b[0m\r\nline2");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+
+ var formatter: PageFormatter = .init(page, .vt);
+ formatter.opts.trim = false; // Don't trim so we can see the trailing behavior
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ // The output should preserve the red background SGR for trailing cells on line 1.
+ // Bug: the first row outputs "CPU:\r\n" only - losing the background color fill.
+ // The red background should appear BEFORE the newline, not after.
+
+ // Find position of CRLF
+ const crlf_pos = std.mem.indexOf(u8, output, "\r\n") orelse {
+ // No CRLF found, fail the test
+ return error.TestUnexpectedResult;
+ };
+
+ // Check that red background (48;5;1) appears BEFORE the newline (on line 1)
+ const line1 = output[0..crlf_pos];
+ const has_red_bg_line1 = std.mem.indexOf(u8, line1, "\x1b[41m") != null or
+ std.mem.indexOf(u8, line1, "\x1b[48;5;1m") != null;
+
+ // This should be true but currently fails due to the bug
+ try testing.expect(has_red_bg_line1);
+}
+
+test "Page HTML with hyperlinks" {
+ 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();
+
+ // Start a hyperlink, write some text, end it
+ s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<a href=\"https://example.com\">link text</a> normal" ++
+ "</div>",
+ output,
+ );
+}
+
+test "Page HTML with multiple hyperlinks" {
+ 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();
+
+ // Two different hyperlinks
+ s.nextSlice("\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ ");
+ s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<a href=\"https://first.com\">first</a>" ++
+ " " ++
+ "<a href=\"https://second.com\">second</a>" ++
+ "</div>",
+ output,
+ );
+}
+
+test "Page HTML with hyperlink escaping" {
+ 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();
+
+ // URL with special characters that need escaping
+ s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<a href=\"https://example.com?a=1&amp;b=2\">link</a>" ++
+ "</div>",
+ output,
+ );
+}
+
+test "Page HTML with styled 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();
+
+ // Bold hyperlink
+ s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<div style=\"display: inline;font-weight: bold;\">" ++
+ "<a href=\"https://example.com\">bold link</div></a>" ++
+ "</div>",
+ output,
+ );
+}
+
+test "Page HTML hyperlink closes style before anchor" {
+ 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();
+
+ // Styled hyperlink followed by plain text
+ s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ try testing.expectEqualStrings(
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<div style=\"display: inline;font-weight: bold;\">" ++
+ "<a href=\"https://example.com\">bold</div> plain</a>" ++
+ "</div>",
+ output,
+ );
+}
+
+test "Page HTML hyperlink point map maps closing to previous cell" {
+ 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();
+
+ s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal");
+
+ const pages = &t.screens.active.pages;
+ const page = &pages.pages.last.?.data;
+ var formatter: PageFormatter = .init(page, .{ .emit = .html });
+
+ var point_map: std.ArrayList(Coordinate) = .empty;
+ defer point_map.deinit(alloc);
+ formatter.point_map = .{ .alloc = alloc, .map = &point_map };
+
+ try formatter.format(&builder.writer);
+ const output = builder.writer.buffered();
+
+ const expected_output =
+ "<div style=\"font-family: monospace; white-space: pre;\">" ++
+ "<a href=\"https://example.com\">link</a> normal" ++
+ "</div>";
+ try testing.expectEqualStrings(expected_output, output);
+ try testing.expectEqual(expected_output.len, point_map.items.len);
+
+ // The </a> closing tag bytes should all map to the last cell of the link
+ const closing_idx = comptime std.mem.indexOf(u8, expected_output, "</a>").?;
+ const expected_coord = point_map.items[closing_idx - 1];
+ for (closing_idx..closing_idx + "</a>".len) |i| {
+ try testing.expectEqual(expected_coord, point_map.items[i]);
+ }
+}