Files
ghostty/src/terminal/formatter.zig
Mitchell Hashimoto f50aa90ced terminal: add lib.zig to centralize lib target and re-exports
Previously every file in the terminal package independently imported
build_options and ../lib/main.zig, then computed the same
lib_target constant. This was repetitive and meant each file needed
both imports just to get the target.

Introduce src/terminal/lib.zig which computes the target once and
re-exports the commonly used lib types (Enum, TaggedUnion, Struct,
String, checkGhosttyHEnum, structSizedFieldFits). All terminal
package files now import lib.zig and use lib.target instead of the
local lib_target constant, removing the per-file boilerplate.
2026-03-25 07:25:16 -07:00

6280 lines
204 KiB
Zig
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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]);
}
}