mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-05-26 14:58:35 +00:00
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.
6280 lines
204 KiB
Zig
6280 lines
204 KiB
Zig
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("<"),
|
||
'>' => try writer.writeAll(">"),
|
||
'&' => try writer.writeAll("&"),
|
||
'"' => try writer.writeAll("""),
|
||
'\'' => try writer.writeAll("'"),
|
||
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;\"><tag>&"'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: <) -> 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: >) -> x=4
|
||
for (0..4) |i| try testing.expectEqual(Coordinate{ .x = 4, .y = 0 }, point_map.items[offset + 7 + i]);
|
||
// & (5 bytes: &) -> x=5
|
||
for (0..5) |i| try testing.expectEqual(Coordinate{ .x = 5, .y = 0 }, point_map.items[offset + 11 + i]);
|
||
// " (6 bytes: ") -> x=6
|
||
for (0..6) |i| try testing.expectEqual(Coordinate{ .x = 6, .y = 0 }, point_map.items[offset + 16 + i]);
|
||
// ' (5 bytes: ') -> 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;\">╰─ ❯</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 ╰─❯ 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&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]);
|
||
}
|
||
}
|