terminal: redesign pin map for formatter

This commit is contained in:
Mitchell Hashimoto
2026-08-03 20:09:57 -07:00
parent d4391ff835
commit 2ed67cadd1
5 changed files with 495 additions and 240 deletions

View File

@@ -47,7 +47,6 @@ const Benchmark = @import("Benchmark.zig");
const options = @import("options.zig");
const Terminal = terminalpkg.Terminal;
const Selection = terminalpkg.Selection;
const Pin = terminalpkg.PageList.Pin;
const global = @import("../global.zig");
const log = std.log.scoped(.@"terminal-formatter-bench");
@@ -60,7 +59,7 @@ terminal: ?Terminal = null,
output: std.Io.Writer.Allocating,
/// Reused pin map storage for `--pin-map=true`.
pins: std.ArrayList(Pin) = .empty,
pins: formatterpkg.PinMap.Map = .empty,
pub const Options = struct {
/// Set by the shared CLI parser for string option ownership.
@@ -112,6 +111,14 @@ pub const Mode = enum {
/// Print content and output sizes. Not a timing benchmark.
report,
/// Semantic verification, not a timing benchmark: format the
/// terminal (must be `--emit=vt`), feed the output into a fresh
/// terminal of the same dimensions, format that, and verify the
/// two outputs converge to identical bytes. This proves the VT
/// output faithfully reconstructs the terminal content even when
/// the exact byte encoding changes.
roundtrip,
};
pub const Emit = enum {
@@ -166,6 +173,7 @@ pub fn benchmark(self: *TerminalFormatter) Benchmark {
.noop => stepNoop,
.format => stepFormat,
.report => stepReport,
.roundtrip => stepRoundtrip,
},
.setupFn = setup,
.teardownFn = teardown,
@@ -312,9 +320,11 @@ fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
// checked for identical output.
const out_hash = std.hash.Wyhash.hash(0, self.output.written());
var pin_hasher = std.hash.Wyhash.init(0);
for (self.pins.items) |pin| {
pin_hasher.update(std.mem.asBytes(&pin.x));
pin_hasher.update(std.mem.asBytes(&pin.y));
for (self.pins.points.items) |coord| {
const x: u16 = coord.x;
const y: u16 = @intCast(coord.y);
pin_hasher.update(std.mem.asBytes(&x));
pin_hasher.update(std.mem.asBytes(&y));
}
std.debug.print(
@@ -329,11 +339,90 @@ fn stepReport(ptr: *anyopaque) Benchmark.Error!void {
self.opts.@"terminal-cols",
rows * self.opts.@"terminal-cols",
self.output.written().len,
self.pins.items.len,
self.pins.count(),
out_hash,
pin_hasher.final(),
},
);
// Pin map storage details on a separate line so the main report
// line remains comparable across implementations.
if (self.opts.@"pin-map") {
std.debug.print(
"terminal-formatter-pins points={d} nodes={d}\n",
.{ self.pins.points.items.len, self.pins.nodes.items.len },
);
}
}
fn stepRoundtrip(ptr: *anyopaque) Benchmark.Error!void {
const self: *TerminalFormatter = @ptrCast(@alignCast(ptr));
self.stepRoundtripImpl() catch |err| {
log.warn("roundtrip failed err={}", .{err});
return error.BenchmarkFailed;
};
}
/// Format the terminal, replay the output into a fresh terminal of the
/// same dimensions, format that, and require both outputs to be
/// identical. This verifies that the emitted VT sequences faithfully
/// reconstruct the terminal contents (text, styles, wrapping) without
/// requiring any specific byte encoding of the first output.
fn stepRoundtripImpl(self: *TerminalFormatter) !void {
// Format the original terminal.
self.output.shrinkRetainingCapacity(0);
if (self.formatter()) |f_init| {
var f = f_init;
try f.format(&self.output.writer);
}
const first = self.output.written();
// Replay into a fresh terminal.
var t2 = try Terminal.init(global.io(), self.alloc, .{
.cols = self.opts.@"terminal-cols",
.rows = self.opts.@"terminal-rows",
.max_scrollback_bytes = null,
.max_scrollback_lines = null,
});
defer t2.deinit(self.alloc);
{
var stream = t2.vtStream();
defer stream.deinit();
stream.nextSlice(first);
}
// Format the replayed terminal identically.
var out2: std.Io.Writer.Allocating = .init(self.alloc);
defer out2.deinit();
var f2: formatterpkg.ScreenFormatter = .init(t2.screens.active, .{
.emit = self.opts.emit.format(),
.unwrap = self.opts.unwrap,
});
try f2.format(&out2.writer);
const second = out2.written();
const equal = std.mem.eql(u8, first, second);
std.debug.print(
"terminal-formatter roundtrip emit={s} bytes={d} replay_bytes={d} equal={}\n",
.{ @tagName(self.opts.emit), first.len, second.len, equal },
);
if (!equal) {
// Find the first differing offset to ease debugging.
const n = @min(first.len, second.len);
var i: usize = 0;
while (i < n and first[i] == second[i]) i += 1;
std.debug.print(
"terminal-formatter roundtrip mismatch offset={d} " ++
"first={f} second={f}\n",
.{
i,
std.zig.fmtString(first[i -| 32..@min(first.len, i + 32)]),
std.zig.fmtString(second[i -| 32..@min(second.len, i + 32)]),
},
);
return error.RoundtripMismatch;
}
}
test TerminalFormatter {
@@ -345,6 +434,19 @@ test TerminalFormatter {
_ = try bench.run(.once);
}
test "TerminalFormatter roundtrip" {
const testing = std.testing;
const impl: *TerminalFormatter = try .create(testing.allocator, .{
.mode = .roundtrip,
.@"terminal-rows" = 4,
.@"terminal-cols" = 8,
});
defer impl.destroy(testing.allocator);
const bench = impl.benchmark();
_ = try bench.run(.once);
}
test "TerminalFormatter formats all emit formats and regions" {
const testing = std.testing;

View File

@@ -17,6 +17,7 @@ const PageList = @import("PageList.zig");
const selection_codepoints = @import("selection_codepoints.zig");
const StringMap = @import("StringMap.zig");
const ScreenFormatter = @import("formatter.zig").ScreenFormatter;
const PinMap = @import("formatter.zig").PinMap;
const osc = @import("osc.zig");
const pagepkg = @import("page.zig");
const point = @import("point.zig");
@@ -2910,7 +2911,7 @@ pub fn selectionString(
formatter.content = .{ .selection = opts.sel };
// If we have a string map, we need to set that up.
var pins: std.ArrayList(Pin) = .empty;
var pins: PinMap.Map = .empty;
defer pins.deinit(alloc);
if (opts.map != null) formatter.pin_map = .{
.alloc = alloc,
@@ -2928,11 +2929,13 @@ pub fn selectionString(
const map_string = try alloc.dupeZ(u8, text);
errdefer alloc.free(map_string);
try selectionString_tw.check(.copy_map);
const map_pins = try pins.toOwnedSlice(alloc);
map.* = .{
.string = map_string,
.map = map_pins,
.map = pins,
};
// Ownership of the pin map moved to the string map.
pins = .empty;
}
return text;

View File

@@ -6,9 +6,9 @@ const std = @import("std");
const build_options = @import("terminal_options");
const oni = @import("oniguruma");
const point = @import("point.zig");
const PinMap = @import("formatter.zig").PinMap;
const Selection = @import("Selection.zig");
const Screen = @import("Screen.zig");
const Pin = @import("PageList.zig").Pin;
const Allocator = std.mem.Allocator;
// Retry budget for StringMap regex searches.
@@ -18,11 +18,15 @@ const Allocator = std.mem.Allocator;
const oni_search_retry_limit = 100_000;
string: [:0]const u8,
map: []Pin,
/// Mapping of string byte offsets to pins. See PinMap for the
/// storage details.
map: PinMap.Map,
pub fn deinit(self: StringMap, alloc: Allocator) void {
alloc.free(self.string);
alloc.free(self.map);
var map = self.map;
map.deinit(alloc);
}
/// Returns an iterator that yields the next match of the given regex.
@@ -106,8 +110,8 @@ pub const Match = struct {
pub fn selection(self: Match) Selection {
const start_idx: usize = @intCast(self.region.starts()[0]);
const end_idx: usize = @intCast(self.region.ends()[0] - 1);
const start_pt = self.map.map[self.offset + start_idx];
const end_pt = self.map.map[self.offset + end_idx];
const start_pt = self.map.map.get(self.offset + start_idx).?;
const end_pt = self.map.map.get(self.offset + end_idx).?;
return .init(start_pt, end_pt, false);
}
};

View File

@@ -120,9 +120,117 @@ pub const Options = struct {
///
/// Used by formatters that operate on PageLists to track the source position
/// of each byte written. The caller is responsible for freeing the map.
///
/// The mapping is stored in two parts: a per-byte x/y coordinate (8
/// bytes per output byte, half the size of a Pin) and a tiny table of
/// page nodes covering byte ranges (there are only ever a handful of
/// pages). This also lets page formatters write coordinates directly
/// into the map without a separate coordinate-to-pin conversion pass.
pub const PinMap = struct {
alloc: Allocator,
map: *std.ArrayList(Pin),
map: *Map,
/// The type of the page node referenced by pins.
pub const Node = @FieldType(Pin, "node");
/// A page node covering output bytes starting at `offset`
/// (inclusive) until the next entry's offset (or the end of the
/// output).
pub const NodeRun = struct {
offset: usize,
node: Node,
};
pub const Map = struct {
/// The x/y coordinate within its page for every output byte.
points: std.ArrayList(Coordinate) = .empty,
/// The page node for ranges of output bytes, ordered by offset.
nodes: std.ArrayList(NodeRun) = .empty,
pub const empty: Map = .{};
pub fn deinit(self: *Map, alloc: Allocator) void {
self.points.deinit(alloc);
self.nodes.deinit(alloc);
}
pub fn clearRetainingCapacity(self: *Map) void {
self.points.clearRetainingCapacity();
self.nodes.clearRetainingCapacity();
}
/// The total number of bytes mapped.
pub fn count(self: *const Map) usize {
return self.points.items.len;
}
/// Set the page node for all bytes appended from here on,
/// until the next call. No-op if the node is unchanged.
pub fn setNode(
self: *Map,
alloc: Allocator,
node: Node,
) Allocator.Error!void {
if (self.nodes.getLastOrNull()) |last| {
if (last.node == node) return;
}
try self.nodes.append(alloc, .{
.offset = self.points.items.len,
.node = node,
});
}
/// Append `n` bytes that map to `pin`.
pub fn append(
self: *Map,
alloc: Allocator,
pin: Pin,
n: usize,
) Allocator.Error!void {
if (n == 0) return;
try self.setNode(alloc, pin.node);
try self.points.appendNTimes(
alloc,
.{ .x = pin.x, .y = pin.y },
n,
);
}
/// Returns the pin that the byte at the given offset maps to,
/// or null if the offset is out of range.
pub fn get(self: *const Map, offset: usize) ?Pin {
if (offset >= self.points.items.len) return null;
const coord = self.points.items[offset];
return .{
.node = findNode(self.nodes.items, offset) orelse return null,
.x = coord.x,
.y = @intCast(coord.y),
};
}
/// Returns the last pin in the map, if any.
pub fn getLastOrNull(self: *const Map) ?Pin {
const len = self.points.items.len;
if (len == 0) return null;
return self.get(len - 1);
}
};
/// Binary search for the node covering `offset` in a slice of node
/// runs sorted by offset. Returns null only if the slice is empty
/// or the offset precedes the first run.
pub fn findNode(runs: []const NodeRun, offset: usize) ?Node {
if (runs.len == 0 or offset < runs[0].offset) return null;
var lo: usize = 0;
var hi: usize = runs.len;
while (lo + 1 < hi) {
const mid = lo + (hi - lo) / 2;
if (runs[mid].offset <= offset) lo = mid else hi = mid;
}
return runs[lo].node;
}
};
/// Terminal formatter formats the active terminal screen.
@@ -289,7 +397,7 @@ pub const TerminalFormatter = struct {
// 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.map.append(
m.alloc,
self.terminal.screens.active.pages.getTopLeft(.screen),
std.math.cast(usize, discarding.count) orelse return error.WriteFailed,
@@ -327,7 +435,7 @@ pub const TerminalFormatter = struct {
// 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.map.append(
m.alloc,
self.terminal.screens.active.pages.getTopLeft(.screen),
std.math.cast(usize, discarding.count) orelse return error.WriteFailed,
@@ -404,16 +512,10 @@ pub const TerminalFormatter = struct {
extra_formatter.extra.pwd = self.extra.pwd;
try extra_formatter.format(&discarding.writer);
m.map.appendNTimes(
m.map.append(
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),
m.map.getLastOrNull() orelse
self.terminal.screens.active.pages.getTopLeft(.screen),
std.math.cast(usize, discarding.count) orelse return error.WriteFailed,
) catch return error.WriteFailed;
}
@@ -672,21 +774,10 @@ pub const ScreenFormatter = struct {
// 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.map.append(
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),
m.map.getLastOrNull() orelse
self.screen.pages.getTopLeft(.screen),
std.math.cast(usize, discarding.count) orelse return error.WriteFailed,
) catch return error.WriteFailed;
}
@@ -738,10 +829,6 @@ pub const PageListFormatter = struct {
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| {
@@ -764,31 +851,19 @@ pub const PageListFormatter = struct {
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 we're tracking pins, the page formatter writes its
// per-byte coordinates directly into our map's point list
// and we record which page node covers those bytes.
if (self.pin_map) |*m| {
point_map.clearRetainingCapacity();
formatter.point_map = .{ .alloc = m.alloc, .map = &point_map };
m.map.setNode(m.alloc, chunk.node) catch return error.WriteFailed;
formatter.point_map = .{
.alloc = m.alloc,
.map = &m.map.points,
.base = m.map.points.items.len,
};
}
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| {
m.map.ensureUnusedCapacity(
m.alloc,
point_map.items.len,
) catch return error.WriteFailed;
for (point_map.items) |coord| {
m.map.appendAssumeCapacity(.{
.node = chunk.node,
.x = coord.x,
.y = @intCast(coord.y),
});
}
}
}
}
};
@@ -847,6 +922,12 @@ pub const PageFormatter = struct {
pub const PointMap = struct {
alloc: Allocator,
map: *std.ArrayList(Coordinate),
/// The index in `map` at which this formatter's output begins.
/// Entries before this index belong to a caller (e.g. previous
/// pages of a PageListFormatter) and aren't inspected. This
/// exists so that callers can share one list across pages.
base: usize = 0,
};
/// Trailing state. This is used to ensure that rows wrapped across
@@ -1113,7 +1194,7 @@ pub const PageFormatter = struct {
// 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)
const start: Coordinate = if (map.map.items.len > map.base)
map.map.items[map.map.items.len - 1]
else
.{ .x = 0, .y = 0 };
@@ -1162,9 +1243,6 @@ pub const PageFormatter = struct {
if (cp_map_empty) fast: {
if (comptime formatStyled(emit)) {
if (style_id == invalid_style_id) break :fast;
if (comptime emit == .html) {
if (current_hyperlink_id != null) break :fast;
}
}
// Specialized on point tracking so that the common
@@ -1178,6 +1256,7 @@ pub const PageFormatter = struct {
x,
y,
style_id,
current_hyperlink_id,
&blank_cells,
)
else
@@ -1189,6 +1268,7 @@ pub const PageFormatter = struct {
x,
y,
style_id,
current_hyperlink_id,
&blank_cells,
);
@@ -1268,9 +1348,16 @@ pub const PageFormatter = struct {
const cell_style = self.cellStyle(cell);
// If the style hasn't changed, don't bloat output.
if (cell_style.eql(style)) {
style_id = cell_style_id;
break :style;
// When both ids are interned (and thus different, since
// equal ids broke out above), interning guarantees the
// styles differ so we can skip the comparison entirely.
if (cell_style_id == invalid_style_id or
style_id == invalid_style_id)
{
if (cell_style.eql(style)) {
style_id = cell_style_id;
break :style;
}
}
// If we had a previous style, we need to close it,
@@ -1478,6 +1565,7 @@ pub const PageFormatter = struct {
run_x: size.CellCountInt,
run_y: size.CellCountInt,
run_style_id: u32,
run_hyperlink_id: ?hyperlink.Id,
blank_cells: *usize,
) std.Io.Writer.Error!usize {
assert(track_points == (self.point_map != null));
@@ -1511,11 +1599,6 @@ pub const PageFormatter = struct {
if (comptime formatStyled(emit)) {
// Style transition, take the slow path.
if (cell.style_id != run_style_id) break;
// Hyperlink transition, take the slow path.
if (comptime emit == .html) {
if (cell.hyperlink) break;
}
}
const cp: u21 = cell.content.codepoint.data;
@@ -1543,6 +1626,18 @@ pub const PageFormatter = struct {
}
}
// Hyperlink state must be stable within a run: any non-blank
// cell must belong to the currently open hyperlink (or none).
// Transitions take the slow path. This is checked after blank
// accounting because blank cells never touch hyperlink state.
if (comptime emit == .html) {
if (cell.hyperlink) {
const run_id = run_hyperlink_id orelse break;
const cell_id = self.page.lookupHyperlink(cell) orelse break;
if (cell_id != run_id) break;
} else if (run_hyperlink_id != null) break;
}
// The page coordinate of this cell, for point tracking.
const x: size.CellCountInt = @intCast(run_x + i);
@@ -4018,7 +4113,7 @@ test "PageList plain single line" {
s.nextSlice("hello, world");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: PageListFormatter = .init(&t.screens.active.pages, .plain);
@@ -4028,11 +4123,11 @@ test "PageList plain single line" {
try testing.expectEqualStrings("hello, world", output);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
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],
pin_map.get(i).?,
);
}
@@ -4071,7 +4166,7 @@ test "PageList plain spanning two pages" {
s.nextSlice("page two");
// Format the entire PageList
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: PageListFormatter = .init(pages, .plain);
@@ -4082,31 +4177,31 @@ test "PageList plain spanning two pages" {
try testing.expectEqualStrings("page one\npage two", output);
// Verify pin map
try testing.expectEqual(full_output.len, pin_map.items.len);
try testing.expectEqual(full_output.len, pin_map.count());
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);
try testing.expectEqual(first_node, pin_map.get(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);
try testing.expectEqual(first_node, pin_map.get(idx).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(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);
try testing.expectEqual(last_node, pin_map.get(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);
try testing.expectEqual(last_node, pin_map.get(idx).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(idx).?.x);
}
}
@@ -4138,7 +4233,7 @@ test "PageList soft-wrapped line spanning two pages without unwrap" {
try testing.expect(pages.pages.first != pages.pages.last);
// Format without unwrap - should show line breaks
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: PageListFormatter = .init(pages, .plain);
@@ -4149,29 +4244,29 @@ test "PageList soft-wrapped line spanning two pages without unwrap" {
try testing.expectEqualStrings("hello worl\nd test", output);
// Verify pin map
try testing.expectEqual(full_output.len, pin_map.items.len);
try testing.expectEqual(full_output.len, pin_map.count());
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);
try testing.expectEqual(first_node, pin_map.get(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);
try testing.expectEqual(first_node, pin_map.get(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);
try testing.expectEqual(last_node, pin_map.get(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);
try testing.expectEqual(last_node, pin_map.get(idx).?.node);
}
}
@@ -4203,7 +4298,7 @@ test "PageList soft-wrapped line spanning two pages with unwrap" {
try testing.expect(pages.pages.first != pages.pages.last);
// Format with unwrap - should join the wrapped lines
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: PageListFormatter = .init(pages, .{ .emit = .plain, .unwrap = true });
@@ -4214,26 +4309,26 @@ test "PageList soft-wrapped line spanning two pages with unwrap" {
try testing.expectEqualStrings("hello world test", output);
// Verify pin map
try testing.expectEqual(full_output.len, pin_map.items.len);
try testing.expectEqual(full_output.len, pin_map.count());
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);
try testing.expectEqual(first_node, pin_map.get(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);
try testing.expectEqual(first_node, pin_map.get(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);
try testing.expectEqual(last_node, pin_map.get(idx).?.node);
}
}
@@ -4272,7 +4367,7 @@ test "PageList VT spanning two pages" {
s.nextSlice("page two");
// Format the entire PageList with VT
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: PageListFormatter = .init(pages, .vt);
@@ -4283,14 +4378,15 @@ test "PageList VT spanning two pages" {
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);
try testing.expectEqual(full_output.len, pin_map.count());
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| {
for (0..pin_map.count()) |byte_i| {
const pin = pin_map.get(byte_i).?;
if (pin.node == first_node) first_count += 1;
if (pin.node == last_node) last_count += 1;
}
@@ -4320,7 +4416,7 @@ test "PageList plain with x offset on single page" {
const pages = &t.screens.active.pages;
const node = pages.pages.first.?;
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: PageListFormatter = .init(pages, .plain);
@@ -4333,15 +4429,16 @@ test "PageList plain with x offset on single page" {
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(output.len, pin_map.count());
for (0..pin_map.count()) |byte_i| {
const pin = pin_map.get(byte_i).?;
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);
try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.get(i).?.x);
try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y);
}
}
@@ -4381,7 +4478,7 @@ test "PageList plain with x offset spanning two pages" {
const first_node = pages.pages.first.?;
const last_node = pages.pages.last.?;
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: PageListFormatter = .init(pages, .plain);
@@ -4395,24 +4492,24 @@ test "PageList plain with x offset spanning two pages" {
try testing.expectEqualStrings("world\nfoo", output);
// Verify pin map
try testing.expectEqual(full_output.len, pin_map.items.len);
try testing.expectEqual(full_output.len, pin_map.count());
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);
try testing.expectEqual(first_node, pin_map.get(idx).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.get(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);
try testing.expectEqual(last_node, pin_map.get(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);
try testing.expectEqual(last_node, pin_map.get(idx).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(idx).?.x);
}
}
@@ -4438,7 +4535,7 @@ test "PageList plain with start_x only" {
const pages = &t.screens.active.pages;
const node = pages.pages.first.?;
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: PageListFormatter = .init(pages, .plain);
@@ -4450,11 +4547,11 @@ test "PageList plain with start_x only" {
try testing.expectEqualStrings("world", output);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
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);
try testing.expectEqual(node, pin_map.get(i).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(6 + i)), pin_map.get(i).?.x);
try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y);
}
}
@@ -4480,7 +4577,7 @@ test "PageList plain with end_x only" {
const pages = &t.screens.active.pages;
const node = pages.pages.first.?;
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: PageListFormatter = .init(pages, .plain);
@@ -4492,23 +4589,23 @@ test "PageList plain with end_x only" {
try testing.expectEqualStrings("hello world\ntes", output);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
// "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);
try testing.expectEqual(node, pin_map.get(i).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x);
try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y);
}
// \n
try testing.expectEqual(node, pin_map.items[11].node);
try testing.expectEqual(node, pin_map.get(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);
try testing.expectEqual(node, pin_map.get(12 + i).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(12 + i).?.x);
try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(12 + i).?.y);
}
}
@@ -4763,7 +4860,7 @@ test "TerminalFormatter plain with pin_map" {
s.nextSlice("hello, world");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: TerminalFormatter = .init(&t, .plain);
@@ -4774,11 +4871,11 @@ test "TerminalFormatter plain with pin_map" {
try testing.expectEqualStrings("hello, world", output);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
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],
pin_map.get(i).?,
);
}
@@ -4801,7 +4898,7 @@ test "TerminalFormatter plain multiline with pin_map" {
s.nextSlice("hello\r\nworld");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: TerminalFormatter = .init(&t, .plain);
@@ -4812,22 +4909,22 @@ test "TerminalFormatter plain multiline with pin_map" {
try testing.expectEqualStrings("hello\nworld", output);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
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);
try testing.expectEqual(node, pin_map.get(i).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x);
try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y);
}
// "\n" maps to end of first line
try testing.expectEqual(node, pin_map.items[5].node);
try testing.expectEqual(node, pin_map.get(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);
try testing.expectEqual(node, pin_map.get(idx).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(idx).?.x);
try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(idx).?.y);
}
}
@@ -4852,7 +4949,7 @@ test "TerminalFormatter vt with palette and pin_map" {
s.nextSlice("\x1b]4;0;rgb:12/34/56\x1b\\");
s.nextSlice("test");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: TerminalFormatter = .init(&t, .vt);
@@ -4862,10 +4959,10 @@ test "TerminalFormatter vt with palette and pin_map" {
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);
try testing.expectEqual(output.len, pin_map.count());
const node = t.screens.active.pages.pages.first.?;
for (0..output.len) |i| {
try testing.expectEqual(node, pin_map.items[i].node);
try testing.expectEqual(node, pin_map.get(i).?.node);
}
}
@@ -4888,7 +4985,7 @@ test "TerminalFormatter with selection and pin_map" {
s.nextSlice("line1\r\nline2\r\nline3");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: TerminalFormatter = .init(&t, .plain);
@@ -4904,13 +5001,13 @@ test "TerminalFormatter with selection and pin_map" {
try testing.expectEqualStrings("line2", output);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
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);
try testing.expectEqual(node, pin_map.get(i).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x);
try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(i).?.y);
}
}
@@ -4933,7 +5030,7 @@ test "Screen plain single line" {
s.nextSlice("hello, world");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: ScreenFormatter = .init(t.screens.active, .plain);
@@ -4944,11 +5041,11 @@ test "Screen plain single line" {
try testing.expectEqualStrings("hello, world", output);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
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],
pin_map.get(i).?,
);
}
@@ -4971,7 +5068,7 @@ test "Screen plain multiline" {
s.nextSlice("hello\r\nworld");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: ScreenFormatter = .init(t.screens.active, .plain);
@@ -4982,22 +5079,22 @@ test "Screen plain multiline" {
try testing.expectEqualStrings("hello\nworld", output);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
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);
try testing.expectEqual(node, pin_map.get(i).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x);
try testing.expectEqual(@as(size.CellCountInt, 0), pin_map.get(i).?.y);
}
// "\n" maps to end of first line
try testing.expectEqual(node, pin_map.items[5].node);
try testing.expectEqual(node, pin_map.get(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);
try testing.expectEqual(node, pin_map.get(idx).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(idx).?.x);
try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(idx).?.y);
}
}
@@ -5020,7 +5117,7 @@ test "Screen plain with selection" {
s.nextSlice("line1\r\nline2\r\nline3");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: ScreenFormatter = .init(t.screens.active, .plain);
@@ -5036,13 +5133,13 @@ test "Screen plain with selection" {
try testing.expectEqualStrings("line2", output);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
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);
try testing.expectEqual(node, pin_map.get(i).?.node);
try testing.expectEqual(@as(size.CellCountInt, @intCast(i)), pin_map.get(i).?.x);
try testing.expectEqual(@as(size.CellCountInt, 1), pin_map.get(i).?.y);
}
}
@@ -5066,7 +5163,7 @@ test "Screen vt with cursor position" {
// Position cursor at a specific location
s.nextSlice("hello\r\nworld");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: ScreenFormatter = .init(t.screens.active, .vt);
@@ -5093,16 +5190,16 @@ test "Screen vt with cursor position" {
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);
try testing.expectEqual(output.len, pin_map.count());
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);
try testing.expectEqual(node, pin_map.get(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);
try testing.expectEqual(node, pin_map.get(i).?.node);
}
}
@@ -5126,7 +5223,7 @@ test "Screen vt with style" {
// Set some style attributes
s.nextSlice("\x1b[1;31mhello");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: ScreenFormatter = .init(t.screens.active, .vt);
@@ -5152,10 +5249,10 @@ test "Screen vt with style" {
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);
try testing.expectEqual(output.len, pin_map.count());
const node = t.screens.active.pages.pages.first.?;
for (0..output.len) |i| {
try testing.expectEqual(node, pin_map.items[i].node);
try testing.expectEqual(node, pin_map.get(i).?.node);
}
}
@@ -5179,7 +5276,7 @@ test "Screen vt with hyperlink" {
// Set a hyperlink
s.nextSlice("\x1b]8;;http://example.com\x1b\\hello");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: ScreenFormatter = .init(t.screens.active, .vt);
@@ -5213,10 +5310,10 @@ test "Screen vt with hyperlink" {
}
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
const node = t.screens.active.pages.pages.first.?;
for (0..output.len) |i| {
try testing.expectEqual(node, pin_map.items[i].node);
try testing.expectEqual(node, pin_map.get(i).?.node);
}
}
@@ -5240,7 +5337,7 @@ test "Screen vt with protection" {
// Enable protection mode
s.nextSlice("\x1b[1\"qhello");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: ScreenFormatter = .init(t.screens.active, .vt);
@@ -5266,10 +5363,10 @@ test "Screen vt with protection" {
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);
try testing.expectEqual(output.len, pin_map.count());
const node = t.screens.active.pages.pages.first.?;
for (0..output.len) |i| {
try testing.expectEqual(node, pin_map.items[i].node);
try testing.expectEqual(node, pin_map.get(i).?.node);
}
}
@@ -5293,7 +5390,7 @@ test "Screen vt with kitty keyboard" {
// Set kitty keyboard flags (disambiguate + report_events = 3)
s.nextSlice("\x1b[=3;1uhello");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: ScreenFormatter = .init(t.screens.active, .vt);
@@ -5321,10 +5418,10 @@ test "Screen vt with kitty keyboard" {
try testing.expectEqual(flags1, flags2);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
const node = t.screens.active.pages.pages.first.?;
for (0..output.len) |i| {
try testing.expectEqual(node, pin_map.items[i].node);
try testing.expectEqual(node, pin_map.get(i).?.node);
}
}
@@ -5348,7 +5445,7 @@ test "Screen vt with charsets" {
// Set G0 to DEC special and shift to G1
s.nextSlice("\x1b(0\x0ehello");
var pin_map: std.ArrayList(Pin) = .empty;
var pin_map: PinMap.Map = .empty;
defer pin_map.deinit(alloc);
var formatter: ScreenFormatter = .init(t.screens.active, .vt);
@@ -5379,10 +5476,10 @@ test "Screen vt with charsets" {
);
// Verify pin map
try testing.expectEqual(output.len, pin_map.items.len);
try testing.expectEqual(output.len, pin_map.count());
const node = t.screens.active.pages.pages.first.?;
for (0..output.len) |i| {
try testing.expectEqual(node, pin_map.items[i].node);
try testing.expectEqual(node, pin_map.get(i).?.node);
}
}

View File

@@ -332,76 +332,125 @@ pub const Style = struct {
self: VTFormatter,
writer: *std.Io.Writer,
) !void {
// Style emission is a hot path when formatting styled terminal
// contents, so all of the sequences are assembled in a buffer
// and written in one call rather than going through the
// (slower) format string machinery with a write per sequence.
//
// Worst case: `\x1b[0m` (4) + 7 flags (28) + `\x1b[53m` (5) +
// `\x1b[4:2m` (6) + 3 RGB colors (19 each = 57) = 100.
var buf: [128]u8 = undefined;
// Always reset the style. Styles are fully self-contained.
// Even if this style is empty, then that means we want to go
// back to the default.
try writer.writeAll("\x1b[0m");
buf[0..4].* = "\x1b[0m".*;
var len: usize = 4;
// Our flags
if (self.style.flags.bold) try writer.writeAll("\x1b[1m");
if (self.style.flags.faint) try writer.writeAll("\x1b[2m");
if (self.style.flags.italic) try writer.writeAll("\x1b[3m");
if (self.style.flags.blink) try writer.writeAll("\x1b[5m");
if (self.style.flags.inverse) try writer.writeAll("\x1b[7m");
if (self.style.flags.invisible) try writer.writeAll("\x1b[8m");
if (self.style.flags.strikethrough) try writer.writeAll("\x1b[9m");
if (self.style.flags.overline) try writer.writeAll("\x1b[53m");
if (self.style.flags.bold) {
buf[len..][0..4].* = "\x1b[1m".*;
len += 4;
}
if (self.style.flags.faint) {
buf[len..][0..4].* = "\x1b[2m".*;
len += 4;
}
if (self.style.flags.italic) {
buf[len..][0..4].* = "\x1b[3m".*;
len += 4;
}
if (self.style.flags.blink) {
buf[len..][0..4].* = "\x1b[5m".*;
len += 4;
}
if (self.style.flags.inverse) {
buf[len..][0..4].* = "\x1b[7m".*;
len += 4;
}
if (self.style.flags.invisible) {
buf[len..][0..4].* = "\x1b[8m".*;
len += 4;
}
if (self.style.flags.strikethrough) {
buf[len..][0..4].* = "\x1b[9m".*;
len += 4;
}
if (self.style.flags.overline) {
buf[len..][0..5].* = "\x1b[53m".*;
len += 5;
}
switch (self.style.flags.underline) {
.none => {},
.single => try writer.writeAll("\x1b[4m"),
.double => try writer.writeAll("\x1b[4:2m"),
.curly => try writer.writeAll("\x1b[4:3m"),
.dotted => try writer.writeAll("\x1b[4:4m"),
.dashed => try writer.writeAll("\x1b[4:5m"),
}
// Various RGB colors.
try self.formatColor(writer, 38, self.style.fg_color);
try self.formatColor(writer, 48, self.style.bg_color);
try self.formatColor(writer, 58, self.style.underline_color);
}
fn formatColor(
self: VTFormatter,
writer: *std.Io.Writer,
prefix: u8,
value: Color,
) !void {
// Style emission is a hot path when formatting styled terminal
// contents, so the sequences are assembled in a buffer and
// written in one call rather than going through the (slower)
// format string machinery.
var buf: [24]u8 = undefined;
var len: usize = 0;
switch (value) {
.none => return,
// Direct RGB: `\x1b[{prefix};2;{r};{g};{b}m`
.palette => |idx| {
if (self.palette) |p| {
len = formatColorRgb(&buf, prefix, p[idx]);
} else {
// Palette reference: `\x1b[{prefix};5;{idx}m`
buf[0..2].* = "\x1b[".*;
len = 2;
len += fastprint.printDecimal(u8, buf[len..], prefix);
buf[len..][0..3].* = ";5;".*;
len += 3;
len += fastprint.printDecimal(u8, buf[len..], idx);
buf[len] = 'm';
len += 1;
}
.single => {
buf[len..][0..4].* = "\x1b[4m".*;
len += 4;
},
.double => {
buf[len..][0..6].* = "\x1b[4:2m".*;
len += 6;
},
.curly => {
buf[len..][0..6].* = "\x1b[4:3m".*;
len += 6;
},
.dotted => {
buf[len..][0..6].* = "\x1b[4:4m".*;
len += 6;
},
.dashed => {
buf[len..][0..6].* = "\x1b[4:5m".*;
len += 6;
},
.rgb => |rgb| len = formatColorRgb(&buf, prefix, rgb),
}
// Various colors.
len += self.appendColor(buf[len..], 38, self.style.fg_color);
len += self.appendColor(buf[len..], 48, self.style.bg_color);
len += self.appendColor(buf[len..], 58, self.style.underline_color);
try writer.writeAll(buf[0..len]);
}
/// Writes `\x1b[{prefix};2;{r};{g};{b}m` into buf, returning the
/// Appends a standalone `\x1b[{prefix};5;{idx}m` or
/// `\x1b[{prefix};2;{r};{g};{b}m` sequence to buf, returning the
/// length written.
fn formatColorRgb(buf: *[24]u8, prefix: u8, rgb: color.RGB) usize {
fn appendColor(
self: VTFormatter,
buf: []u8,
prefix: u8,
value: Color,
) usize {
switch (value) {
.none => return 0,
.palette => |idx| {
// Direct RGB: `\x1b[{prefix};2;{r};{g};{b}m`
if (self.palette) |p| return appendColorRgb(
buf,
prefix,
p[idx],
);
// Palette reference: `\x1b[{prefix};5;{idx}m`
buf[0..2].* = "\x1b[".*;
var len: usize = 2;
len += fastprint.printDecimal(u8, buf[len..], prefix);
buf[len..][0..3].* = ";5;".*;
len += 3;
len += fastprint.printDecimal(u8, buf[len..], idx);
buf[len] = 'm';
len += 1;
return len;
},
.rgb => |rgb| return appendColorRgb(buf, prefix, rgb),
}
}
/// Appends `\x1b[{prefix};2;{r};{g};{b}m` to buf, returning the
/// length written.
fn appendColorRgb(buf: []u8, prefix: u8, rgb: color.RGB) usize {
buf[0..2].* = "\x1b[".*;
var len: usize = 2;
len += fastprint.printDecimal(u8, buf[len..], prefix);