mirror of
https://github.com/ghostty-org/ghostty.git
synced 2026-08-05 07:08:39 +00:00
terminal: redesign pin map for formatter
This commit is contained in:
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user