diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-10 21:51:57 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-10 21:51:57 +0300 |
| commit | 425c86179791cd32e2d4ce0ae7f269940b4b0663 (patch) | |
| tree | e52c4383a6d69216cbe2a32638bf90841d7e2189 /vendor/libghostty-vt/src/terminal/formatter.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/formatter.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/formatter.zig | 1523 |
1 files changed, 1137 insertions, 386 deletions
diff --git a/vendor/libghostty-vt/src/terminal/formatter.zig b/vendor/libghostty-vt/src/terminal/formatter.zig index 553ea28d..4feedda5 100644 --- a/vendor/libghostty-vt/src/terminal/formatter.zig +++ b/vendor/libghostty-vt/src/terminal/formatter.zig @@ -1,5 +1,6 @@ const std = @import("std"); const assert = @import("../quirks.zig").inlineAssert; +const fastprint = @import("../fastprint.zig"); const lib = @import("lib.zig"); const Allocator = std.mem.Allocator; const color = @import("color.zig"); @@ -119,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. @@ -288,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, @@ -326,7 +435,47 @@ 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, + ) catch return error.WriteFailed; + } + } + + // Emit tabstop positions before the screen contents because setting + // them moves the cursor. Screen formatting will restore the requested + // cursor position afterwards. + if (self.opts.emit == .vt and self.extra.tabstops) { + // Clear all tabs (CSI 3 g) + try writer.print("\x1b[3g", .{}); + + // Set each configured tabstop by moving cursor and using HTS + for (0..self.terminal.cols) |col| { + if (self.terminal.tabstops.get(col)) { + // Move cursor to the column (1-indexed) + try writer.print("\x1b[{d}G", .{col + 1}); + // Set tab (HTS) + try writer.print("\x1bH", .{}); + } + } + + // Screen contents are formatted relative to the top-left. + try writer.writeAll("\x1b[H"); + + // If we have a pin_map, add the bytes we wrote to map. + if (self.pin_map) |*m| { + var discarding: std.Io.Writer.Discarding = .init(&.{}); + var extra_formatter: TerminalFormatter = self; + extra_formatter.content = .none; + extra_formatter.pin_map = null; + extra_formatter.extra = .none; + extra_formatter.extra.tabstops = true; + try extra_formatter.format(&discarding.writer); + + // Map all those bytes to the same pin. Use the top left to ensure + // the node pointer is always properly initialized. + m.map.append( m.alloc, self.terminal.screens.active.pages.getTopLeft(.screen), std.math.cast(usize, discarding.count) orelse return error.WriteFailed, @@ -336,7 +485,6 @@ pub const TerminalFormatter = struct { var screen_formatter: ScreenFormatter = .init(self.terminal.screens.active, self.opts); screen_formatter.content = self.content; - screen_formatter.extra = self.extra.screen; screen_formatter.pin_map = self.pin_map; try screen_formatter.format(writer); @@ -360,22 +508,6 @@ pub const TerminalFormatter = struct { } } - // Emit tabstop positions - if (self.extra.tabstops) { - // Clear all tabs (CSI 3 g) - try writer.print("\x1b[3g", .{}); - - // Set each configured tabstop by moving cursor and using HTS - for (0..self.terminal.cols) |col| { - if (self.terminal.tabstops.get(col)) { - // Move cursor to the column (1-indexed) - try writer.print("\x1b[{d}G", .{col + 1}); - // Set tab (HTS) - try writer.print("\x1bH", .{}); - } - } - } - // Emit keyboard modes such as ModifyOtherKeys if (self.extra.keyboard) { // Only emit if modify_other_keys_2 is true @@ -398,25 +530,25 @@ pub const TerminalFormatter = struct { extra_formatter.pin_map = null; extra_formatter.extra = .none; extra_formatter.extra.scrolling_region = self.extra.scrolling_region; - extra_formatter.extra.tabstops = self.extra.tabstops; extra_formatter.extra.keyboard = self.extra.keyboard; extra_formatter.extra.pwd = self.extra.pwd; try extra_formatter.format(&discarding.writer); - m.map.appendNTimes( + m.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; } } + + // Emit extra screen state last because terminal state such + // as scrolling regions can move the cursor, so we have to set + // cursor last. + screen_formatter.content = .none; + screen_formatter.extra = self.extra.screen; + try screen_formatter.format(writer); } }; @@ -563,6 +695,43 @@ pub const ScreenFormatter = struct { .html => return, } + // Emit cursor position before the other extras because restoring a + // pending wrap requires reprinting the cell at the right edge. That + // print uses and changes active screen state, so the requested style, + // hyperlink, protection, and charset must be restored afterwards. + if (self.extra.cursor) cursor: { + const cursor = &self.screen.cursor; + + // If we don't have pending wrap, then we can just use CUP. + if (!cursor.pending_wrap or cursor.x != self.screen.pages.cols - 1) { + try writer.print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 }); + break :cursor; + } + + // Pending wrap, we can't use CUP because it resets pending wrap. + const start_x = switch (cursor.page_cell.wide) { + .spacer_tail => cursor.x - 1, + .narrow, .wide, .spacer_head => cursor.x, + }; + + // Move cursor to the edge. + try writer.print( + "\x1b[{d};{d}H", + .{ cursor.y + 1, start_x + 1 }, + ); + + // Reformat the cell which sets the proper pending wrap state. + var cell_formatter: PageFormatter = .init( + cursor.page_pin.node.page(), + self.opts, + ); + cell_formatter.start_x = cursor.x; + cell_formatter.end_x = cursor.x; + cell_formatter.start_y = cursor.page_pin.y; + cell_formatter.end_y = cursor.page_pin.y; + try cell_formatter.format(writer); + } + // Emit current SGR style state if (self.extra.style) { const cursor = &self.screen.cursor; @@ -652,13 +821,6 @@ pub const ScreenFormatter = struct { } } - // Emit cursor position using CUP (CSI H) - if (self.extra.cursor) { - const cursor = &self.screen.cursor; - // CUP is 1-indexed - try writer.print("\x1b[{d};{d}H", .{ cursor.y + 1, cursor.x + 1 }); - } - // If we have a pin_map, we need to count how many bytes the extras // will emit so we can map them all to the same pin. We do this by // formatting to a discarding writer with content=none. @@ -671,21 +833,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; } @@ -737,10 +888,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| { @@ -763,27 +910,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| { - for (point_map.items) |coord| { - m.map.append(m.alloc, .{ - .node = chunk.node, - .x = coord.x, - .y = @intCast(coord.y), - }) catch return error.WriteFailed; - } - } } } }; @@ -831,16 +970,25 @@ pub const PageFormatter = struct { /// The x/y coordinate will be the coordinates within the page. /// /// Warning: there is a significant performance hit to track this - point_map: ?struct { - alloc: Allocator, - map: *std.ArrayList(Coordinate), - }, + point_map: ?PointMap, /// The previous trailing state from the prior page. If you're iterating /// over multiple pages this helps ensure that unwrapping and other /// accounting works properly. trailing_state: ?TrailingState, + /// See point_map. + 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 /// multiple pages are unwrapped properly, as well as other accounting /// we may do in the future. @@ -878,6 +1026,18 @@ pub const PageFormatter = struct { self: PageFormatter, writer: *std.Io.Writer, ) std.Io.Writer.Error!TrailingState { + // Specialize the hot path on the emitted format so that the + // per-cell loop contains no per-cell format dispatch. + switch (self.opts.emit) { + inline else => |emit| return self.formatWithStateEmit(writer, emit), + } + } + + fn formatWithStateEmit( + self: PageFormatter, + writer: *std.Io.Writer, + comptime emit: Format, + ) std.Io.Writer.Error!TrailingState { var blank_rows: usize = 0; var blank_cells: usize = 0; @@ -934,15 +1094,14 @@ pub const PageFormatter = struct { } // Wrap HTML output in monospace font styling - switch (self.opts.emit) { + switch (emit) { .plain => {}, .html => { // Setup our div. We use a buffer here that should always // fit the stuff we need, in order to make counting bytes easier. var buf: [1024]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const buf_writer = stream.writer(); + var buf_writer: std.Io.Writer = .fixed(&buf); // Monospace and whitespace preserving buf_writer.writeAll("<div style=\"font-family: monospace; white-space: pre;") catch return error.WriteFailed; @@ -959,7 +1118,7 @@ pub const PageFormatter = struct { buf_writer.writeAll("\">") catch return error.WriteFailed; - const header = stream.getWritten(); + const header = buf_writer.buffered(); try writer.writeAll(header); if (self.point_map) |*map| map.map.appendNTimes( map.alloc, @@ -971,8 +1130,7 @@ pub const PageFormatter = struct { .vt => { // OSC 10 sets foreground color, OSC 11 sets background color var buf: [512]u8 = undefined; - var stream = std.io.fixedBufferStream(&buf); - const buf_writer = stream.writer(); + var buf_writer: std.Io.Writer = .fixed(&buf); if (self.opts.foreground) |fg| { buf_writer.print( "\x1b]10;rgb:{x:0>2}/{x:0>2}/{x:0>2}\x1b\\", @@ -986,7 +1144,7 @@ pub const PageFormatter = struct { ) catch return error.WriteFailed; } - const header = stream.getWritten(); + const header = buf_writer.buffered(); try writer.writeAll(header); if (self.point_map) |*map| map.map.appendNTimes( map.alloc, @@ -996,8 +1154,23 @@ pub const PageFormatter = struct { }, } - // Our style for non-plain formats + // Our style for non-plain formats. Alongside the style itself we + // track the page-local interned style id it corresponds to (styles + // are interned per-page so id equality implies style equality). + // The id is only a fast-path hint: it is set to `invalid_style_id` + // whenever the current style didn't come from an interned id + // (e.g. bg-color-only cells which synthesize styles). + const invalid_style_id: u32 = std.math.maxInt(u32); var style: Style = .{}; + var style_id: u32 = 0; + + // Whether the codepoint map has any entries. Hoisted out of the + // per-codepoint path so that the common no-map case can use the + // fast cell run path below. + const cp_map_empty: bool = if (self.opts.codepoint_map) |m| + m.len == 0 + else + true; // Track hyperlink state for HTML output. We need to close </a> tags // when the hyperlink changes or ends. @@ -1052,11 +1225,12 @@ pub const PageFormatter = struct { // Reset style before emitting newlines to prevent background // colors from bleeding into the next line's leading cells. if (!style.default()) { - try self.formatStyleClose(writer); + try self.formatStyleClose(emit, writer); style = .{}; + style_id = 0; } - const sequence: []const u8 = switch (self.opts.emit) { + const sequence: []const u8 = switch (emit) { // Plaintext just uses standard newlines because newlines // on their own usually move the cursor back in anywhere // you type plaintext. @@ -1079,7 +1253,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 }; @@ -1115,8 +1289,41 @@ pub const PageFormatter = struct { if (!row.wrap_continuation or !self.opts.unwrap) blank_cells = 0; // Go through each cell and print it - for (cells_subset, row_start_x..) |*cell, x_usize| { - const x: size.CellCountInt = @intCast(x_usize); + var cell_i: usize = 0; + while (cell_i < cells_subset.len) : (cell_i += 1) { + const cell: *const Cell = &cells_subset[cell_i]; + const x: size.CellCountInt = @intCast(row_start_x + cell_i); + + // Fast path: runs of simple cells (single codepoint, no + // style/hyperlink transitions) are encoded in batches, + // avoiding all of the per-cell bookkeeping below. This is + // only valid when we have no codepoint map and when our + // current style/hyperlink state is known-stable. + if (cp_map_empty) fast: { + if (comptime formatStyled(emit)) { + if (style_id == invalid_style_id) break :fast; + } + + const consumed = try self.writeCellRun( + emit, + self.point_map != null, + writer, + cells_subset[cell_i..], + x, + y, + style_id, + current_hyperlink_id, + &blank_cells, + ); + + // Zero cells consumed means the first cell isn't + // eligible for the fast path; handle it below. + if (consumed == 0) break :fast; + + // The continue expression adds the final one. + cell_i += consumed - 1; + continue; + } // Skip spacers. These happen naturally when wide characters // are printed again on the screen (for well-behaved terminals!) @@ -1132,8 +1339,9 @@ pub const PageFormatter = struct { // If we're emitting styled output (not plaintext) and // the cell has some kind of styling or is not empty // then this isn't blank. - if (formatStyled(self.opts.emit) and - (!cell.isEmpty() or cell.hasStyling())) break :blank; + if (comptime formatStyled(emit)) { + if (!cell.isEmpty() or cell.hasStyling()) break :blank; + } // Cells with no text are blank if (!cell.hasText()) { @@ -1155,32 +1363,12 @@ pub const PageFormatter = struct { if (blank_cells > 0) { try writer.splatByteAll(' ', blank_cells); - if (self.point_map) |*map| { - // Map each blank cell to its coordinate. Blank cells can span - // multiple rows if they carry over from wrap continuation. - var remaining_blanks = blank_cells; - var blank_x = x; - var blank_y = y; - while (remaining_blanks > 0) : (remaining_blanks -= 1) { - if (blank_x > 0) { - // We have space in this row - blank_x -= 1; - } else if (blank_y > 0) { - // Wrap to previous row - blank_y -= 1; - blank_x = self.page.size.cols - 1; - } else { - // Can't go back further, just use (0, 0) - blank_x = 0; - blank_y = 0; - } - - map.map.append( - map.alloc, - .{ .x = blank_x, .y = blank_y }, - ) catch return error.WriteFailed; - } - } + if (self.point_map) |*map| try self.appendBlankPoints( + map, + blank_cells, + x, + y, + ); blank_cells = 0; } @@ -1188,24 +1376,44 @@ pub const PageFormatter = struct { style: { // If we aren't emitting styled output then we don't // have to worry about styles. - if (!formatStyled(self.opts.emit)) break :style; + if (!comptime formatStyled(emit)) break :style; + + // Fast path: styles are interned per-page, so if this + // cell's style id matches the id of our current style + // then the style is unchanged. + const cell_style_id: u32 = switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => cell.style_id, + .bg_color_palette, .bg_color_rgb => invalid_style_id, + }; + if (cell_style_id == style_id and + cell_style_id != invalid_style_id) break :style; // Get our cell style. const cell_style = self.cellStyle(cell); // If the style hasn't changed, don't bloat output. - if (cell_style.eql(style)) break :style; + // 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, // because we've confirmed we have some new style // (which is maybe default). - if (!style.default()) switch (self.opts.emit) { - .html => try self.formatStyleClose(writer), + if (!style.default()) switch (emit) { + .html => try self.formatStyleClose(emit, writer), // For VT, we only close if we're switching to a default // style because any non-default style will emit // a \x1b[0m as the start of a VT coloring sequence. - .vt => if (cell_style.default()) try self.formatStyleClose(writer), + .vt => if (cell_style.default()) try self.formatStyleClose(emit, writer), // Unreachable because of the styled() check at the // top of this block. @@ -1214,12 +1422,14 @@ pub const PageFormatter = struct { // At this point, we can copy our style over style = cell_style; + style_id = cell_style_id; // If we're just the default style now, we're done. if (cell_style.default()) break :style; // New style, emit it. try self.formatStyleOpen( + emit, writer, &style, ); @@ -1229,16 +1439,18 @@ pub const PageFormatter = struct { if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); try self.formatStyleOpen( + emit, &discarding.writer, &style, ); - for (0..std.math.cast( - usize, - discarding.count, - ) orelse return error.WriteFailed) |_| map.map.append(map.alloc, .{ - .x = x, - .y = y, - }) catch return error.WriteFailed; + map.map.appendNTimes( + map.alloc, + .{ .x = x, .y = y }, + std.math.cast( + usize, + discarding.count, + ) orelse return error.WriteFailed, + ) catch return error.WriteFailed; } } @@ -1247,7 +1459,7 @@ pub const PageFormatter = struct { // We currently only emit hyperlinks for HTML. In the // future we can support emitting OSC 8 hyperlinks for // VT output as well. - if (self.opts.emit != .html) break :hyperlink; + if (comptime emit != .html) break :hyperlink; // Get the hyperlink ID. This ID is our internal ID, // not necessarily the OSC8 ID. @@ -1263,7 +1475,7 @@ pub const PageFormatter = struct { // If our prior hyperlink ID was non-null, we need to // close it because the ID has changed. if (current_hyperlink_id != null) { - try self.formatHyperlinkClose(writer); + try self.formatHyperlinkClose(emit, writer); current_hyperlink_id = null; } @@ -1280,6 +1492,7 @@ pub const PageFormatter = struct { break :uri link.uri.offset.ptr(self.page.memory)[0..link.uri.len]; }; try self.formatHyperlinkOpen( + emit, writer, uri, ); @@ -1289,16 +1502,18 @@ pub const PageFormatter = struct { if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); try self.formatHyperlinkOpen( + emit, &discarding.writer, uri, ); - for (0..std.math.cast( - usize, - discarding.count, - ) orelse return error.WriteFailed) |_| map.map.append(map.alloc, .{ - .x = x, - .y = y, - }) catch return error.WriteFailed; + map.map.appendNTimes( + map.alloc, + .{ .x = x, .y = y }, + std.math.cast( + usize, + discarding.count, + ) orelse return error.WriteFailed, + ) catch return error.WriteFailed; } } @@ -1306,20 +1521,21 @@ pub const PageFormatter = struct { // We combine codepoint and graphemes because both have // shared style handling. We use comptime to dup it. inline .codepoint, .codepoint_grapheme => |tag| { - try self.writeCell(tag, writer, cell); + try self.writeCell(tag, emit, writer, cell); // If we have a point map, all codepoints map to this // cell. if (self.point_map) |*map| { var discarding: std.Io.Writer.Discarding = .init(&.{}); - try self.writeCell(tag, &discarding.writer, cell); - for (0..std.math.cast( - usize, - discarding.count, - ) orelse return error.WriteFailed) |_| map.map.append(map.alloc, .{ - .x = x, - .y = y, - }) catch return error.WriteFailed; + try self.writeCell(tag, emit, &discarding.writer, cell); + map.map.appendNTimes( + map.alloc, + .{ .x = x, .y = y }, + std.math.cast( + usize, + discarding.count, + ) orelse return error.WriteFailed, + ) catch return error.WriteFailed; } }, @@ -1337,13 +1553,13 @@ pub const PageFormatter = struct { } // If the style is non-default, we need to close our style tag. - if (!style.default()) try self.formatStyleClose(writer); + if (!style.default()) try self.formatStyleClose(emit, writer); // Close any open hyperlink for HTML output - if (current_hyperlink_id != null) try self.formatHyperlinkClose(writer); + if (current_hyperlink_id != null) try self.formatHyperlinkClose(emit, writer); // Close the monospace wrapper for HTML output - if (self.opts.emit == .html) { + if (comptime emit == .html) { const closing = "</div>"; try writer.writeAll(closing); if (self.point_map) |*map| { @@ -1356,14 +1572,315 @@ pub const PageFormatter = struct { closing.len, ); } + // Closing the div creates a newline in the output + // so make sure to create one less newline + if (blank_rows >= 1) blank_rows -= 1; } return .{ .rows = blank_rows, .cells = blank_cells }; } + /// Fast path for writing runs of simple cells: single-codepoint cells + /// that require no style or hyperlink handling. Output bytes are + /// batched into a stack buffer to avoid per-cell writer dispatch. + /// Returns the number of cells consumed, which may be zero if the + /// first cell isn't eligible for the fast path (in which case the + /// caller must handle it via the slow path). + /// + /// Requirements (asserted by the caller, not here): + /// + /// - The codepoint map is empty. + /// - For styled formats, `run_style_id` is the valid interned + /// page-local id of the currently active style. + /// - For HTML, no hyperlink is currently open. + /// + /// `run_x`/`run_y` are the page coordinates of `cells[0]`, used for + /// point map tracking. + /// + /// Blank cell accounting matches the slow path: accumulated blanks + /// are only materialized once a non-blank cell is found, and any + /// remainder is written back to `blank_cells`. + // Deliberately not inlined: this is instantiated per emit format and + // inlining every copy into formatWithStateEmit's row loop bloats the + // binary. track_points is a runtime bool for the same reason: a + // comptime bool doubles the instantiation count for one predictable + // branch per emitted cell. + noinline fn writeCellRun( + self: *const PageFormatter, + comptime emit: Format, + track_points: bool, + writer: *std.Io.Writer, + cells: []const Cell, + 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)); + + // The largest single-cell encoding must fit after a flush: the + // HTML entity for the maximum codepoint ("�") is 10 + // bytes, escapes are up to 6. + const max_encoding_len = 16; + var buf: [512]u8 = undefined; + var len: usize = 0; + var pending: usize = blank_cells.*; + + var i: usize = 0; + while (i < cells.len) : (i += 1) { + const cell = &cells[i]; + + // Spacers produce no output, matching the slow path which + // skips them before any blank/style handling. + switch (cell.wide) { + .narrow, .wide => {}, + .spacer_head, .spacer_tail => continue, + } + + // Only text cells: bg-color cells synthesize styles and take + // the slow path. + switch (cell.content_tag) { + .codepoint, .codepoint_grapheme => {}, + .bg_color_palette, .bg_color_rgb => break, + } + + if (comptime formatStyled(emit)) { + // Style transition, take the slow path. + if (cell.style_id != run_style_id) break; + } + + const cp: u21 = cell.content.codepoint.data; + + // Blank cell accounting, matching the slow path blank block. + if (comptime formatStyled(emit)) { + // Styled formats only treat unstyled empty cells as + // blank; anything else (including spaces) is written + // so that styling is preserved. + if (cp == 0 and cell.wide == .narrow and run_style_id == 0) { + pending += 1; + continue; + } + } else { + // Cells with no text are blank. + if (cp == 0) { + pending += 1; + continue; + } + + // Trailing spaces are blank. + if (cp == ' ' and self.opts.trim) { + pending += 1; + continue; + } + } + + // 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); + + // This cell produces output: materialize accumulated blanks. + if (pending > 0) { + if (track_points) try self.appendBlankPoints( + &self.point_map.?, + pending, + x, + run_y, + ); + + while (pending > 0) { + if (len == buf.len) { + try writer.writeAll(buf[0..len]); + len = 0; + } + const n = @min(pending, buf.len - len); + @memset(buf[len..][0..n], ' '); + len += n; + pending -= n; + } + } + + // Flush if the largest possible encoding may not fit. + if (len + max_encoding_len > buf.len) { + try writer.writeAll(buf[0..len]); + len = 0; + } + + var cell_bytes: usize = 0; + + // Empty (but styled or wide) cells emit a space, matching + // writeCell. + if (cp == 0) { + buf[len] = ' '; + len += 1; + cell_bytes = 1; + } else { + cell_bytes = encodeCodepoint(emit, &buf, &len, cp); + + // Multi-codepoint graphemes emit their extra codepoints, + // matching writeCell. This is out-of-line to keep the + // hot loop for the common single-codepoint case small. + if (cell.content_tag == .codepoint_grapheme) { + @branchHint(.unlikely); + cell_bytes += try self.writeGraphemeCps( + emit, + writer, + cell, + &buf, + &len, + ); + } + } + + // All of the cell's bytes map to the cell's coordinate. + if (track_points) { + const map = &self.point_map.?; + map.map.appendNTimes( + map.alloc, + .{ .x = x, .y = run_y }, + cell_bytes, + ) catch return error.WriteFailed; + } + } + + if (len > 0) try writer.writeAll(buf[0..len]); + blank_cells.* = pending; + return i; + } + + /// Encode the extra codepoints of a multi-codepoint grapheme into + /// buf, flushing to the writer as needed. Returns the number of + /// bytes written. This is deliberately not inlined so that the + /// (rare) grapheme case doesn't bloat the writeCellRun hot loop. + noinline fn writeGraphemeCps( + self: *const PageFormatter, + comptime emit: Format, + writer: *std.Io.Writer, + cell: *const Cell, + buf: *[512]u8, + len: *usize, + ) std.Io.Writer.Error!usize { + const max_encoding_len = 16; + var bytes: usize = 0; + for (self.page.lookupGrapheme(cell).?) |gcp| { + if (len.* + max_encoding_len > buf.len) { + try writer.writeAll(buf[0..len.*]); + len.* = 0; + } + bytes += encodeCodepoint(emit, buf, len, gcp); + } + return bytes; + } + + /// Encode a single codepoint into buf at len, advancing len and + /// returning the number of bytes written. The caller must guarantee + /// enough remaining buffer space for the largest possible encoding. + inline fn encodeCodepoint( + comptime emit: Format, + buf: *[512]u8, + len: *usize, + cp: u21, + ) usize { + const start = len.*; + switch (emit) { + .plain, .vt => if (cp < 0x80) { + buf[start] = @intCast(cp); + len.* += 1; + } else { + len.* += std.unicode.utf8Encode(cp, buf[start..][0..4]) catch l: { + // Matches Writer.printUnicodeCodepoint: invalid + // codepoints become the replacement character. + buf[start..][0..3].* = std.unicode.replacement_character_utf8; + break :l 3; + }; + }, + + .html => html: { + const esc: ?[]const u8 = switch (cp) { + '<' => "<", + '>' => ">", + '&' => "&", + '"' => """, + '\'' => "'", + else => null, + }; + if (esc) |s| { + @memcpy(buf[start..][0..s.len], s); + len.* += s.len; + break :html; + } + + // ASCII is emitted directly, everything else as a + // numeric entity. See writeCodepoint. + if (cp < 0x80) { + buf[start] = @intCast(cp); + len.* += 1; + break :html; + } + + buf[start..][0..2].* = "&#".*; + len.* += 2; + len.* += fastprint.printDecimal(u21, buf[len.*..], cp); + buf[len.*] = ';'; + len.* += 1; + }, + } + + return len.* - start; + } + + /// Append the point map entries for a run of `count` blank cells + /// that are materialized as spaces just before the cell at (x, y). + /// Blank cells can span multiple rows if they carry over from wrap + /// continuation, so this walks backwards from (x, y). + fn appendBlankPoints( + self: *const PageFormatter, + map: *const PointMap, + count: usize, + x: size.CellCountInt, + y: size.CellCountInt, + ) std.Io.Writer.Error!void { + map.map.ensureUnusedCapacity( + map.alloc, + count, + ) catch return error.WriteFailed; + + var remaining = count; + var blank_x = x; + var blank_y = y; + while (remaining > 0) : (remaining -= 1) { + if (blank_x > 0) { + // We have space in this row + blank_x -= 1; + } else if (blank_y > 0) { + // Wrap to previous row + blank_y -= 1; + blank_x = self.page.size.cols - 1; + } else { + // Can't go back further, just use (0, 0) + blank_x = 0; + blank_y = 0; + } + + map.map.appendAssumeCapacity(.{ .x = blank_x, .y = blank_y }); + } + } + fn writeCell( self: PageFormatter, comptime tag: Cell.ContentTag, + comptime emit: Format, writer: *std.Io.Writer, cell: *const Cell, ) !void { @@ -1375,16 +1892,17 @@ pub const PageFormatter = struct { return; } - try self.writeCodepointWithReplacement(writer, cell.content.codepoint); + try self.writeCodepointWithReplacement(emit, writer, cell.content.codepoint.data); if (comptime tag == .codepoint_grapheme) { for (self.page.lookupGrapheme(cell).?) |cp| { - try self.writeCodepointWithReplacement(writer, cp); + try self.writeCodepointWithReplacement(emit, writer, cp); } } } fn writeCodepointWithReplacement( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, codepoint: u21, ) !void { @@ -1406,12 +1924,14 @@ pub const PageFormatter = struct { // If no replacement, write it directly. const r = r_ orelse return try self.writeCodepoint( + emit, writer, codepoint, ); switch (r) { .codepoint => |v| try self.writeCodepoint( + emit, writer, v, ), @@ -1420,6 +1940,7 @@ pub const PageFormatter = struct { const view = std.unicode.Utf8View.init(s) catch unreachable; var it = view.iterator(); while (it.nextCodepoint()) |cp| try self.writeCodepoint( + emit, writer, cp, ); @@ -1429,11 +1950,13 @@ pub const PageFormatter = struct { fn writeCodepoint( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, codepoint: u21, ) !void { - switch (self.opts.emit) { - .plain, .vt => try writer.print("{u}", .{codepoint}), + _ = self; + switch (emit) { + .plain, .vt => try writer.printUnicodeCodepoint(codepoint), .html => { switch (codepoint) { '<' => try writer.writeAll("<"), @@ -1448,9 +1971,14 @@ pub const PageFormatter = struct { // meta tag because we emit partial HTML so this ensures // proper unicode handling. if (codepoint < 0x80) { - try writer.print("{u}", .{codepoint}); + try writer.writeByte(@intCast(codepoint)); } else { - try writer.print("&#{d};", .{codepoint}); + var buf: [16]u8 = undefined; + buf[0..2].* = "&#".*; + var len: usize = 2 + fastprint.printDecimal(u21, buf[2..], codepoint); + buf[len] = ';'; + len += 1; + try writer.writeAll(buf[0..len]); } }, } @@ -1475,7 +2003,7 @@ pub const PageFormatter = struct { .bg_color_palette => .{ .bg_color = .{ - .palette = cell.content.color_palette, + .palette = cell.content.color_palette.data, }, }, @@ -1495,16 +2023,17 @@ pub const PageFormatter = struct { /// and other HTML attribute values. fn formatStyleOpen( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, style: *const Style, ) std.Io.Writer.Error!void { - switch (self.opts.emit) { + switch (emit) { .plain => unreachable, .vt => { var formatter = style.formatterVt(); formatter.palette = self.opts.palette; - try writer.print("{f}", .{formatter}); + try formatter.format(writer); }, // We use `display: inline` so that the div doesn't impact @@ -1512,19 +2041,19 @@ pub const PageFormatter = struct { .html => { var formatter = style.formatterHtml(); formatter.palette = self.opts.palette; - try writer.print( - "<div style=\"display: inline;{f}\">", - .{formatter}, - ); + try writer.writeAll("<div style=\"display: inline;"); + try formatter.format(writer); + try writer.writeAll("\">"); }, } } fn formatStyleClose( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { - const str: []const u8 = switch (self.opts.emit) { + const str: []const u8 = switch (emit) { .plain => return, .vt => "\x1b[0m", .html => "</div>", @@ -1546,16 +2075,18 @@ pub const PageFormatter = struct { fn formatHyperlinkOpen( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, uri: []const u8, ) std.Io.Writer.Error!void { - switch (self.opts.emit) { + switch (emit) { .plain, .vt => unreachable, // layout since we're primarily using it as a CSS wrapper. .html => { try writer.writeAll("<a href=\""); for (uri) |byte| try self.writeCodepoint( + emit, writer, byte, ); @@ -1566,9 +2097,10 @@ pub const PageFormatter = struct { fn formatHyperlinkClose( self: PageFormatter, + comptime emit: Format, writer: *std.Io.Writer, ) std.Io.Writer.Error!void { - const str: []const u8 = switch (self.opts.emit) { + const str: []const u8 = switch (emit) { .html => "</a>", .plain, .vt => return, }; @@ -1591,11 +2123,12 @@ pub const PageFormatter = struct { test "Page plain single line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -1638,11 +2171,12 @@ test "Page plain single line" { test "Page plain single line soft-wrapped unwrapped" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 3, .rows = 5, }); @@ -1708,11 +2242,12 @@ test "Page plain single line soft-wrapped unwrapped" { test "Page plain single wide char" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -1799,11 +2334,12 @@ test "Page plain single wide char" { test "Page plain single wide char soft-wrapped unwrapped" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 3, .rows = 24, }); @@ -1916,11 +2452,12 @@ test "Page plain single wide char soft-wrapped unwrapped" { test "Page plain multiline" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -1967,11 +2504,12 @@ test "Page plain multiline" { test "Page plain multiline rectangle" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2021,11 +2559,12 @@ test "Page plain multiline rectangle" { test "Page plain multi blank lines" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2074,11 +2613,12 @@ test "Page plain multi blank lines" { test "Page plain trailing blank lines" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2127,11 +2667,12 @@ test "Page plain trailing blank lines" { test "Page plain trailing whitespace" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2180,11 +2721,12 @@ test "Page plain trailing whitespace" { test "Page plain trailing whitespace no trim" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2236,11 +2778,12 @@ test "Page plain trailing whitespace no trim" { test "Page plain with prior trailing state rows" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2282,11 +2825,12 @@ test "Page plain with prior trailing state rows" { test "Page plain with prior trailing state cells no wrapped line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2327,11 +2871,12 @@ test "Page plain with prior trailing state cells no wrapped line" { test "Page plain with prior trailing state cells with wrap continuation" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2381,11 +2926,12 @@ test "Page plain with prior trailing state cells with wrap continuation" { test "Page plain soft-wrapped without unwrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 10, .rows = 24, }); @@ -2430,11 +2976,12 @@ test "Page plain soft-wrapped without unwrap" { test "Page plain soft-wrapped with unwrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 10, .rows = 24, }); @@ -2478,11 +3025,12 @@ test "Page plain soft-wrapped with unwrap" { test "Page plain soft-wrapped 3 lines without unwrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 10, .rows = 24, }); @@ -2532,11 +3080,12 @@ test "Page plain soft-wrapped 3 lines without unwrap" { test "Page plain soft-wrapped 3 lines with unwrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 10, .rows = 24, }); @@ -2584,11 +3133,12 @@ test "Page plain soft-wrapped 3 lines with unwrap" { test "Page plain start_y subset" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2631,11 +3181,12 @@ test "Page plain start_y subset" { test "Page plain end_y subset" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2678,11 +3229,12 @@ test "Page plain end_y subset" { test "Page plain start_y and end_y range" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2726,11 +3278,12 @@ test "Page plain start_y and end_y range" { test "Page plain start_y out of bounds" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2764,11 +3317,12 @@ test "Page plain start_y out of bounds" { test "Page plain end_y greater than rows" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2807,11 +3361,12 @@ test "Page plain end_y greater than rows" { test "Page plain end_y less than start_y" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2846,11 +3401,12 @@ test "Page plain end_y less than start_y" { test "Page plain start_x on first row only" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2888,11 +3444,12 @@ test "Page plain start_x on first row only" { test "Page plain end_x on last row only" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2941,11 +3498,12 @@ test "Page plain end_x on last row only" { test "Page plain start_x and end_x multiline" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -2998,11 +3556,12 @@ test "Page plain start_x and end_x multiline" { test "Page plain start_x out of bounds" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3036,11 +3595,12 @@ test "Page plain start_x out of bounds" { test "Page plain end_x greater than cols" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3078,11 +3638,12 @@ test "Page plain end_x greater than cols" { test "Page plain end_x less than start_x single row" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3118,11 +3679,12 @@ test "Page plain end_x less than start_x single row" { test "Page plain start_y non-zero ignores trailing state" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3162,11 +3724,12 @@ test "Page plain start_y non-zero ignores trailing state" { test "Page plain start_x non-zero ignores trailing state" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3206,11 +3769,12 @@ test "Page plain start_x non-zero ignores trailing state" { test "Page plain start_y and start_x zero uses trailing state" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3253,11 +3817,12 @@ test "Page plain start_y and start_x zero uses trailing state" { test "Page plain single line with styling" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3299,11 +3864,12 @@ test "Page plain single line with styling" { test "Page VT single line plain text" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3338,11 +3904,12 @@ test "Page VT single line plain text" { test "Page VT single line with bold" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3384,11 +3951,12 @@ test "Page VT single line with bold" { test "Page VT multiple styles" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3419,11 +3987,12 @@ test "Page VT multiple styles" { test "Page VT with foreground color" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3465,11 +4034,12 @@ test "Page VT with foreground color" { test "Page VT with background and foreground colors" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3502,11 +4072,12 @@ test "Page VT with background and foreground colors" { test "Page VT multi-line with styles" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3539,11 +4110,12 @@ test "Page VT multi-line with styles" { test "Page VT duplicate style not emitted twice" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3574,11 +4146,12 @@ test "Page VT duplicate style not emitted twice" { test "PageList plain single line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3589,7 +4162,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); @@ -3599,22 +4172,23 @@ 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).?, ); } test "PageList plain spanning two pages" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3641,7 +4215,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); @@ -3652,42 +4226,43 @@ 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); } } test "PageList soft-wrapped line spanning two pages without unwrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 10, .rows = 3, }); @@ -3707,7 +4282,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); @@ -3718,40 +4293,41 @@ 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); } } test "PageList soft-wrapped line spanning two pages with unwrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 10, .rows = 3, }); @@ -3771,7 +4347,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 }); @@ -3782,37 +4358,38 @@ 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); } } test "PageList VT spanning two pages" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3839,7 +4416,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); @@ -3850,14 +4427,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; } @@ -3868,11 +4446,12 @@ test "PageList VT spanning two pages" { test "PageList plain with x offset on single page" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3886,7 +4465,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); @@ -3899,26 +4478,28 @@ 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); } } test "PageList plain with x offset spanning two pages" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -3946,7 +4527,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); @@ -3960,35 +4541,36 @@ 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); } } test "PageList plain with start_x only" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4002,7 +4584,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); @@ -4014,22 +4596,23 @@ 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); } } test "PageList plain with end_x only" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4043,7 +4626,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); @@ -4055,34 +4638,35 @@ 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); } } test "PageList plain rectangle basic" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 30, .rows = 5, }); @@ -4118,11 +4702,12 @@ test "PageList plain rectangle basic" { test "PageList plain rectangle with EOL" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 30, .rows = 5, }); @@ -4160,11 +4745,12 @@ test "PageList plain rectangle with EOL" { test "PageList plain rectangle more complex with breaks" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 30, .rows = 8, }); @@ -4206,11 +4792,12 @@ test "PageList plain rectangle more complex with breaks" { test "TerminalFormatter plain no selection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4230,11 +4817,12 @@ test "TerminalFormatter plain no selection" { test "TerminalFormatter vt with palette" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4255,7 +4843,7 @@ test "TerminalFormatter vt with palette" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4275,11 +4863,12 @@ test "TerminalFormatter vt with palette" { test "TerminalFormatter with selection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4304,11 +4893,12 @@ test "TerminalFormatter with selection" { test "TerminalFormatter plain with pin_map" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4319,7 +4909,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); @@ -4330,22 +4920,23 @@ 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).?, ); } test "TerminalFormatter plain multiline with pin_map" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4356,7 +4947,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); @@ -4367,33 +4958,34 @@ 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); } } test "TerminalFormatter vt with palette and pin_map" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4406,7 +4998,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); @@ -4416,21 +5008,22 @@ 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); } } test "TerminalFormatter with selection and pin_map" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4441,7 +5034,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); @@ -4457,24 +5050,25 @@ 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); } } test "Screen plain single line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4485,7 +5079,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); @@ -4496,22 +5090,23 @@ 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).?, ); } test "Screen plain multiline" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4522,7 +5117,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); @@ -4533,33 +5128,34 @@ 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); } } test "Screen plain with selection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4570,7 +5166,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); @@ -4586,24 +5182,25 @@ 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); } } test "Screen vt with cursor position" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4615,7 +5212,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); @@ -4626,7 +5223,7 @@ test "Screen vt with cursor position" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4642,27 +5239,87 @@ 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); } } +test "Terminal vt cursor preserves pending wrap" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var source = try Terminal.init(io, alloc, .{ + .cols = 4, + .rows = 2, + }); + defer source.deinit(alloc); + + var source_stream = source.vtStream(); + defer source_stream.deinit(); + source_stream.nextSlice("abcd"); + try testing.expect(source.screens.active.cursor.pending_wrap); + + var pin_map: PinMap.Map = .empty; + defer pin_map.deinit(alloc); + + var formatter: TerminalFormatter = .init(&source, .vt); + formatter.extra = .none; + formatter.extra.screen.cursor = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; + try formatter.format(&builder.writer); + try testing.expectEqual(builder.writer.buffered().len, pin_map.count()); + + var target = try Terminal.init(io, alloc, .{ + .cols = 4, + .rows = 2, + }); + defer target.deinit(alloc); + + var target_stream = target.vtStream(); + defer target_stream.deinit(); + target_stream.nextSlice(builder.writer.buffered()); + + try testing.expectEqual( + source.screens.active.cursor.pending_wrap, + target.screens.active.cursor.pending_wrap, + ); + + source_stream.nextSlice("X"); + target_stream.nextSlice("X"); + const source_contents = try source.screens.active.dumpStringAlloc( + alloc, + .{ .screen = .{} }, + ); + defer alloc.free(source_contents); + const target_contents = try target.screens.active.dumpStringAlloc( + alloc, + .{ .screen = .{} }, + ); + defer alloc.free(target_contents); + try testing.expectEqualStrings(source_contents, target_contents); +} + test "Screen vt with style" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4674,7 +5331,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); @@ -4685,7 +5342,7 @@ test "Screen vt with style" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4700,21 +5357,22 @@ 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); } } test "Screen vt with hyperlink" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4726,7 +5384,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); @@ -4737,7 +5395,7 @@ test "Screen vt with hyperlink" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4760,21 +5418,22 @@ 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); } } test "Screen vt with protection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4786,7 +5445,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); @@ -4797,7 +5456,7 @@ test "Screen vt with protection" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4812,21 +5471,22 @@ 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); } } test "Screen vt with kitty keyboard" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4838,7 +5498,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); @@ -4849,7 +5509,7 @@ test "Screen vt with kitty keyboard" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4866,21 +5526,22 @@ 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); } } test "Screen vt with charsets" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4892,7 +5553,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); @@ -4903,7 +5564,7 @@ test "Screen vt with charsets" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4923,21 +5584,22 @@ 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); } } test "Terminal vt with scrolling region" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4956,7 +5618,7 @@ test "Terminal vt with scrolling region" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -4977,11 +5639,12 @@ test "Terminal vt with scrolling region" { test "Terminal vt with modes" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5003,7 +5666,7 @@ test "Terminal vt with modes" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5023,11 +5686,12 @@ test "Terminal vt with modes" { test "Terminal vt with tabstops" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5041,16 +5705,21 @@ test "Terminal vt with tabstops" { s.nextSlice("\x1b[5G\x1bH"); // Set tab at column 5 s.nextSlice("\x1b[15G\x1bH"); // Set tab at column 15 s.nextSlice("\x1b[30G\x1bH"); // Set tab at column 30 - s.nextSlice("hello"); + s.nextSlice("\x1b[Hhello"); + + var pin_map: PinMap.Map = .empty; + defer pin_map.deinit(alloc); var formatter: TerminalFormatter = .init(&t, .vt); formatter.extra.tabstops = true; + formatter.extra.screen.cursor = true; + formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; try formatter.format(&builder.writer); const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5069,16 +5738,33 @@ test "Terminal vt with tabstops" { try testing.expect(t2.tabstops.get(14)); // Column 15 (1-indexed) try testing.expect(t2.tabstops.get(29)); // Column 30 (1-indexed) try testing.expect(!t2.tabstops.get(8)); // Not a tab + + // Tabstop serialization must not offset the screen contents. + for ("hello", 0..) |expected, col| { + const cell = t2.screens.active.pages.getCell(.{ + .screen = .{ .x = @intCast(col), .y = 0 }, + }).?; + try testing.expectEqual(expected, cell.cell.codepoint()); + } + + // Emitting tabstops moves the cursor to each configured column. When + // cursor state is included, it must be restored afterwards. + try testing.expectEqual(t.screens.active.cursor.x, t2.screens.active.cursor.x); + try testing.expectEqual(t.screens.active.cursor.y, t2.screens.active.cursor.y); + + // Verify the reordered terminal state is still represented in the map. + try testing.expectEqual(output.len, pin_map.count()); } test "Terminal vt with keyboard modes" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5098,7 +5784,7 @@ test "Terminal vt with keyboard modes" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5117,11 +5803,12 @@ test "Terminal vt with keyboard modes" { test "Terminal vt with pwd" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5140,7 +5827,7 @@ test "Terminal vt with pwd" { const output = builder.writer.buffered(); // Create a second terminal and apply the output - var t2 = try Terminal.init(alloc, .{ + var t2 = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5158,11 +5845,12 @@ test "Terminal vt with pwd" { test "Page html with multiple styles" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5194,11 +5882,12 @@ test "Page html with multiple styles" { test "Page html plain text" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5226,11 +5915,12 @@ test "Page html plain text" { test "Page html with colors" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5260,11 +5950,12 @@ test "Page html with colors" { test "TerminalFormatter html with palette" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5297,11 +5988,12 @@ test "TerminalFormatter html with palette" { test "Page html with background and foreground colors" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5332,11 +6024,12 @@ test "Page html with background and foreground colors" { test "Page html with escaping" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5402,11 +6095,12 @@ test "Page html with escaping" { test "Page html with unicode as numeric entities" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5433,14 +6127,52 @@ test "Page html with unicode as numeric entities" { ); } +test "Page html trailing blank lines" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var builder: std.Io.Writer.Allocating = .init(alloc); + defer builder.deinit(); + + var t = try Terminal.init(io, alloc, .{ + .cols = 80, + .rows = 24, + }); + defer t.deinit(alloc); + + var s = t.vtStream(); + defer s.deinit(); + + s.nextSlice("hello\r\nworld\r\n\r\n"); + + const pages = &t.screens.active.pages; + try testing.expect(pages.pages.first != null); + try testing.expect(pages.pages.first == pages.pages.last); + + const page = pages.pages.last.?.page(); + var formatter: PageFormatter = .init(page, .{ .emit = .html }); + + const state = try formatter.formatWithState(&builder.writer); + const output = builder.writer.buffered(); + + // The closing div behaves as a newline + try testing.expectEqual(@as(usize, page.size.rows - 2), state.rows); + try testing.expectEqualStrings( + "<div style=\"font-family: monospace; white-space: pre;\">hello\nworld</div>", + output, + ); +} + test "Page html ascii characters unchanged" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5468,11 +6200,12 @@ test "Page html ascii characters unchanged" { test "Page html mixed ascii and unicode" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5500,11 +6233,12 @@ test "Page html mixed ascii and unicode" { test "Page VT with palette option emits RGB" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5544,11 +6278,12 @@ test "Page VT with palette option emits RGB" { test "Page html with palette option emits RGB" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5598,11 +6333,12 @@ test "Page html with palette option emits RGB" { test "Page VT style reset properly closes styles" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5629,11 +6365,12 @@ test "Page VT style reset properly closes styles" { test "Page codepoint_map single replacement" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5688,11 +6425,12 @@ test "Page codepoint_map single replacement" { test "Page codepoint_map conflicting replacement prefers last" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5730,11 +6468,12 @@ test "Page codepoint_map conflicting replacement prefers last" { test "Page codepoint_map replace with string" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5786,11 +6525,12 @@ test "Page codepoint_map replace with string" { test "Page codepoint_map range replacement" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5824,11 +6564,12 @@ test "Page codepoint_map range replacement" { test "Page codepoint_map multiple ranges" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5868,11 +6609,12 @@ test "Page codepoint_map multiple ranges" { test "Page codepoint_map unicode replacement" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -5933,11 +6675,12 @@ test "Page codepoint_map unicode replacement" { test "Page codepoint_map with styled formats" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 10, .rows = 24, }); @@ -5974,11 +6717,12 @@ test "Page codepoint_map with styled formats" { test "Page codepoint_map empty map" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -6011,11 +6755,12 @@ test "Page VT background color on trailing blank cells" { // This causes TUIs like htop to lose background colors on rehydration. const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 20, .rows = 5, }); @@ -6062,11 +6807,12 @@ test "Page VT background color on trailing blank cells" { test "Page HTML with hyperlinks" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -6096,11 +6842,12 @@ test "Page HTML with hyperlinks" { test "Page HTML with multiple hyperlinks" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -6133,11 +6880,12 @@ test "Page HTML with multiple hyperlinks" { test "Page HTML with hyperlink escaping" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -6167,11 +6915,12 @@ test "Page HTML with hyperlink escaping" { test "Page HTML with styled hyperlink" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -6202,11 +6951,12 @@ test "Page HTML with styled hyperlink" { test "Page HTML hyperlink closes style before anchor" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); @@ -6237,11 +6987,12 @@ test "Page HTML hyperlink closes style before anchor" { test "Page HTML hyperlink point map maps closing to previous cell" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; var builder: std.Io.Writer.Allocating = .init(alloc); defer builder.deinit(); - var t = try Terminal.init(alloc, .{ + var t = try Terminal.init(io, alloc, .{ .cols = 80, .rows = 24, }); |
