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/Screen.zig | |
| parent | 90e947a6e26e02f10ec7f1694c4eb5b78a86406b (diff) | |
chore: upgrade libghostty and preserve terminal behavior (#3906)
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/Screen.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/Screen.zig | 2078 |
1 files changed, 1603 insertions, 475 deletions
diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig index 6c4db323..208e276a 100644 --- a/vendor/libghostty-vt/src/terminal/Screen.zig +++ b/vendor/libghostty-vt/src/terminal/Screen.zig @@ -8,6 +8,7 @@ const ansi = @import("ansi.zig"); const charsets = @import("charsets.zig"); const fastmem = @import("../fastmem.zig"); const kitty = @import("kitty.zig"); +const lib = @import("lib.zig"); const sgr = @import("sgr.zig"); const tripwire = @import("../tripwire.zig"); const unicode = @import("../unicode/main.zig"); @@ -16,6 +17,7 @@ const PageList = @import("PageList.zig"); const selection_codepoints = @import("selection_codepoints.zig"); const StringMap = @import("StringMap.zig"); const ScreenFormatter = @import("formatter.zig").ScreenFormatter; +const PinMap = @import("formatter.zig").PinMap; const osc = @import("osc.zig"); const pagepkg = @import("page.zig"); const point = @import("point.zig"); @@ -32,6 +34,9 @@ pub const CursorStyle = @import("cursor.zig").Style; const log = std.log.scoped(.screen); +/// The I/O implementation to use for any operation that requires one. +io: std.Io, + /// The general purpose allocator to use for all memory allocations. /// Unfortunately some screen operations do require allocation. alloc: Allocator, @@ -111,7 +116,12 @@ pub const SemanticPrompt = struct { .click = .none, }; - pub const SemanticClick = union(enum) { + pub const SemanticClickKind = lib.Enum( + lib.target, + &.{ "none", "click_events", "cl" }, + ); + + pub const SemanticClick = union(SemanticClickKind) { none, click_events: osc.semantic_prompt.ClickEvents, cl: osc.semantic_prompt.Click, @@ -250,9 +260,15 @@ pub const Options = struct { cols: size.CellCountInt, rows: size.CellCountInt, - /// The maximum size of scrollback in bytes. Zero means unlimited. Any - /// other value will be clamped to support a minimum of the active area. - max_scrollback: usize = 0, + /// The maximum size of scrollback in bytes. Null is unlimited, zero + /// disables scrollback, and any other value is clamped to support a + /// minimum of the active area. + max_scrollback_bytes: ?usize = 0, + + /// The maximum number of physical scrollback rows, excluding the active + /// area. Null is unlimited. The effective limit permits at least one + /// standard page and only complete historical pages are pruned. + max_scrollback_lines: ?usize = null, /// The total storage limit for Kitty images in bytes for this /// screen. Kitty image storage is per-screen. @@ -273,28 +289,29 @@ pub const Options = struct { pub const default: Options = .{ .cols = 80, .rows = 24, - .max_scrollback = 0, + .max_scrollback_bytes = 0, }; }; /// Initialize a new screen. /// -/// max_scrollback is the amount of scrollback to keep in bytes. This +/// max_scrollback_bytes is the amount of scrollback to keep in bytes. This /// will be rounded UP to the nearest page size because our minimum allocation /// size is that anyways. /// /// If max scrollback is 0, then no scrollback is kept at all. pub fn init( + io: std.Io, alloc: Allocator, opts: Options, ) Allocator.Error!Screen { // Initialize our backing pages. - var pages = try PageList.init( - alloc, - opts.cols, - opts.rows, - opts.max_scrollback, - ); + var pages = try PageList.init(alloc, .{ + .cols = opts.cols, + .rows = opts.rows, + .max_size = opts.max_scrollback_bytes, + .max_lines = opts.max_scrollback_lines, + }); errdefer pages.deinit(); // Create our tracked pin for the cursor. @@ -303,9 +320,10 @@ pub fn init( const page_rac = page_pin.rowAndCell(); var result: Screen = .{ + .io = io, .alloc = alloc, .pages = pages, - .no_scrollback = opts.max_scrollback == 0, + .no_scrollback = opts.max_scrollback_bytes == 0, .cursor = .{ .x = 0, .y = 0, @@ -316,13 +334,12 @@ pub fn init( }; if (comptime build_options.kitty_graphics) { - // This can't fail because the storage is always empty at this point - // and the only fail-able case is that we have to evict images. result.kitty_images.setLimit( + io, alloc, &result, opts.kitty_image_storage_limit, - ) catch unreachable; + ); result.kitty_images.image_limits = opts.kitty_image_loading_limits; } @@ -361,6 +378,16 @@ pub fn assertIntegrity(self: *const Screen) void { ) orelse unreachable; assert(self.cursor.x == pt.active.x); assert(self.cursor.y == pt.active.y); + + // The cursor style and hyperlink if non-zero must reference + // real data in the page the pin is in. + const page: *const Page = self.cursor.page_pin.node.page(); + if (self.cursor.style_id != style.default_id) { + assert(page.styles.refCount(page.memory, self.cursor.style_id) > 0); + } + if (self.cursor.hyperlink_id != 0) { + assert(page.hyperlink_set.refCount(page.memory, self.cursor.hyperlink_id) > 0); + } } } @@ -380,11 +407,16 @@ pub fn reset(self: *Screen) void { self.pages.reset(); // The above reset preserves tracked pins so we can still use - // our cursor pin, which should be at the top-left already. + // our cursor pin, which should be at the top-left already. The + // reset marks every tracked pin as garbage, but we keep using + // this one at its new valid position, so clear the flag: copies + // of the cursor pin (e.g. for Kitty image placements) must not + // be born garbage. const cursor_pin: *PageList.Pin = self.cursor.page_pin; assert(cursor_pin.node == self.pages.pages.first.?); assert(cursor_pin.x == 0); assert(cursor_pin.y == 0); + cursor_pin.garbage = false; const cursor_rac = cursor_pin.rowAndCell(); self.cursor.deinit(self.alloc); self.cursor = .{ @@ -395,8 +427,14 @@ pub fn reset(self: *Screen) void { if (comptime build_options.kitty_graphics) { // Reset kitty graphics storage + const image_limits = self.kitty_images.image_limits; + const total_limit = self.kitty_images.total_limit; self.kitty_images.deinit(self.alloc, self); - self.kitty_images = .{ .dirty = true }; + self.kitty_images = .{ + .dirty = true, + .image_limits = image_limits, + .total_limit = total_limit, + }; } // Reset our basic state @@ -441,6 +479,7 @@ pub fn reset(self: *Screen) void { /// pub fn clone( self: *const Screen, + io: std.Io, alloc: Allocator, top: point.Point, bot: ?point.Point, @@ -565,6 +604,7 @@ pub fn clone( } else null; const result: Screen = .{ + .io = io, .alloc = alloc, .pages = pages, .no_scrollback = self.no_scrollback, @@ -575,6 +615,7 @@ pub fn clone( result.assertIntegrity(); return result; } + pub fn increaseCapacity( self: *Screen, node: *PageList.List.Node, @@ -645,6 +686,74 @@ pub fn increaseCapacity( return new_node; } +/// Clone the cells in columns [x_start, x_end) of a source row into +/// the row at `dst_y` of the given node's page, increasing the node's +/// capacity as necessary to fit the managed memory (styles, +/// hyperlinks, etc.) of the copied cells. +/// +/// This is the Screen-level analog of PageList.cloneRowGrowCapacity: +/// capacity increases are routed through Screen.increaseCapacity so +/// that the cursor's style/hyperlink references are migrated when the +/// destination node is the cursor's page. +/// +/// Since increasing capacity replaces the node in the page list, the +/// (possibly replaced) node is returned and the caller must use it in +/// place of the old node. Tracked pins are updated automatically. The +/// source must NOT be on the given node since the node's page memory +/// may be freed on capacity increase. +/// +/// Callers use this mid-mutation (after rows have been rotated or +/// shifted), so a failure can't be propagated without leaving the +/// page list half-mutated (and corrupt). If the capacity can't be +/// increased (system OOM or the page is already at max capacity), +/// this panics: a crash is better than corruption. +pub fn clonePartialRowGrowCapacity( + self: *Screen, + node: *PageList.List.Node, + dst_y: usize, + src_page: *Page, + src_row: *const Row, + x_start: usize, + x_end: usize, +) *PageList.List.Node { + assert(src_page != node.page()); + + var current = node; + while (true) { + const cur_page: *Page = current.page(); + const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); + cur_page.clonePartialRowFrom( + src_page, + &cur_rows[dst_y], + src_row, + x_start, + x_end, + ) catch |err| { + // Adjust our page capacity to make room for what we + // didn't have space for and retry the copy. + current = self.increaseCapacity( + current, + PageList.IncreaseCapacity.forCloneError(err), + ) catch |e| switch (e) { + // We can't gracefully recover from either of these + // here: our callers have already rotated or shifted + // rows, so returning an error would leave the page + // list half-mutated (and corrupt), so a crash is + // better. + error.OutOfMemory, + => @panic("increaseCapacity system allocator OOM"), + + error.OutOfSpace, + => @panic("increaseCapacity OutOfSpace"), + }; + + continue; + }; + + return current; + } +} + pub inline fn cursorCellRight(self: *Screen, n: size.CellCountInt) *pagepkg.Cell { assert(self.cursor.x + n < self.pages.cols); const cell: [*]pagepkg.Cell = @ptrCast(self.cursor.page_cell); @@ -775,9 +884,31 @@ pub fn cursorReload(self: *Screen) void { .active, self.cursor.page_pin.*, ) orelse reset: { + // Our cached row/cell pointers may be invalid (that is often + // the reason cursorReload is being called), so refresh them + // from the pin first since cursorChangePin below marks the + // old cursor row as dirty. + const old_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = old_rac.row; + self.cursor.page_cell = old_rac.cell; + + // The cursor style and hyperlink IDs are only valid within the + // page that the pin points at, so the pin change must go through + // cursorChangePin, which migrates them when the active top-left + // is on a different page. Writing the pin directly here would + // leave the cursor holding IDs that are dead or alias unrelated + // entries on the new page. const pin = self.pages.pin(.{ .active = .{} }).?; - self.cursor.page_pin.* = pin; - break :reset self.pages.pointFromPin(.active, pin).?; + self.cursor.x = 0; // Must be set before cursorChangePin + self.cursor.y = 0; + self.cursorChangePin(pin); + + // cursorChangePin can trigger a page capacity adjustment which + // moves the pin again, so we re-read it to derive our point. + break :reset self.pages.pointFromPin( + .active, + self.cursor.page_pin.*, + ).?; }; self.cursor.x = @intCast(pt.active.x); @@ -785,20 +916,6 @@ pub fn cursorReload(self: *Screen) void { const page_rac = self.cursor.page_pin.rowAndCell(); self.cursor.page_row = page_rac.row; self.cursor.page_cell = page_rac.cell; - - // If we have a style, we need to ensure it is in the page because this - // method may also be called after a page change. - if (self.cursor.style_id != style.default_id) { - self.manualStyleUpdate() catch |err| { - // This failure should not happen because manualStyleUpdate - // handles page splitting, overflow, and more. This should only - // happen if we're out of RAM. In this case, we'll just degrade - // gracefully back to the default style. - log.err("failed to update style on cursor reload err={}", .{err}); - self.cursor.style = .{}; - self.cursor.style_id = 0; - }; - } } /// Scroll the active area and keep the cursor at the bottom of the screen. @@ -824,6 +941,12 @@ pub fn cursorDownScroll(self: *Screen) !void { self.cursor.page_row, page.getCells(self.cursor.page_row), ); + + // The row is a fresh blank row now and must not retain + // metadata (wrap state, semantic prompt) from the + // discarded content. + self.cursor.page_row.reset(); + self.cursorMarkDirty(); } else { // The call to `eraseRow` will move the tracked cursor pin up by one @@ -1035,36 +1158,58 @@ fn cursorScrollAboveRotate( self: *Screen, fresh_node: ?*PageList.List.Node, ) !void { + // A fresh node given to us is always the newly allocated tail of + // the page list (see grow), which is where our iteration starts. + assert(fresh_node == null or fresh_node == self.pages.pages.last); + self.cursorChangePin(self.cursor.page_pin.down(1).?); // Go through each of the pages following our pin, shift all rows - // down by one, and copy the last row of the previous page. + // down by one, and copy the last row of the previous page. We start + // at the tail, which is the only node that can be freshly allocated. var current = self.pages.pages.last.?; - while (current != self.cursor.page_pin.node) : (current = current.prev.?) { + var current_is_fresh = fresh_node != null; + while (current != self.cursor.page_pin.node) : ({ + current = current.prev.?; + current_is_fresh = false; + }) { const prev = current.prev.?; - const prev_page = prev.page(); - const cur_page = current.page(); - const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr); - const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); // A newly allocated tail has no earlier references to invalidate. - if (fresh_node == null or current != fresh_node.?) { + if (!current_is_fresh) { // Rotating this page moves every cached row coordinate down by one. self.pages.invalidateNodeLayout(current); } // Rotate the pages down: [ 0 1 2 3 ] => [ 3 0 1 2 ] - fastmem.rotateOnceR(Row, cur_rows[0..cur_page.size.rows]); + { + const cur_page = current.page(); + const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); + fastmem.rotateOnceR(Row, cur_rows[0..cur_page.size.rows]); + } // Copy the last row of the previous page to the top of current. - try cur_page.cloneRowFrom( - prev_page, - &cur_rows[0], - &prev_rows[prev_page.size.rows - 1], - ); + // If the current page doesn't have enough capacity for the + // managed memory of the copied row (styles, hyperlinks, etc.) + // then its capacity is increased and the copy retried, which + // may replace `current` in the page list. Our loop condition + // guarantees `current` is never the cursor page, and `prev` is + // unaffected by the replacement. + { + const prev_page = prev.page(); + const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr); + current = self.clonePartialRowGrowCapacity( + current, + 0, + prev_page, + &prev_rows[prev_page.size.rows - 1], + 0, + self.pages.cols, + ); + } // Mark dirty on the page, since we are dirtying all rows with this. - cur_page.dirty = true; + current.page().dirty = true; } // Our current is our cursor page, we need to rotate down from @@ -1081,6 +1226,11 @@ fn cursorScrollAboveRotate( cur_page.getCells(&cur_rows[self.cursor.page_pin.y]), ); + // The recycled storage becomes the new blank cursor row and must + // not retain metadata (wrap state, semantic prompt) from the row + // whose content was moved to the next page. + cur_rows[self.cursor.page_pin.y].reset(); + // Mark the whole page as dirty. // // Technically we only need to mark from the cursor row to the @@ -1154,6 +1304,11 @@ pub fn cursorScrollRegionUp(self: *Screen, limit: usize) !void { // row with our blank cell, preserving the background color. self.clearCells(page, row, page.getCells(row)); } + + // The row becomes the new blank cursor row after the rotation + // below and must not retain metadata (wrap state, semantic + // prompt) from the discarded content. + row.reset(); } // Rotate the region rows so the now-blank top row moves to the @@ -1366,6 +1521,10 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { if (self.cursor.hyperlink != null) { const old_page: *Page = self.cursor.page_pin.node.page(); old_page.hyperlink_set.release(old_page.memory, self.cursor.hyperlink_id); + // Zero the ID, it is invalid now and style changes below may + // run integrity checks. We still have self.cursor.hyperlink to + // rebuild this later. + self.cursor.hyperlink_id = 0; } // Update our pin to the new page @@ -1387,8 +1546,9 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // On the new page, we need to migrate our hyperlink if (self.cursor.hyperlink) |link| { - // So we don't attempt to free any memory in the replaced page. - self.cursor.hyperlink_id = 0; + // startHyperlink will try to free old hyperlinks, so set this + // to null. We free it ourselves later since we're doing some + // ref-counting shenanigans in this function. self.cursor.hyperlink = null; // Re-add @@ -1789,6 +1949,11 @@ pub fn splitCellBoundary( p_rac.row, p_cells[p_row.node.cols() - 1 ..][0..1], ); + + // `clearCells` does not mark rows dirty, and our + // callers only mark the cursor row, so mark the + // previous row here. + p_row.markDirty(); } } } @@ -1851,50 +2016,80 @@ pub const Resize = struct { prompt_redraw: osc.semantic_prompt.Redraw = .false, }; -/// Resize the screen. The rows or cols can be bigger or smaller. -/// -/// If this returns an error, the screen is left in a likely garbage state. -/// It is very hard to undo this operation without blowing up our memory -/// usage. The only way to recover is to reset the screen. The only way -/// this really fails is if page allocation is required and fails, which -/// probably means the system is in trouble anyways. I'd like to improve this -/// in the future but it is not a priority particularly because this scenario -/// (resize) is difficult. +const resize_tw = tripwire.module(enum { + saved_cursor_pin, + pages, +}, resize); + +/// Resize the screen. The rows or cols can be bigger or smaller. If this +/// returns an error, the screen is unchanged. pub inline fn resize( self: *Screen, opts: Resize, -) !void { +) Allocator.Error!void { + const tw = resize_tw; defer self.assertIntegrity(); - if (comptime build_options.kitty_graphics) { - // No matter what we mark our image state as dirty - self.kitty_images.dirty = true; - } + // We need to insert a tracked pin for our saved cursor so we can + // modify its X/Y for reflow. Do this before changing any state since + // tracking the pin can fail. + const saved_cursor_pin: ?*Pin = saved_cursor: { + const sc = self.saved_cursor orelse break :saved_cursor null; + const pin = self.pages.pin(.{ .active = .{ + .x = sc.x, + .y = sc.y, + } }) orelse break :saved_cursor null; + try tw.check(.saved_cursor_pin); + break :saved_cursor try self.pages.trackPin(pin); + }; + defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); - // Release the cursor style while resizing just - // in case the cursor ends up on a different page. + // A cursor style and hyperlink are partly stored in the page containing + // the cursor. Their IDs only have meaning within that page, and the page + // keeps reference counts for them. Resizing can replace or destroy the + // page, so below we temporarily remove both values from the cursor before + // asking PageList to resize. + // + // Save everything first so we can put the cursor back together afterward, + // or restore it unchanged if the resize fails. We also save the original + // node and serial so after a successful resize we can tell whether that + // page survived and still needs its temporary references released. + const cursor_node = self.cursor.page_pin.node; + const cursor_node_serial = cursor_node.serial; const cursor_style = self.cursor.style; - self.cursor.style = .{}; - self.manualStyleUpdate() catch unreachable; - defer { - // Restore the cursor style. + const cursor_style_id = self.cursor.style_id; + const cursor_hyperlink = self.cursor.hyperlink; + const cursor_hyperlink_id = self.cursor.hyperlink_id; + + // Keep an extra reference to the cursor style and hyperlink while the + // resize is in progress. Releasing the cursor references below therefore + // can't delete either entry, and an error can restore the cursor without + // any allocation. + { + const page = cursor_node.page(); + if (cursor_style_id != style.default_id) { + page.styles.use(page.memory, cursor_style_id); + } + if (cursor_hyperlink_id != 0) { + page.hyperlink_set.use(page.memory, cursor_hyperlink_id); + } + } + errdefer { self.cursor.style = cursor_style; - self.manualStyleUpdate() catch |err| { - // This failure should not happen because manualStyleUpdate - // handles page splitting, overflow, and more. This should only - // happen if we're out of RAM. In this case, we'll just degrade - // gracefully back to the default style. - log.err("failed to update style on cursor reload err={}", .{err}); - self.cursor.style = .{}; - self.cursor.style_id = 0; - }; + self.cursor.style_id = cursor_style_id; + self.cursor.hyperlink = cursor_hyperlink; + self.cursor.hyperlink_id = cursor_hyperlink_id; } + // Release the cursor style while resizing just in case the cursor ends + // up on a different page. + self.cursor.style = .{}; + self.manualStyleUpdate() catch unreachable; + // If we have a hyperlink, release it from the old page // and then we need to re-add it to the new page. This needs // to happen because resize below typically reallocates a // new page so the old hyperlink is invalid. - const hyperlink_ = self.cursor.hyperlink; if (self.cursor.hyperlink_id != 0) { // Note we do NOT use endHyperlink because we want to keep // our allocated self.cursor.hyperlink valid. @@ -1904,73 +2099,8 @@ pub inline fn resize( self.cursor.hyperlink = null; } - // We need to insert a tracked pin for our saved cursor so we can - // modify its X/Y for reflow. - const saved_cursor_pin: ?*Pin = saved_cursor: { - const sc = self.saved_cursor orelse break :saved_cursor null; - const pin = self.pages.pin(.{ .active = .{ - .x = sc.x, - .y = sc.y, - } }) orelse break :saved_cursor null; - break :saved_cursor try self.pages.trackPin(pin); - }; - defer if (saved_cursor_pin) |p| self.pages.untrackPin(p); - - // If our cursor is on a prompt or input line, clear it so the shell can - // redraw it. This works with OSC 133 semantic prompts. - // - // We check cursor.semantic_content rather than page_row.semantic_prompt - // because some shells (e.g., Nu) mark input areas with OSC 133 B but don't - // mark continuation lines with k=s. If the input spans multiple lines and - // continuation lines are unmarked, checking only page_row.semantic_prompt - // would miss them. By checking semantic_content, we assume that if the - // cursor is on anything other than command output, we're at a prompt/input - // line and should clear from there. - if (opts.prompt_redraw != .false and - self.cursor.semantic_content != .output) - prompt: { - switch (opts.prompt_redraw) { - .false => unreachable, - - // For `.last`, only clear the current line where the cursor is. - // For `.true`, clear all prompt lines starting from the beginning. - .last => { - const page = self.cursor.page_pin.node.page(); - const row = self.cursor.page_row; - const cells = page.getCells(row); - self.clearCells(page, row, cells); - }, - - .true => { - const start = start: { - var it = self.cursor.page_pin.promptIterator( - .left_up, - null, - ); - break :start it.next() orelse { - // This should never happen because promptIterator should always - // find a prompt if we already verified our row is some kind of - // prompt. - log.warn("cursor on prompt line but promptIterator found no prompt", .{}); - break :prompt; - }; - }; - - // Clear cells from our start down. We replace it with spaces, - // and do not physically erase the rows (eraseRows) because the - // shell is going to expect this space to be available. - var it = start.rowIterator(.right_down, null); - while (it.next()) |pin| { - const page = pin.node.page(); - const row = pin.rowAndCell().row; - const cells = page.getCells(row); - self.clearCells(page, row, cells); - } - }, - } - } - // Perform the resize operation. + try tw.check(.pages); try self.pages.resize(.{ .rows = opts.rows, .cols = opts.cols, @@ -1982,17 +2112,39 @@ pub inline fn resize( }, }); + // No more failures are possible after this. Enforced by compiler + // because we do NO cleanup of the state below. + errdefer comptime unreachable; + + // Resizing moves image placements, so mark their geometry as dirty. + if (comptime build_options.kitty_graphics) self.kitty_images.dirty = true; + // If we have no scrollback and we shrunk our rows, we must explicitly // erase our history. This is because PageList always keeps at least // a page size of history. - if (self.no_scrollback) { - self.pages.eraseHistory(null); - } + if (self.no_scrollback) self.pages.eraseHistory(null); // If our cursor was updated, we do a full reload so all our cursor // state is correct. self.cursorReload(); + // Clear any redrawable prompt after the fallible resize but before + // restoring the cursor style and hyperlink, so cleared cells retain the + // same default styling they had with the previous ordering. + self.clearPromptForRedraw(opts.prompt_redraw); + + // Restore the cursor style. + self.cursor.style = cursor_style; + self.manualStyleUpdate() catch |err| { + // This failure should not happen because manualStyleUpdate handles + // page splitting, overflow, and more. This should only happen if + // we're out of RAM. In this case, we'll just degrade gracefully back + // to the default style. + log.err("failed to update style on cursor reload err={}", .{err}); + self.cursor.style = .{}; + self.cursor.style_id = 0; + }; + // If we reflowed a saved cursor, update it. if (saved_cursor_pin) |p| { // This should never fail because a non-null saved_cursor_pin @@ -2024,7 +2176,7 @@ pub inline fn resize( } // Fix up our hyperlink if we had one. - if (hyperlink_) |link| { + if (cursor_hyperlink) |link| { self.startHyperlink(link.uri, switch (link.id) { .explicit => |v| v, .implicit => null, @@ -2039,6 +2191,97 @@ pub inline fn resize( link.deinit(self.alloc); self.alloc.destroy(link); } + + // A tracked pin follows its content when PageList moves or replaces a + // page. If the cursor's pin still points to the node we started with, + // that node survived the resize and still owns the temporary style and + // hyperlink references we added above. + // + // Comparing pointers is not enough by itself. PageList keeps a pool of + // nodes, so it can destroy a node and create a replacement at the same + // memory address. Every new use of a node gets a different serial number, + // making the pointer-and-serial pair an O(1) identity check. + const cursor_node_survived = + self.cursor.page_pin.node == cursor_node and + self.cursor.page_pin.node.serial == cursor_node_serial; + if (comptime build_options.slow_runtime_safety) { + assert(cursor_node_survived == + self.pages.nodeIsValid(cursor_node, cursor_node_serial)); + } + + // A surviving node still contains our temporary references, so remove + // them now. If the node was replaced, its destruction removed those + // references along with the rest of the old page so we don't need to + // fix it up. + if (cursor_node_survived) { + const page = cursor_node.page(); + if (cursor_style_id != style.default_id) { + page.styles.release(page.memory, cursor_style_id); + } + if (cursor_hyperlink_id != 0) { + page.hyperlink_set.release(page.memory, cursor_hyperlink_id); + } + } +} + +fn clearPromptForRedraw( + self: *Screen, + redraw: osc.semantic_prompt.Redraw, +) void { + // If our cursor is on a prompt or input line, clear it so the shell can + // redraw it. This works with OSC 133 semantic prompts. We do this after + // the fallible resize so an error leaves the original prompt untouched. + // + // We check cursor.semantic_content rather than page_row.semantic_prompt + // because some shells (e.g., Nu) mark input areas with OSC 133 B but don't + // mark continuation lines with k=s. If the input spans multiple lines and + // continuation lines are unmarked, checking only page_row.semantic_prompt + // would miss them. By checking semantic_content, we assume that if the + // cursor is on anything other than command output, we're at a prompt/input + // line and should clear from there. + if (redraw != .false and + self.cursor.semantic_content != .output) + prompt: { + switch (redraw) { + .false => unreachable, + + // For `.last`, only clear the current line where the cursor is. + // For `.true`, clear all prompt lines starting from the beginning. + .last => { + const page = self.cursor.page_pin.node.page(); + const row = self.cursor.page_row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + }, + + .true => { + const start = start: { + var it = self.cursor.page_pin.promptIterator( + .left_up, + null, + ); + break :start it.next() orelse { + // This should never happen because promptIterator should always + // find a prompt if we already verified our row is some kind of + // prompt. + log.warn("cursor on prompt line but promptIterator found no prompt", .{}); + break :prompt; + }; + }; + + // Clear cells from our start down. We replace it with spaces, + // and do not physically erase the rows (eraseRows) because the + // shell is going to expect this space to be available. + var it = start.rowIterator(.right_down, null); + while (it.next()) |pin| { + const page = pin.node.page(); + const row = pin.rowAndCell().row; + const cells = page.getCells(row); + self.clearCells(page, row, cells); + } + }, + } + } } /// Set a style attribute for the current cursor. @@ -2684,17 +2927,8 @@ pub const SelectionString = struct { /// If true, trim whitespace around the selection. trim: bool = true, - - /// If non-null, a stringmap will be written here. This will use - /// the same allocator as the call to selectionString. The string will - /// be duplicated here and in the return value so both must be freed. - map: ?*StringMap = null, }; -const selectionString_tw = tripwire.module(enum { - copy_map, -}, selectionString); - /// Returns the raw text associated with a selection. This will unwrap /// soft-wrapped edges. The returned slice is owned by the caller and allocated /// using alloc, not the allocator associated with the screen (unless they match). @@ -2705,6 +2939,32 @@ pub fn selectionString( alloc: Allocator, opts: SelectionString, ) Allocator.Error![:0]const u8 { + return self.selectionStringImpl(alloc, opts, null); +} + +/// Returns a StringMap associated with a selection. The map contains the +/// selection's raw text and a mapping from each byte to its screen location. +/// +/// The returned map is owned by the caller. +pub fn selectionStringMap( + self: *Screen, + alloc: Allocator, + opts: SelectionString, +) Allocator.Error!StringMap { + var pins: PinMap.Map = .empty; + errdefer pins.deinit(alloc); + return .{ + .string = try self.selectionStringImpl(alloc, opts, &pins), + .map = pins, + }; +} + +fn selectionStringImpl( + self: *Screen, + alloc: Allocator, + opts: SelectionString, + pins: ?*PinMap.Map, +) Allocator.Error![:0]const u8 { // We'll use this as our buffer to build our string. var aw: std.Io.Writer.Allocating = .init(alloc); defer aw.deinit(); @@ -2720,33 +2980,15 @@ pub fn selectionString( ); formatter.content = .{ .selection = opts.sel }; - // If we have a string map, we need to set that up. - var pins: std.ArrayList(Pin) = .empty; - defer pins.deinit(alloc); - if (opts.map != null) formatter.pin_map = .{ + if (pins) |map| formatter.pin_map = .{ .alloc = alloc, - .map = &pins, + .map = map, }; // Emit. Since this is an allocating writer, a failed write // just becomes an OOM. formatter.format(&aw.writer) catch return error.OutOfMemory; - - // Build our final text and if we have a string map set that up. - const text = try aw.toOwnedSliceSentinel(0); - errdefer alloc.free(text); - if (opts.map) |map| { - const map_string = try alloc.dupeZ(u8, text); - errdefer alloc.free(map_string); - try selectionString_tw.check(.copy_map); - const map_pins = try pins.toOwnedSlice(alloc); - map.* = .{ - .string = map_string, - .map = map_pins, - }; - } - - return text; + return try aw.toOwnedSliceSentinel(0); } pub const SelectLine = struct { @@ -2894,7 +3136,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { const this_whitespace = std.mem.indexOfScalar( u21, whitespace, - cell.content.codepoint, + cell.content.codepoint.data, ) != null; if (this_whitespace) continue; @@ -2916,7 +3158,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { const this_whitespace = std.mem.indexOfScalar( u21, whitespace, - cell.content.codepoint, + cell.content.codepoint.data, ) != null; if (this_whitespace) continue; @@ -2948,7 +3190,7 @@ pub fn selectAll(self: *Screen) ?Selection { const this_whitespace = std.mem.indexOfAny( u32, whitespace, - &[_]u32{cell.content.codepoint}, + &[_]u32{cell.content.codepoint.data}, ) != null; if (this_whitespace) continue; @@ -2972,7 +3214,7 @@ pub fn selectAll(self: *Screen) ?Selection { const this_whitespace = std.mem.indexOfAny( u32, whitespace, - &[_]u32{cell.content.codepoint}, + &[_]u32{cell.content.codepoint.data}, ) != null; if (this_whitespace) continue; @@ -3040,7 +3282,7 @@ pub fn selectWord( const expect_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, - start_cell.content.codepoint, + start_cell.content.codepoint.data, ) != null; // Go forwards to find our end boundary @@ -3058,7 +3300,7 @@ pub fn selectWord( const this_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, - cell.content.codepoint, + cell.content.codepoint.data, ) != null; if (this_boundary != expect_boundary) break :end prev; @@ -3095,7 +3337,7 @@ pub fn selectWord( const this_boundary = std.mem.indexOfScalar( u21, boundary_codepoints, - cell.content.codepoint, + cell.content.codepoint.data, ) != null; if (this_boundary != expect_boundary) break :start prev; @@ -3517,7 +3759,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { 1 => { self.cursor.page_cell.* = .{ .content_tag = .codepoint, - .content = .{ .codepoint = c }, + .content = .{ .codepoint = @bitCast(@as(u24, @intCast(c))) }, .style_id = self.cursor.style_id, .protected = self.cursor.protected, .semantic_content = self.cursor.semantic_content, @@ -3539,7 +3781,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { if (self.cursor.x == self.pages.cols - 1) { self.cursor.page_cell.* = .{ .content_tag = .codepoint, - .content = .{ .codepoint = 0 }, + .content = .{ .codepoint = .{ .data = 0 } }, .wide = .spacer_head, .protected = self.cursor.protected, .semantic_content = self.cursor.semantic_content, @@ -3557,7 +3799,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { // Write our wide char self.cursor.page_cell.* = .{ .content_tag = .codepoint, - .content = .{ .codepoint = c }, + .content = .{ .codepoint = .{ .data = c } }, .style_id = self.cursor.style_id, .wide = .wide, .protected = self.cursor.protected, @@ -3571,7 +3813,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { self.cursorRight(1); self.cursor.page_cell.* = .{ .content_tag = .codepoint, - .content = .{ .codepoint = 0 }, + .content = .{ .codepoint = .{ .data = 0 } }, .wide = .spacer_tail, .protected = self.cursor.protected, .semantic_content = self.cursor.semantic_content, @@ -3600,11 +3842,48 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { } } +test "Screen forwards optional scrollback limits" { + const testing = std.testing; + const max_lines: usize = 123; + var s = try init(testing.io, testing.allocator, .{ + .cols = 80, + .rows = 24, + .max_scrollback_bytes = null, + .max_scrollback_lines = max_lines, + }); + defer s.deinit(); + + try testing.expectEqual( + std.math.maxInt(usize), + s.pages.limits.bytes.explicit, + ); + try testing.expectEqual(max_lines, s.pages.limits.lines.explicit); + try testing.expect(!s.no_scrollback); +} + +test "Screen reset cursor pin is not garbage" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); + defer s.deinit(); + try s.testWriteString("hello, world"); + + // The page reset marks every tracked pin garbage but the screen + // keeps using the cursor pin, so it must come back clean: anything + // that copies it (e.g. Kitty image placements) would otherwise be + // born garbage and reaped. + s.reset(); + try testing.expect(!s.cursor.page_pin.garbage); +} + test "Screen read and write" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); defer s.deinit(); try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); @@ -3617,8 +3896,9 @@ test "Screen read and write" { test "Screen read and write newline" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); defer s.deinit(); try testing.expectEqual(@as(style.Id, 0), s.cursor.style_id); @@ -3631,8 +3911,9 @@ test "Screen read and write newline" { test "Screen read and write scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 2, .max_scrollback_bytes = 1000 }); defer s.deinit(); try s.testWriteString("hello\nworld\ntest"); @@ -3651,8 +3932,9 @@ test "Screen read and write scrollback" { test "Screen read and write no scrollback small" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 0 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 2, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("hello\nworld\ntest"); @@ -3671,8 +3953,9 @@ test "Screen read and write no scrollback small" { test "Screen read and write no scrollback large" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 2, .max_scrollback = 0 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 2, .max_scrollback_bytes = 0 }); defer s.deinit(); for (0..1_000) |i| { @@ -3692,14 +3975,15 @@ test "Screen read and write no scrollback large" { test "Screen cursorCopy x/y" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); s.cursorAbsolute(2, 3); try testing.expect(s.cursor.x == 2); try testing.expect(s.cursor.y == 3); - var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s2 = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s2.deinit(); try s2.cursorCopy(s.cursor, .{}); try testing.expect(s2.cursor.x == 2); @@ -3716,11 +4000,12 @@ test "Screen cursorCopy x/y" { test "Screen cursorCopy style deref" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s2 = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s2.deinit(); const page = s2.cursor.page_pin.node.page(); @@ -3738,11 +4023,12 @@ test "Screen cursorCopy style deref" { test "Screen cursorCopy style deref new page" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 2048 }); + var s2 = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 2048 }); defer s2.deinit(); // We need to get the cursor on a new page. @@ -3811,12 +4097,13 @@ test "Screen cursorCopy style deref new page" { test "Screen cursorCopy style copy" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); - var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s2 = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s2.deinit(); const page = s2.cursor.page_pin.node.page(); try s2.cursorCopy(s.cursor, .{}); @@ -3827,11 +4114,12 @@ test "Screen cursorCopy style copy" { test "Screen cursorCopy hyperlink deref" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s2 = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s2.deinit(); const page = s2.cursor.page_pin.node.page(); @@ -3849,11 +4137,12 @@ test "Screen cursorCopy hyperlink deref" { test "Screen write regrows compacted page capacity" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, - .max_scrollback = 0, + .max_scrollback_bytes = 0, }); defer s.deinit(); @@ -3894,14 +4183,91 @@ test "Screen write regrows compacted page capacity" { try testing.expect(page.graphemeCount() >= 1); } +// The cursor style and hyperlink IDs are only meaningful within the page +// the cursor pin points at. scrollClear can move the active area onto a +// later page while the cursor pin stays with its content on an earlier +// page (now scrollback), so the reset in cursorReload must migrate both +// references to the destination page. It previously replaced the pin +// directly and then released the old style ID on the new page. +test "Screen scrollClear across pages migrates cursor style and hyperlink" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ + .cols = 10, + .rows = 10, + .max_scrollback_bytes = std.math.maxInt(usize), + }); + defer s.deinit(); + + // Fill the first page so the active area spans two pages. + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_size - 5) |_| { + try s.testWriteString("\n"); + } + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try s.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // Move the cursor to the top of the active area, which is on the + // first page, and give it a style and a hyperlink there. + s.cursorAbsolute(0, 0); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); + try s.setAttribute(.{ .bold = {} }); + try s.startHyperlink("https://example.com/", null); + + const old_page: *Page = s.cursor.page_pin.node.page(); + const old_style_id = s.cursor.style_id; + const old_hyperlink_id = s.cursor.hyperlink_id; + try testing.expect(old_style_id != style.default_id); + try testing.expect(old_hyperlink_id != 0); + + // All ten active rows are non-empty, so this moves the active area + // fully onto the second page while the cursor pin stays with its + // old row, which is now scrollback. + try s.scrollClear(); + + // The cursor was moved to the new active top-left on the second + // page with its style and hyperlink references rebuilt there. + const new_page: *Page = s.cursor.page_pin.node.page(); + try testing.expect(new_page != old_page); + try testing.expect(s.cursor.style_id != style.default_id); + try testing.expect(s.cursor.hyperlink_id != 0); + try testing.expect(new_page.styles.refCount( + new_page.memory, + s.cursor.style_id, + ) > 0); + try testing.expect(new_page.hyperlink_set.refCount( + new_page.memory, + s.cursor.hyperlink_id, + ) > 0); + + // The cursor's references on the old page were released. Nothing + // else referenced either entry, so both are dead there now. + try testing.expectEqual(0, old_page.styles.refCount( + old_page.memory, + old_style_id, + )); + try testing.expectEqual(0, old_page.hyperlink_set.refCount( + old_page.memory, + old_hyperlink_id, + )); + + // Printing attaches the migrated style and hyperlink to a cell. + try s.testWriteString("B"); +} + test "Screen cursorCopy hyperlink deref new page" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); - var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 2048 }); + var s2 = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 2048 }); defer s2.deinit(); // We need to get the cursor on a new page. @@ -3970,8 +4336,9 @@ test "Screen cursorCopy hyperlink deref new page" { test "Screen cursorCopy hyperlink copy" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); // Create a hyperlink for the cursor. @@ -3979,7 +4346,7 @@ test "Screen cursorCopy hyperlink copy" { try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); - var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s2 = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s2.deinit(); const page = s2.cursor.page_pin.node.page(); @@ -3995,8 +4362,9 @@ test "Screen cursorCopy hyperlink copy" { test "Screen cursorCopy hyperlink copy disabled" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); // Create a hyperlink for the cursor. @@ -4004,7 +4372,7 @@ test "Screen cursorCopy hyperlink copy disabled" { try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); - var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s2 = try Screen.init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s2.deinit(); const page = s2.cursor.page_pin.node.page(); @@ -4020,8 +4388,9 @@ test "Screen cursorCopy hyperlink copy disabled" { test "Screen style basics" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); defer s.deinit(); const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); @@ -4042,8 +4411,9 @@ test "Screen style basics" { test "Screen style reset to default" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); defer s.deinit(); const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); @@ -4062,8 +4432,9 @@ test "Screen style reset to default" { test "Screen style reset with unset" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); defer s.deinit(); const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); @@ -4082,8 +4453,9 @@ test "Screen style reset with unset" { test "Screen clearRows active one line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); defer s.deinit(); try s.testWriteString("hello, world"); @@ -4097,8 +4469,9 @@ test "Screen clearRows active one line" { test "Screen clearRows active multi line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); defer s.deinit(); try s.testWriteString("hello\nworld"); @@ -4113,8 +4486,9 @@ test "Screen clearRows active multi line" { test "Screen clearRows active styled line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); @@ -4138,7 +4512,7 @@ test "Screen clearRows active styled line" { test "Screen clearCells empty range" { const testing = std.testing; - var s = try Screen.init(testing.allocator, .default); + var s = try Screen.init(testing.io, testing.allocator, .default); defer s.deinit(); const page = s.cursor.page_pin.node.page(); @@ -4150,10 +4524,10 @@ test "Screen clearCells empty range" { test "Screen clearRows uses stored page width" { const testing = std.testing; - var s = try Screen.init(testing.allocator, .{ + var s = try Screen.init(testing.io, testing.allocator, .{ .cols = 2, .rows = 1, - .max_scrollback = 0, + .max_scrollback_bytes = 0, }); defer s.deinit(); @@ -4170,8 +4544,9 @@ test "Screen clearRows uses stored page width" { test "Screen clearRows protected" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 1000 }); defer s.deinit(); try s.testWriteString("UNPROTECTED"); @@ -4198,8 +4573,9 @@ test "Screen clearRows protected" { test "Screen eraseRows history" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 1000 }); defer s.deinit(); try s.testWriteString("1\n2\n3\n4\n5\n6"); @@ -4232,8 +4608,9 @@ test "Screen eraseRows history" { test "Screen eraseRows history with more lines" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 1000 }); + var s = try Screen.init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 1000 }); defer s.deinit(); try s.testWriteString("A\nB\nC\n1\n2\n3\n4\n5\n6"); @@ -4266,8 +4643,9 @@ test "Screen eraseRows history with more lines" { test "Screen eraseRows active partial" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try Screen.init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try Screen.init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1\n2\n3"); @@ -4294,10 +4672,10 @@ test "Screen eraseRows active partial" { test "Screen: cursorCellEndOfPrev across mixed-width pages" { const testing = std.testing; - var s = try init(testing.allocator, .{ + var s = try init(testing.io, testing.allocator, .{ .cols = 4, .rows = 2, - .max_scrollback = 0, + .max_scrollback_bytes = 0, }); defer s.deinit(); @@ -4316,8 +4694,9 @@ test "Screen: cursorCellEndOfPrev across mixed-width pages" { test "Screen: cursorDown across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -4368,8 +4747,9 @@ test "Screen: cursorDown across pages preserves style" { test "Screen: cursorUp across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -4415,8 +4795,9 @@ test "Screen: cursorUp across pages preserves style" { test "Screen: cursorAbsolute across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -4470,8 +4851,9 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); // Scroll down enough to go to another page @@ -4537,8 +4919,9 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { test "Screen: scrolling" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4579,8 +4962,9 @@ test "Screen: scrolling" { test "Screen: scrolling with a single-row screen no scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 1, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1ABCD"); @@ -4599,8 +4983,9 @@ test "Screen: scrolling with a single-row screen no scrollback" { test "Screen: scrolling with a single-row screen with scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 1, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD"); @@ -4629,8 +5014,9 @@ test "Screen: scrolling with a single-row screen with scrollback" { test "Screen: scrolling across pages preserves style" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.setAttribute(.{ .bold = {} }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4658,8 +5044,9 @@ test "Screen: scrolling across pages preserves style" { test "Screen: scroll down from 0" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4677,8 +5064,9 @@ test "Screen: scroll down from 0" { test "Screen: scrollback various cases" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); try s.cursorDownScroll(); @@ -4758,8 +5146,9 @@ test "Screen: scrollback various cases" { test "Screen: scrollback with multi-row delta" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); @@ -4784,8 +5173,9 @@ test "Screen: scrollback with multi-row delta" { test "Screen: scrollback empty" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 50 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 50 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); s.scroll(.{ .delta_row = 1 }); @@ -4799,8 +5189,9 @@ test "Screen: scrollback empty" { test "Screen: scrollback doesn't move viewport if not at bottom" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"); @@ -4834,8 +5225,9 @@ test "Screen: scrollback doesn't move viewport if not at bottom" { test "Screen: scrolling moves selection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -4913,8 +5305,9 @@ test "Screen: scrolling moves selection" { test "Screen: cursorScrollRegionUp simple" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); @@ -4940,8 +5333,9 @@ test "Screen: cursorScrollRegionUp simple" { test "Screen: cursorScrollRegionUp renews page generation" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); s.cursorAbsolute(0, 2); @@ -4956,8 +5350,9 @@ test "Screen: cursorScrollRegionUp renews page generation" { test "Screen: cursorScrollRegionUp moves selection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); @@ -4994,8 +5389,9 @@ test "Screen: cursorScrollRegionUp moves selection" { test "Screen: cursorScrollRegionUp region spans pages" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -5058,8 +5454,9 @@ test "Screen: cursorScrollRegionUp region spans pages" { test "Screen: cursorScrollRegionUp region spans pages with background SGR" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10 }); defer s.deinit(); // We need to get the cursor to a new page. See the previous test @@ -5101,8 +5498,9 @@ test "Screen: cursorScrollRegionUp region spans pages with background SGR" { test "Screen: cursorScrollRegionUp with styled erased row" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); // Write a styled row at the top so the erased row has managed @@ -5130,8 +5528,9 @@ test "Screen: cursorScrollRegionUp with styled erased row" { test "Screen: scrolling moves viewport" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5155,8 +5554,9 @@ test "Screen: scrolling moves viewport" { test "Screen: scrolling when viewport is pruned" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 215, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 215, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); // Write some to create scrollback and move back into our scrollback. @@ -5181,8 +5581,9 @@ test "Screen: scrolling when viewport is pruned" { test "Screen: scroll and clear full screen" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5208,8 +5609,9 @@ test "Screen: scroll and clear full screen" { test "Screen: scroll and clear partial screen" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); @@ -5235,8 +5637,9 @@ test "Screen: scroll and clear partial screen" { test "Screen: scroll and clear empty screen" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); try s.scrollClear(); { @@ -5254,8 +5657,9 @@ test "Screen: scroll and clear empty screen" { test "Screen: scroll and clear ignore blank lines" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); try s.scrollClear(); @@ -5297,8 +5701,9 @@ test "Screen: scroll and clear ignore blank lines" { test "Screen: scroll above same page" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 10 }); defer s.deinit(); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5359,8 +5764,9 @@ test "Screen: scroll above same page" { test "Screen: scroll above same page but cursor on previous page" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -5446,8 +5852,9 @@ test "Screen: scroll above same page but cursor on previous page" { test "Screen: scroll above same page but cursor on previous page last row" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -5536,8 +5943,9 @@ test "Screen: scroll above same page but cursor on previous page last row" { test "Screen: scroll above creates new page" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 10 }); defer s.deinit(); // We need to get the cursor to a new page @@ -5611,8 +6019,9 @@ test "Screen: scroll above creates new page" { test "Screen: scroll above with cursor on non-final row" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 4, .max_scrollback_bytes = 10 }); defer s.deinit(); // Get the cursor to be 2 rows above a new page @@ -5688,8 +6097,9 @@ test "Screen: scroll above with cursor on non-final row" { test "Screen: scroll above no scrollback bottom of page" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const first_page_size = s.pages.pages.first.?.capacity().rows; @@ -5750,11 +6160,175 @@ test "Screen: scroll above no scrollback bottom of page" { try testing.expect(s.pages.isDirty(.{ .active = .{ .x = 0, .y = 2 } })); } +test "Screen: scroll above hyperlink-dense row to fresh page" { + // Regression test for https://github.com/ghostty-org/ghostty/discussions/13160 + // + // When a scroll-above operation pushes a row carrying more unique + // hyperlinks than a fresh page's default hyperlink capacity across + // a page boundary, the cross-page row clone must increase the + // destination page's capacity (like insertLines/deleteLines do) + // rather than error out mid-operation, which leaves the page list + // half-mutated and aborts later (e.g. in clearCells). + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10000 }); + defer s.deinit(); + + // Fill the first page so it is exactly full and the cursor is on + // its last row (which is also the bottom row of the active area). + // The next grow() will then allocate a fresh page. + const first_page_rows = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try testing.expect(s.pages.pages.first == s.pages.pages.last); + try testing.expectEqual( + s.pages.pages.first.?.capacity().rows, + s.pages.pages.first.?.page().size.rows, + ); + try testing.expectEqual(s.pages.rows - 1, s.cursor.y); + + // Fill the bottom row with unique hyperlinks: more than a fresh + // page can hold with default hyperlink capacity. + for (0..s.pages.cols) |i| { + var buf: [64]u8 = undefined; + const uri = try std.fmt.bufPrint(&buf, "http://example.com/{d}", .{i}); + try s.startHyperlink(uri, null); + try s.testWriteString("A"); + s.endHyperlink(); + } + try testing.expectEqual( + @as(usize, s.pages.cols), + s.cursor.page_pin.node.page().hyperlink_set.count(), + ); + + // Move the cursor above the bottom row and scroll. The dense row is + // pushed across the page boundary into the freshly allocated page. + s.cursorAbsolute(0, 1); + try s.cursorScrollAbove(); + + // We must have created a second page and the dense row must now be + // the top row of that page. + try testing.expect(s.pages.pages.first != s.pages.pages.last); + + // All hyperlinks must have survived the scroll intact: every cell + // flagged as a hyperlink must resolve to a real entry in its page's + // hyperlink map. A half-applied scroll leaves cells whose hyperlink + // flag is set but that have no map entry, which aborts in + // clearCells later. + var node_: ?*PageList.List.Node = s.pages.pages.first; + while (node_) |node| : (node_ = node.next) { + const page: *Page = node.page(); + page.assertIntegrity(); + for (0..page.size.rows) |y| { + const row = page.getRow(y); + if (!row.hyperlink) continue; + for (page.getCells(row)) |*cell| { + if (!cell.hyperlink) continue; + try testing.expect(page.lookupHyperlink(cell) != null); + } + } + } + { + const last_page: *Page = s.pages.pages.last.?.page(); + try testing.expectEqual( + @as(usize, s.pages.cols), + last_page.hyperlink_set.count(), + ); + } + + // The dense row is still the bottom row of the active area. + for (0..s.pages.cols) |x| { + const list_cell = s.pages.getCell(.{ .active = .{ + .x = @intCast(x), + .y = 4, + } }).?; + try testing.expect(list_cell.cell.hyperlink); + const page: *Page = list_cell.node.page(); + const id = page.lookupHyperlink(list_cell.cell).?; + const link = page.hyperlink_set.get(page.memory, id); + var buf: [64]u8 = undefined; + const expected = try std.fmt.bufPrint(&buf, "http://example.com/{d}", .{x}); + try testing.expectEqualStrings(expected, link.uri.slice(page.memory)); + } +} + +test "Screen: scroll above hyperlink-dense row to existing page" { + // Same as the fresh page variant above but the destination page + // already exists (fresh_node == null path in cursorScrollAboveRotate). + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10000 }); + defer s.deinit(); + + // Fill the first page so it is exactly full and the cursor is on + // its last row. + const first_page_rows = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try testing.expectEqual(s.pages.rows - 1, s.cursor.y); + + // Fill the last row of the first page with unique hyperlinks: + // more than a page can hold with default hyperlink capacity. + for (0..s.pages.cols) |i| { + var buf: [64]u8 = undefined; + const uri = try std.fmt.bufPrint(&buf, "http://example.com/{d}", .{i}); + try s.startHyperlink(uri, null); + try s.testWriteString("A"); + s.endHyperlink(); + } + + // Scroll twice so the active area straddles the page boundary: + // the last two active rows are on a second page while the dense + // row remains the last row of the first page. + try s.testWriteString("\n\n"); + try testing.expect(s.pages.pages.first != s.pages.pages.last); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?); + + // Move the cursor to an active row that is still on the first page + // and above the dense row, then scroll. grow() has capacity in the + // last page so no fresh page is allocated, but the dense row still + // crosses the page boundary during the rotate. + s.cursorAbsolute(0, 0); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); + try s.cursorScrollAbove(); + + // All hyperlinks must have survived the scroll intact: every cell + // flagged as a hyperlink must resolve to a real entry in its page's + // hyperlink map. + var node_: ?*PageList.List.Node = s.pages.pages.first; + while (node_) |node| : (node_ = node.next) { + const page: *Page = node.page(); + page.assertIntegrity(); + for (0..page.size.rows) |y| { + const row = page.getRow(y); + if (!row.hyperlink) continue; + for (page.getCells(row)) |*cell| { + if (!cell.hyperlink) continue; + try testing.expect(page.lookupHyperlink(cell) != null); + } + } + } + { + const last_page: *Page = s.pages.pages.last.?.page(); + try testing.expectEqual( + @as(usize, s.pages.cols), + last_page.hyperlink_set.count(), + ); + } +} + test "Screen: clone" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { @@ -5766,7 +6340,7 @@ test "Screen: clone" { try testing.expectEqual(@as(usize, 1), s.cursor.y); // Clone - var s2 = try s.clone(alloc, .{ .active = .{} }, null); + var s2 = try s.clone(io, alloc, .{ .active = .{} }, null); defer s2.deinit(); { const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); @@ -5795,8 +6369,9 @@ test "Screen: clone" { test "Screen: clone partial" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { @@ -5808,7 +6383,7 @@ test "Screen: clone partial" { try testing.expectEqual(@as(usize, 1), s.cursor.y); // Clone - var s2 = try s.clone(alloc, .{ .active = .{ .y = 1 } }, null); + var s2 = try s.clone(io, alloc, .{ .active = .{ .y = 1 } }, null); defer s2.deinit(); { const contents = try s2.dumpStringAlloc(alloc, .{ .active = .{} }); @@ -5824,8 +6399,9 @@ test "Screen: clone partial" { test "Screen: clone partial cursor out of bounds" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 10 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH"); { @@ -5838,6 +6414,7 @@ test "Screen: clone partial cursor out of bounds" { // Clone var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = 0 } }, @@ -5857,8 +6434,9 @@ test "Screen: clone partial cursor out of bounds" { test "Screen: clone contains full selection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5871,6 +6449,7 @@ test "Screen: clone contains full selection" { // Clone var s2 = try s.clone( + io, alloc, .{ .active = .{} }, null, @@ -5894,8 +6473,9 @@ test "Screen: clone contains full selection" { test "Screen: clone contains none of selection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5908,6 +6488,7 @@ test "Screen: clone contains none of selection" { // Clone var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 1 } }, null, @@ -5921,8 +6502,9 @@ test "Screen: clone contains none of selection" { test "Screen: clone contains selection start cutoff" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5935,6 +6517,7 @@ test "Screen: clone contains selection start cutoff" { // Clone var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 1 } }, null, @@ -5958,8 +6541,9 @@ test "Screen: clone contains selection start cutoff" { test "Screen: clone contains selection end cutoff" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5972,6 +6556,7 @@ test "Screen: clone contains selection end cutoff" { // Clone var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = 1 } }, @@ -5995,8 +6580,9 @@ test "Screen: clone contains selection end cutoff" { test "Screen: clone contains selection end cutoff reversed" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -6009,6 +6595,7 @@ test "Screen: clone contains selection end cutoff reversed" { // Clone var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = 1 } }, @@ -6032,8 +6619,9 @@ test "Screen: clone contains selection end cutoff reversed" { test "Screen: clone contains subset of selection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 4, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); @@ -6046,6 +6634,7 @@ test "Screen: clone contains subset of selection" { // Clone var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 1 } }, .{ .active = .{ .y = 2 } }, @@ -6069,8 +6658,9 @@ test "Screen: clone contains subset of selection" { test "Screen: clone clamps clipped selections to mixed-width pages" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 4, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 4, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const first = s.pages.pages.first.?; @@ -6086,6 +6676,7 @@ test "Screen: clone clamps clipped selections to mixed-width pages" { false, )); var linear = try s.clone( + io, alloc, .{ .screen = .{} }, .{ .screen = .{ .y = 1 } }, @@ -6101,6 +6692,7 @@ test "Screen: clone clamps clipped selections to mixed-width pages" { true, )); var rectangle = try s.clone( + io, alloc, .{ .screen = .{ .y = 1 } }, null, @@ -6114,8 +6706,9 @@ test "Screen: clone clamps clipped selections to mixed-width pages" { test "Screen: clone contains subset of rectangle selection" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 4, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 4, .max_scrollback_bytes = 1 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD"); @@ -6128,6 +6721,7 @@ test "Screen: clone contains subset of rectangle selection" { // Clone var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 1 } }, .{ .active = .{ .y = 2 } }, @@ -6153,13 +6747,15 @@ test "Screen: clone contains subset of rectangle selection" { test "Screen: clone basic" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); { var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 1 } }, .{ .active = .{ .y = 1 } }, @@ -6174,6 +6770,7 @@ test "Screen: clone basic" { { var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 1 } }, .{ .active = .{ .y = 2 } }, @@ -6190,12 +6787,14 @@ test "Screen: clone basic" { test "Screen: clone empty viewport" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); { var s2 = try s.clone( + io, alloc, .{ .viewport = .{ .y = 0 } }, .{ .viewport = .{ .y = 0 } }, @@ -6212,13 +6811,15 @@ test "Screen: clone empty viewport" { test "Screen: clone one line viewport" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1ABC"); { var s2 = try s.clone( + io, alloc, .{ .viewport = .{ .y = 0 } }, .{ .viewport = .{ .y = 0 } }, @@ -6235,12 +6836,14 @@ test "Screen: clone one line viewport" { test "Screen: clone empty active" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); { var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 0 } }, .{ .active = .{ .y = 0 } }, @@ -6257,13 +6860,15 @@ test "Screen: clone empty active" { test "Screen: clone one line active with extra space" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1ABC"); { var s2 = try s.clone( + io, alloc, .{ .active = .{ .y = 0 } }, null, @@ -6280,8 +6885,9 @@ test "Screen: clone one line active with extra space" { test "Screen: clear history with no history" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 3 }); defer s.deinit(); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); try testing.expect(s.pages.viewport == .active); @@ -6304,8 +6910,9 @@ test "Screen: clear history with no history" { test "Screen: clear history" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL"); try testing.expect(s.pages.viewport == .active); @@ -6338,8 +6945,9 @@ test "Screen: clear history" { test "Screen: clear above cursor" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 3 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 3 }); defer s.deinit(); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); s.clearRows( @@ -6365,8 +6973,9 @@ test "Screen: clear above cursor" { test "Screen: clear above cursor with history" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 3 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 3 }); defer s.deinit(); try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n"); try s.testWriteString("4ABCD\n5EFGH\n6IJKL"); @@ -6393,8 +7002,9 @@ test "Screen: clear above cursor with history" { test "Screen: resize (no reflow) more rows" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6411,8 +7021,9 @@ test "Screen: resize (no reflow) more rows" { test "Screen: resize (no reflow) less rows" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6434,8 +7045,9 @@ test "Screen: resize (no reflow) less rows" { test "Screen: resize (no reflow) less rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str); @@ -6469,8 +7081,9 @@ test "Screen: resize (no reflow) less rows trims blank lines" { test "Screen: resize (no reflow) more rows trims blank lines" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str); @@ -6504,8 +7117,9 @@ test "Screen: resize (no reflow) more rows trims blank lines" { test "Screen: resize (no reflow) more cols" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6521,8 +7135,9 @@ test "Screen: resize (no reflow) more cols" { test "Screen: resize (no reflow) less cols" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6539,8 +7154,9 @@ test "Screen: resize (no reflow) less cols" { test "Screen: resize (no reflow) more rows with scrollback cursor end" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 7, .rows = 3, .max_scrollback = 2 }); + var s = try init(io, alloc, .{ .cols = 7, .rows = 3, .max_scrollback_bytes = 2 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6556,8 +7172,9 @@ test "Screen: resize (no reflow) more rows with scrollback cursor end" { test "Screen: resize (no reflow) less rows with scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 7, .rows = 3, .max_scrollback = 2 }); + var s = try init(io, alloc, .{ .cols = 7, .rows = 3, .max_scrollback_bytes = 2 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6575,8 +7192,9 @@ test "Screen: resize (no reflow) less rows with scrollback" { test "Screen: resize (no reflow) less rows with empty trailing" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); @@ -6599,8 +7217,9 @@ test "Screen: resize (no reflow) less rows with empty trailing" { test "Screen: resize (no reflow) more rows with soft wrapping" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 3 }); + var s = try init(io, alloc, .{ .cols = 2, .rows = 3, .max_scrollback_bytes = 3 }); defer s.deinit(); const str = "1A2B\n3C4E\n5F6G"; try s.testWriteString(str); @@ -6640,8 +7259,9 @@ test "Screen: resize (no reflow) more rows with soft wrapping" { test "Screen: resize more rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6667,8 +7287,9 @@ test "Screen: resize more rows no scrollback" { test "Screen: resize more rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 10 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6694,8 +7315,9 @@ test "Screen: resize more rows with empty scrollback" { test "Screen: resize more rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -6713,7 +7335,7 @@ test "Screen: resize more rows with populated scrollback" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint.data); } // Resize @@ -6725,7 +7347,7 @@ test "Screen: resize more rows with populated scrollback" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, '4'), list_cell.cell.content.codepoint.data); } { @@ -6739,8 +7361,9 @@ test "Screen: resize more rows with populated scrollback" { test "Screen: resize more cols no reflow" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6768,8 +7391,9 @@ test "Screen: resize more cols no reflow" { test "Screen: resize more cols perfect split" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); @@ -6786,8 +7410,9 @@ test "Screen: resize more cols perfect split" { test "Screen: resize (no reflow) more cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); @@ -6819,8 +7444,9 @@ test "Screen: resize (no reflow) more cols with scrollback scrolled up" { test "Screen: resize (no reflow) less cols with scrollback scrolled up" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); const str = "1\n2\n3\n4\n5\n6\n7\n8"; try s.testWriteString(str); @@ -6863,8 +7489,9 @@ test "Screen: resize (no reflow) less cols with scrollback scrolled up" { test "Screen: resize more cols no reflow preserves semantic prompt" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); // Set one of the rows to be a prompt @@ -6907,8 +7534,9 @@ test "Screen: resize more cols no reflow preserves semantic prompt" { test "Screen: resize more cols with reflow that fits full width" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6928,7 +7556,7 @@ test "Screen: resize more cols with reflow that fits full width" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint.data); } // Resize and verify we undid the soft wrap because we have space now @@ -6947,8 +7575,9 @@ test "Screen: resize more cols with reflow that fits full width" { test "Screen: resize more cols with reflow that ends in newline" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 6, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 6, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -6968,7 +7597,7 @@ test "Screen: resize more cols with reflow that ends in newline" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint.data); } // Resize and verify we undid the soft wrap because we have space now @@ -6985,15 +7614,16 @@ test "Screen: resize more cols with reflow that ends in newline" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint.data); } } test "Screen: resize more cols with reflow that forces more wrapping" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3IJKL"; try s.testWriteString(str); @@ -7005,7 +7635,7 @@ test "Screen: resize more cols with reflow that forces more wrapping" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, '2'), list_cell.cell.content.codepoint.data); } // Verify we soft wrapped @@ -7033,8 +7663,9 @@ test "Screen: resize more cols with reflow that forces more wrapping" { test "Screen: resize more cols with reflow that unwraps multiple times" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); @@ -7046,7 +7677,7 @@ test "Screen: resize more cols with reflow that unwraps multiple times" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, '3'), list_cell.cell.content.codepoint.data); } // Verify we soft wrapped @@ -7074,8 +7705,9 @@ test "Screen: resize more cols with reflow that unwraps multiple times" { test "Screen: resize more cols with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH"; try s.testWriteString(str); @@ -7093,7 +7725,7 @@ test "Screen: resize more cols with populated scrollback" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint.data); } // Resize @@ -7111,7 +7743,7 @@ test "Screen: resize more cols with populated scrollback" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u21, '5'), list_cell.cell.content.codepoint.data); } } @@ -7123,11 +7755,12 @@ test "Screen: resize more cols bounded scrollback keeps viewport valid" { // resize path. A direct PageList resize repro does not hit the same bug. const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ + var s = try init(io, alloc, .{ .cols = 2, .rows = 10, - .max_scrollback = 10_000, + .max_scrollback_bytes = 10_000, }); defer s.deinit(); @@ -7158,7 +7791,7 @@ test "Screen: resize more cols bounded scrollback keeps viewport valid" { for (0..s.pages.cols) |x| { page.getRowAndCell(x, y).cell.* = .{ .content_tag = .codepoint, - .content = .{ .codepoint = 'A' }, + .content = .{ .codepoint = @bitCast(@as(u24, 'A')) }, }; } } @@ -7198,8 +7831,9 @@ test "Screen: resize more cols bounded scrollback keeps viewport valid" { test "Screen: resize more cols with reflow" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 2, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); const str = "1ABC\n2DEF\n3ABC\n4DEF"; try s.testWriteString(str); @@ -7211,7 +7845,7 @@ test "Screen: resize more cols with reflow" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint.data); } // Verify we soft wrapped @@ -7236,11 +7870,208 @@ test "Screen: resize more cols with reflow" { try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y); } +test "Screen: resize errors preserve state" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + for (std.meta.tags(resize_tw.FailPoint)) |tag| { + const tw = resize_tw; + defer tw.end(.reset) catch unreachable; + + var s = try init(io, alloc, .{ + .cols = 10, + .rows = 3, + .max_scrollback_bytes = 0, + }); + defer s.deinit(); + + s.cursorSetSemanticContent(.{ .prompt = .initial }); + try s.testWriteString("> "); + s.cursorSetSemanticContent(.{ .input = .clear_explicit }); + try s.testWriteString("echo"); + try s.setAttribute(.{ .bold = {} }); + try s.startHyperlink("https://example.com", "resize"); + s.saved_cursor = .{ + .x = 1, + .y = 0, + .style = s.cursor.style, + .protected = s.cursor.protected, + .pending_wrap = s.cursor.pending_wrap, + .origin = false, + .charset = s.charset, + }; + + if (comptime build_options.kitty_graphics) { + s.kitty_images.dirty = false; + } + + // Keep a shallow copy for all non-page state and a byte-for-byte + // copy of the sole page so reference counts and prompt contents are + // covered as well. + try testing.expectEqual(s.pages.pages.first, s.pages.pages.last); + const before = s; + const before_viewport_pin = s.pages.viewport_pin.*; + const before_tracked_pins = s.pages.countTrackedPins(); + const before_page = try alloc.dupe( + u8, + s.pages.pages.first.?.page().memory, + ); + defer alloc.free(before_page); + + tw.errorAlways(tag, error.OutOfMemory); + try testing.expectError(error.OutOfMemory, s.resize(.{ + .cols = 20, + .rows = 4, + .prompt_redraw = .true, + })); + + try testing.expect(std.meta.eql(before.cursor, s.cursor)); + try testing.expect(std.meta.eql(before.saved_cursor, s.saved_cursor)); + try testing.expect(std.meta.eql(before.selection, s.selection)); + try testing.expect(std.meta.eql(before.charset, s.charset)); + try testing.expectEqual(before.protected_mode, s.protected_mode); + try testing.expect(std.meta.eql(before.kitty_keyboard, s.kitty_keyboard)); + try testing.expect(std.meta.eql(before.semantic_prompt, s.semantic_prompt)); + try testing.expectEqual(before.dirty, s.dirty); + try testing.expectEqual(before.pages.pages.first, s.pages.pages.first); + try testing.expectEqual(before.pages.pages.last, s.pages.pages.last); + try testing.expectEqual(before.pages.cols, s.pages.cols); + try testing.expectEqual(before.pages.rows, s.pages.rows); + try testing.expectEqual(before.pages.total_rows, s.pages.total_rows); + try testing.expectEqual(before.pages.viewport, s.pages.viewport); + try testing.expectEqual(before_viewport_pin, s.pages.viewport_pin.*); + try testing.expectEqual(before_tracked_pins, s.pages.countTrackedPins()); + if (std.valgrind.runningOnValgrind() > 0) { + // This assertion deliberately compares the complete raw page, + // including semantically irrelevant struct padding. + std.valgrind.memcheck.makeMemDefined(before_page); + std.valgrind.memcheck.makeMemDefined( + s.pages.pages.first.?.page().memory, + ); + } + try testing.expectEqualSlices( + u8, + before_page, + s.pages.pages.first.?.page().memory, + ); + if (comptime build_options.kitty_graphics) { + try testing.expectEqual( + before.kitty_images.dirty, + s.kitty_images.dirty, + ); + } + } +} + +test "Screen: resize cursor references when node survives" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ + .cols = 5, + .rows = 3, + .max_scrollback_bytes = 1000, + }); + defer s.deinit(); + + try s.setAttribute(.bold); + try s.startHyperlink("https://example.com/", "resize"); + try s.testWriteString("abc"); + + const original_node = s.cursor.page_pin.node; + const original_serial = original_node.serial; + { + const page = original_node.page(); + try testing.expectEqual( + 4, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + try testing.expectEqual( + 4, + page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id), + ); + } + + // A row-only resize grows the existing page without replacing the + // cursor's node. The temporary resize references must be released from + // this page after the cursor state is restored. + try s.resize(.{ .cols = 5, .rows = 4 }); + + try testing.expectEqual(original_node, s.cursor.page_pin.node); + try testing.expectEqual(original_serial, s.cursor.page_pin.node.serial); + { + const page = s.cursor.page_pin.node.page(); + try testing.expectEqual( + 4, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + try testing.expectEqual( + 4, + page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id), + ); + } +} + +test "Screen: resize cursor references when node is replaced" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ + .cols = 5, + .rows = 3, + .max_scrollback_bytes = 1000, + }); + defer s.deinit(); + + try s.setAttribute(.bold); + try s.startHyperlink("https://example.com/", "resize"); + try s.testWriteString("abc"); + + const original_node = s.cursor.page_pin.node; + const original_serial = original_node.serial; + { + const page = original_node.page(); + try testing.expectEqual( + 4, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + try testing.expectEqual( + 4, + page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id), + ); + } + + // A column resize with reflow replaces the page and remaps the tracked + // cursor pin. The old page owns the temporary references, so destroying + // it must account for them without attempting to release them afterward. + try s.resize(.{ .cols = 10, .rows = 3 }); + + try testing.expect( + s.cursor.page_pin.node != original_node or + s.cursor.page_pin.node.serial != original_serial, + ); + { + const page = s.cursor.page_pin.node.page(); + try testing.expectEqual( + 4, + page.styles.refCount(page.memory, s.cursor.style_id), + ); + try testing.expectEqual( + 4, + page.hyperlink_set.refCount(page.memory, s.cursor.hyperlink_id), + ); + } +} + test "Screen: resize more rows and cols with wrapping" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 2, .rows = 4, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 2, .rows = 4, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1A2B\n3C4D"; try s.testWriteString(str); @@ -7272,8 +8103,9 @@ test "Screen: resize more rows and cols with wrapping" { test "Screen: resize less rows no scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -7303,8 +8135,9 @@ test "Screen: resize less rows no scrollback" { test "Screen: resize less rows moving cursor" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -7316,7 +8149,7 @@ test "Screen: resize less rows moving cursor" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u32, 'I'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u32, 'I'), list_cell.cell.content.codepoint.data); } // Resize @@ -7343,8 +8176,9 @@ test "Screen: resize less rows moving cursor" { test "Screen: resize less rows with empty scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 10 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -7366,8 +8200,9 @@ test "Screen: resize less rows with empty scrollback" { test "Screen: resize less rows with populated scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -7397,8 +8232,9 @@ test "Screen: resize less rows with populated scrollback" { test "Screen: resize less rows with full scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 3 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 3 }); defer s.deinit(); const str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -7437,8 +8273,9 @@ test "Screen: resize less rows with full scrollback" { test "Screen: resize less cols no reflow" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1AB\n2EF\n3IJ"; try s.testWriteString(str); @@ -7466,8 +8303,9 @@ test "Screen: resize less cols no reflow" { test "Screen: resize less cols with reflow but row space" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); const str = "1ABCD"; try s.testWriteString(str); @@ -7479,7 +8317,7 @@ test "Screen: resize less cols with reflow but row space" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u32, 'D'), list_cell.cell.content.codepoint.data); } try s.resize(.{ .cols = 3, .rows = 3 }); @@ -7504,8 +8342,9 @@ test "Screen: resize less cols with reflow but row space" { test "Screen: resize less cols with reflow with trimmed rows" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -7528,8 +8367,9 @@ test "Screen: resize less cols with reflow with trimmed rows" { test "Screen: resize less cols with reflow with trimmed rows and scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); const str = "3IJKL\n4ABCD\n5EFGH"; try s.testWriteString(str); @@ -7552,8 +8392,9 @@ test "Screen: resize less cols with reflow with trimmed rows and scrollback" { test "Screen: resize less cols with reflow previously wrapped" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "3IJKL4ABCD5EFGH"; try s.testWriteString(str); @@ -7585,8 +8426,9 @@ test "Screen: resize less cols with reflow previously wrapped" { test "Screen: resize less cols with reflow and scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); @@ -7598,7 +8440,7 @@ test "Screen: resize less cols with reflow and scrollback" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u32, 'E'), list_cell.cell.content.codepoint.data); } try s.resize(.{ .cols = 3, .rows = 3 }); @@ -7618,8 +8460,9 @@ test "Screen: resize less cols with reflow and scrollback" { test "Screen: resize less cols with reflow previously wrapped and scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 2 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 2 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL4ABCD5EFGH"; try s.testWriteString(str); @@ -7639,7 +8482,7 @@ test "Screen: resize less cols with reflow previously wrapped and scrollback" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint.data); } try s.resize(.{ .cols = 3, .rows = 3 }); @@ -7665,15 +8508,16 @@ test "Screen: resize less cols with reflow previously wrapped and scrollback" { .x = s.cursor.x, .y = s.cursor.y, } }).?; - try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint); + try testing.expectEqual(@as(u32, 'H'), list_cell.cell.content.codepoint.data); } } test "Screen: resize less cols with scrollback keeps cursor row" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); const str = "1A\n2B\n3C\n4D\n5E"; try s.testWriteString(str); @@ -7701,8 +8545,9 @@ test "Screen: resize less cols with scrollback keeps cursor row" { test "Screen: resize more rows, less cols with reflow with scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 3 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 3 }); defer s.deinit(); const str = "1ABCD\n2EFGH3IJKL\n4MNOP"; try s.testWriteString(str); @@ -7742,8 +8587,9 @@ test "Screen: resize more rows, less cols with reflow with scrollback" { test "Screen: resize more rows then shrink again" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 10 }); defer s.deinit(); const str = "1ABC"; try s.testWriteString(str); @@ -7791,8 +8637,9 @@ test "Screen: resize more rows then shrink again" { test "Screen: resize less cols to eliminate wide char" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 2, .rows = 1, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 2, .rows = 1, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "😀"; try s.testWriteString(str); @@ -7805,7 +8652,7 @@ test "Screen: resize less cols to eliminate wide char" { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint.data); } // Resize to 1 column can't fit a wide char. So it should be deleted. @@ -7818,7 +8665,7 @@ test "Screen: resize less cols to eliminate wide char" { { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; - try testing.expectEqual(@as(u21, 0), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -7826,8 +8673,9 @@ test "Screen: resize less cols to eliminate wide char" { test "Screen: resize less cols to wrap wide char" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 3, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 3, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "x😀"; try s.testWriteString(str); @@ -7840,7 +8688,7 @@ test "Screen: resize less cols to wrap wide char" { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint.data); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; @@ -7865,8 +8713,9 @@ test "Screen: resize less cols to wrap wide char" { test "Screen: resize less cols to eliminate wide char with row space" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "😀"; try s.testWriteString(str); @@ -7879,7 +8728,7 @@ test "Screen: resize less cols to eliminate wide char with row space" { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint.data); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 0 } }).?; @@ -7898,7 +8747,8 @@ test "Screen: resize less cols to eliminate wide char with row space" { test "Screen: resize less cols reflows cursor after wrapped text" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, .{ .cols = 50, .rows = 7, .max_scrollback = 0 }); + const io = testing.io; + var s = try Screen.init(io, alloc, .{ .cols = 50, .rows = 7, .max_scrollback_bytes = 0 }); defer s.deinit(); for (0..30) |_| try s.testWriteString("a"); @@ -7915,7 +8765,8 @@ test "Screen: resize less cols reflows cursor after wrapped text" { test "Screen: resize less cols reflows cursor after empty cells" { const testing = std.testing; const alloc = testing.allocator; - var s = try Screen.init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); + const io = testing.io; + var s = try Screen.init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("abc"); @@ -7933,8 +8784,9 @@ test "Screen: resize less cols reflows cursor after empty cells" { test "Screen: resize more cols with wide spacer head" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 3, .rows = 2, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 3, .rows = 2, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = " 😀"; try s.testWriteString(str); @@ -7974,7 +8826,7 @@ test "Screen: resize more cols with wide spacer head" { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint.data); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 3, .y = 0 } }).?; @@ -7986,8 +8838,9 @@ test "Screen: resize more cols with wide spacer head" { test "Screen: resize more cols with wide spacer head multiple lines" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 3, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 3, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "xxxyy😀"; try s.testWriteString(str); @@ -8025,7 +8878,7 @@ test "Screen: resize more cols with wide spacer head multiple lines" { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint.data); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 6, .y = 0 } }).?; @@ -8037,8 +8890,9 @@ test "Screen: resize more cols with wide spacer head multiple lines" { test "Screen: resize more cols requiring a wide spacer head" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 2, .rows = 2, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 2, .rows = 2, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "xx😀"; try s.testWriteString(str); @@ -8076,7 +8930,7 @@ test "Screen: resize more cols requiring a wide spacer head" { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 0, .y = 1 } }).?; const cell = list_cell.cell; try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint); + try testing.expectEqual(@as(u21, '😀'), cell.content.codepoint.data); } { const list_cell = s.pages.getCell(.{ .screen = .{ .x = 1, .y = 1 } }).?; @@ -8088,8 +8942,9 @@ test "Screen: resize more cols requiring a wide spacer head" { test "Screen: resize more cols with cursor at prompt" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); // zig fmt: off @@ -8128,8 +8983,9 @@ test "Screen: resize more cols with cursor at prompt" { test "Screen: resize more cols with cursor not at prompt" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); // zig fmt: off @@ -8169,8 +9025,9 @@ test "Screen: resize more cols with cursor not at prompt" { test "Screen: resize with prompt_redraw last clears only one line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 4, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 4, .max_scrollback_bytes = 5 }); defer s.deinit(); // zig fmt: off @@ -8208,8 +9065,9 @@ test "Screen: resize with prompt_redraw last clears only one line" { test "Screen: resize with prompt_redraw last multiline prompt clears only last line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 5 }); defer s.deinit(); // Create a 3-line prompt: 1 initial + 2 continuation lines @@ -8248,8 +9106,9 @@ test "Screen: resize with prompt_redraw last multiline prompt clears only last l test "Screen: select untracked" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); @@ -8268,8 +9127,9 @@ test "Screen: select untracked" { test "Screen: select replaces existing pins" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); @@ -8294,8 +9154,9 @@ test "Screen: select replaces existing pins" { test "Screen: reselecting tracked selection preserves its pins" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 2, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 2, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.select(Selection.init( @@ -8311,8 +9172,9 @@ test "Screen: reselecting tracked selection preserves its pins" { test "Screen: selectAll" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); { @@ -8347,8 +9209,9 @@ test "Screen: selectAll" { test "Screen: selectLine" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); @@ -8428,8 +9291,9 @@ test "Screen: selectLine" { test "Screen: selectLine across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); @@ -8454,8 +9318,9 @@ test "Screen: selectLine across soft-wrap" { test "Screen: selectLine across full soft-wrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1ABCD2EFGH\n3IJKL"); @@ -8479,8 +9344,9 @@ test "Screen: selectLine across full soft-wrap" { test "Screen: selectLine across soft-wrap ignores blank lines" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); @@ -8539,8 +9405,9 @@ test "Screen: selectLine across soft-wrap ignores blank lines" { test "Screen: selectLine disabled whitespace trimming" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString(" 12 34012 \n 123"); @@ -8588,8 +9455,9 @@ test "Screen: selectLine disabled whitespace trimming" { test "Screen: selectLine with scrollback" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 2, .rows = 3, .max_scrollback = 5 }); + var s = try init(io, alloc, .{ .cols = 2, .rows = 3, .max_scrollback_bytes = 5 }); defer s.deinit(); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -8632,8 +9500,9 @@ test "Screen: selectLine with scrollback" { test "Screen: selectLine semantic prompt boundary" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("ABCDE\n"); s.cursorSetSemanticContent(.{ .prompt = .initial }); @@ -8682,8 +9551,9 @@ test "Screen: selectLine semantic prompt boundary" { test "Screen: selectLine semantic prompt to input boundary" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Write prompt followed by user input on same row: "$>command" @@ -8731,8 +9601,9 @@ test "Screen: selectLine semantic prompt to input boundary" { test "Screen: selectLine semantic input to output boundary" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Row 0: user input @@ -8776,8 +9647,9 @@ test "Screen: selectLine semantic input to output boundary" { test "Screen: selectLine semantic mid-row boundary" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Single row with output then prompt then input: "out$>cmd" @@ -8844,8 +9716,9 @@ test "Screen: selectLine semantic mid-row boundary" { test "Screen: selectLine semantic boundary soft-wrap with mid-row transition" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Row 0: prompt "$ " + input "cmd" (soft-wraps) @@ -8913,8 +9786,9 @@ test "Screen: selectLine semantic boundary soft-wrap with mid-row transition" { test "Screen: selectLine semantic boundary disabled" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Write prompt followed by input @@ -8945,8 +9819,9 @@ test "Screen: selectLine semantic boundary disabled" { test "Screen: selectLine semantic boundary first cell of row" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Row 0: input that soft-wraps @@ -9001,8 +9876,9 @@ test "Screen: selectLine semantic boundary first cell of row" { test "Screen: selectLine semantic boundary across mixed-width pages" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 4, .rows = 2, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 4, .rows = 2, .max_scrollback_bytes = 0 }); defer s.deinit(); s.cursorSetSemanticContent(.{ .input = .clear_explicit }); @@ -9032,8 +9908,9 @@ test "Screen: selectLine semantic boundary across mixed-width pages" { test "Screen: selectLine semantic all same content" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // All prompt content that soft-wraps @@ -9066,15 +9943,19 @@ test "Screen: selectLine semantic all same content" { test "Screen: selectWord" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("ABC DEF\n 123\n456"); // Default boundary codepoints for word selection const boundary_codepoints = &[_]u21{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + 0, ' ', '\t', '\'', '"', + '│', + '`', '|', ':', ';', ',', + '(', ')', '[', ']', '{', + '}', '<', '>', '$', }; // Outside of active area @@ -9187,15 +10068,19 @@ test "Screen: selectWord" { test "Screen: selectWord across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString(" 1234012\n 123"); // Default boundary codepoints for word selection const boundary_codepoints = &[_]u21{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + 0, ' ', '\t', '\'', '"', + '│', + '`', '|', ':', ';', ',', + '(', ')', '[', ']', '{', + '}', '<', '>', '$', }; { @@ -9259,15 +10144,19 @@ test "Screen: selectWord across soft-wrap" { test "Screen: selectWord whitespace across soft-wrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString("1 1\n 123"); // Default boundary codepoints for word selection const boundary_codepoints = &[_]u21{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + 0, ' ', '\t', '\'', '"', + '│', + '`', '|', ':', ';', ',', + '(', ')', '[', ']', '{', + '}', '<', '>', '$', }; // Going forward @@ -9325,11 +10214,15 @@ test "Screen: selectWord whitespace across soft-wrap" { test "Screen: selectWord with character boundary" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; // Default boundary codepoints for word selection const boundary_codepoints = &[_]u21{ - 0, ' ', '\t', '\'', '"', '│', '`', '|', ':', ';', - ',', '(', ')', '[', ']', '{', '}', '<', '>', '$', + 0, ' ', '\t', '\'', '"', + '│', + '`', '|', ':', ';', ',', + '(', ')', '[', ']', '{', + '}', '<', '>', '$', }; const cases = [_][]const u8{ @@ -9353,7 +10246,7 @@ test "Screen: selectWord with character boundary" { }; for (cases) |case| { - var s = try init(alloc, .{ .cols = 20, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.testWriteString(case); @@ -9432,8 +10325,9 @@ test "Screen: selectWord with character boundary" { test "Screen: selectOutput" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 15, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 15, .max_scrollback_bytes = 0 }); defer s.deinit(); // Build content with cell-level semantic content: @@ -9528,8 +10422,9 @@ test "Screen: selectOutput" { test "Screen: selectionString basic" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -9553,8 +10448,9 @@ test "Screen: selectionString basic" { test "Screen: selectionString start outside of written area" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -9578,8 +10474,9 @@ test "Screen: selectionString start outside of written area" { test "Screen: selectionString end outside of written area" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -9603,8 +10500,9 @@ test "Screen: selectionString end outside of written area" { test "Screen: selectionString trim space" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1AB \n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -9640,8 +10538,9 @@ test "Screen: selectionString trim space" { test "Screen: selectionString trim empty line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1AB \n\n2EFGH\n3IJKL"; try s.testWriteString(str); @@ -9677,8 +10576,9 @@ test "Screen: selectionString trim empty line" { test "Screen: selectionString soft wrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD2EFGH3IJKL"; try s.testWriteString(str); @@ -9702,8 +10602,9 @@ test "Screen: selectionString soft wrap" { test "Screen: selectionString wide char" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1A⚡"; try s.testWriteString(str); @@ -9757,8 +10658,9 @@ test "Screen: selectionString wide char" { test "Screen: selectionString wide char with header" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABC⚡"; try s.testWriteString(str); @@ -9783,8 +10685,9 @@ test "Screen: selectionString wide char with header" { test "Screen: selectionString empty with soft wrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 2, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 2, .max_scrollback_bytes = 0 }); defer s.deinit(); // Let me describe the situation that caused this because this @@ -9816,8 +10719,9 @@ test "Screen: selectionString empty with soft wrap" { test "Screen: selectionString with zero width joiner" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 1, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 1, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "👨"; // this has a ZWJ try s.testWriteString(str); @@ -9826,7 +10730,7 @@ test "Screen: selectionString with zero width joiner" { { const pin = s.pages.pin(.{ .screen = .{ .y = 0, .x = 0 } }).?; const cell = pin.rowAndCell().cell; - try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); + try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint.data); try testing.expectEqual(Cell.Wide.wide, cell.wide); const cps = pin.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); @@ -9852,8 +10756,9 @@ test "Screen: selectionString with zero width joiner" { test "Screen: selectionString, rectangle, basic" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 30, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 30, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -9885,8 +10790,9 @@ test "Screen: selectionString, rectangle, basic" { test "Screen: selectionString, rectangle, w/EOL" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 30, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 30, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -9920,8 +10826,9 @@ test "Screen: selectionString, rectangle, w/EOL" { test "Screen: selectionString, rectangle, more complex w/breaks" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 30, .rows = 8, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 30, .rows = 8, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = \\Lorem ipsum dolor @@ -9959,8 +10866,9 @@ test "Screen: selectionString, rectangle, more complex w/breaks" { test "Screen: selectionString multi-page" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 2048 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 2048 }); defer s.deinit(); const first_page_size = s.pages.pages.first.?.capacity().rows; @@ -9993,8 +10901,9 @@ test "Screen: selectionString multi-page" { test "Screen: lineIterator" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD\n2EFGH"; try s.testWriteString(str); @@ -10024,8 +10933,9 @@ test "Screen: lineIterator" { test "Screen: lineIterator soft wrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); const str = "1ABCD2EFGH\n3ABCD"; try s.testWriteString(str); @@ -10056,8 +10966,9 @@ test "Screen: lineIterator soft wrap" { test "Screen: hyperlink start/end" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); { @@ -10083,8 +10994,9 @@ test "Screen: hyperlink start/end" { test "Screen: hyperlink accepts its current values" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); try s.startHyperlink("http://example.com", "current"); @@ -10098,8 +11010,9 @@ test "Screen: hyperlink accepts its current values" { test "Screen: implicit hyperlink ID wraps" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); s.cursor.hyperlink_implicit_id = std.math.maxInt(size.OffsetInt); @@ -10135,8 +11048,9 @@ test "Screen: implicit hyperlink ID wraps" { test "Screen: hyperlink reuse" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); @@ -10169,12 +11083,13 @@ test "Screen: hyperlink reuse" { test "Screen: hyperlink cursor state on resize" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; // This test depends on underlying PageList implementation so // it may be invalid one day. It's here to document/verify the // current behavior. - var s = try init(alloc, .{ .cols = 5, .rows = 10, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 5, .rows = 10, .max_scrollback_bytes = 0 }); defer s.deinit(); // Start a hyperlink @@ -10204,8 +11119,9 @@ test "Screen: hyperlink cursor state on resize" { test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 80, .rows = 24, .max_scrollback_bytes = 0 }); defer s.deinit(); // Start a hyperlink with a URI that just barely fits in the string alloc. @@ -10238,11 +11154,12 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { test "Screen: increaseCapacity cursor style ref count preserved" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, - .max_scrollback = 0, + .max_scrollback_bytes = 0, }); defer s.deinit(); try s.setAttribute(.bold); @@ -10293,11 +11210,12 @@ test "Screen: increaseCapacity cursor style ref count preserved" { test "Screen: increaseCapacity cursor hyperlink ref count preserved" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, - .max_scrollback = 0, + .max_scrollback_bytes = 0, }); defer s.deinit(); try s.startHyperlink("https://example.com/", null); @@ -10336,11 +11254,12 @@ test "Screen: increaseCapacity cursor hyperlink ref count preserved" { test "Screen: increaseCapacity cursor with both style and hyperlink preserved" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, - .max_scrollback = 0, + .max_scrollback_bytes = 0, }); defer s.deinit(); @@ -10401,11 +11320,12 @@ test "Screen: increaseCapacity non-cursor page returns early" { // extra cursor accounting (style/hyperlink re-adding). const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ + var s = try init(io, alloc, .{ .cols = 80, .rows = 24, - .max_scrollback = 10000, + .max_scrollback_bytes = 10000, }); defer s.deinit(); @@ -10486,9 +11406,10 @@ test "Screen: cursorDown to page with insufficient capacity" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; // Small screen to make page boundary crossing easy to set up - var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 1 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 3, .max_scrollback_bytes = 1 }); defer s.deinit(); // Scroll down enough to create a second page @@ -10555,9 +11476,10 @@ test "Screen setAttribute increases capacity when style map is full" { // returns OutOfSpace, manualStyleUpdate will split the page instead. const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; // Use a small screen with multiple rows - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10 }); defer s.deinit(); // Write content to multiple rows @@ -10604,11 +11526,12 @@ test "Screen setAttribute increases capacity when style map is full" { test "Screen setAttribute splits page on OutOfSpace at max styles" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ + var s = try init(io, alloc, .{ .cols = 10, .rows = 10, - .max_scrollback = 0, + .max_scrollback_bytes = 0, }); defer s.deinit(); @@ -10670,42 +11593,12 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { try testing.expect(page_was_split); } -test "selectionString map allocation failure cleanup" { - // This test verifies that if toOwnedSlice fails when building - // the StringMap, we don't leak the already-allocated map.string. - const testing = std.testing; - const alloc = testing.allocator; - var s = try Screen.init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); - defer s.deinit(); - - try s.testWriteString("hello"); - - // Get a selection - const sel = Selection.init( - s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?, - s.pages.pin(.{ .active = .{ .x = 4, .y = 0 } }).?, - false, - ); - - // Trigger allocation failure on toOwnedSlice - var map: StringMap = undefined; - selectionString_tw.errorAlways(.copy_map, error.OutOfMemory); - const result = s.selectionString(alloc, .{ - .sel = sel, - .map = &map, - }); - try testing.expectError(error.OutOfMemory, result); - try selectionString_tw.end(.reset); - - // If this test passes without memory leaks (when run with testing.allocator), - // it means the errdefer properly cleaned up map.string when toOwnedSlice failed. -} - test "Screen: promptClickMove line right basic" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -10731,8 +11624,9 @@ test "Screen: promptClickMove line right basic" { test "Screen: promptClickMove line right cursor not on input" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -10758,8 +11652,9 @@ test "Screen: promptClickMove line right cursor not on input" { test "Screen: promptClickMove line right click on same position" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -10784,8 +11679,9 @@ test "Screen: promptClickMove line right click on same position" { test "Screen: promptClickMove line right skips non-input cells" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -10816,8 +11712,9 @@ test "Screen: promptClickMove line right skips non-input cells" { test "Screen: promptClickMove line right soft-wrapped line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -10854,8 +11751,9 @@ test "Screen: promptClickMove line right soft-wrapped line" { test "Screen: promptClickMove disabled when click is none" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Click mode is .none by default (disabled) @@ -10880,8 +11778,9 @@ test "Screen: promptClickMove disabled when click is none" { test "Screen: promptClickMove line right stops at hard wrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -10912,8 +11811,9 @@ test "Screen: promptClickMove line right stops at hard wrap" { test "Screen: promptClickMove line right stops at non-continuation row" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -10959,8 +11859,9 @@ test "Screen: promptClickMove line right stops at non-continuation row" { test "Screen: promptClickMove line left basic" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -10986,8 +11887,9 @@ test "Screen: promptClickMove line left basic" { test "Screen: promptClickMove line left skips non-input cells" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -11018,8 +11920,9 @@ test "Screen: promptClickMove line left skips non-input cells" { test "Screen: promptClickMove line left soft-wrapped line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -11056,8 +11959,9 @@ test "Screen: promptClickMove line left soft-wrapped line" { test "Screen: promptClickMove line left stops at hard wrap" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -11088,11 +11992,12 @@ test "Screen: promptClickMove line left stops at hard wrap" { test "Screen: promptClickMove click right of input same line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; // Set up: "> hello" where "> " is prompt and "hello" is input // Clicking to the right of the 'o' should move cursor past the input - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -11118,8 +12023,9 @@ test "Screen: promptClickMove click right of input same line" { test "Screen: promptClickMove click right of input cursor at end" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -11144,8 +12050,9 @@ test "Screen: promptClickMove click right of input cursor at end" { test "Screen: promptClickMove click right of input on lower line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -11173,8 +12080,9 @@ test "Screen: promptClickMove click right of input on lower line" { test "Screen: promptClickMove click right of input cursor at end lower line" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -11198,8 +12106,9 @@ test "Screen: promptClickMove click right of input cursor at end lower line" { test "Screen: promptClickMove click right of input cursor on last char" { const testing = std.testing; const alloc = testing.allocator; + const io = testing.io; - var s = try init(alloc, .{ .cols = 20, .rows = 5, .max_scrollback = 0 }); + var s = try init(io, alloc, .{ .cols = 20, .rows = 5, .max_scrollback_bytes = 0 }); defer s.deinit(); // Enable line click mode @@ -11221,3 +12130,222 @@ test "Screen: promptClickMove click right of input cursor on last char" { try testing.expectEqual(@as(usize, 1), result.right); try testing.expectEqual(@as(usize, 0), result.left); } + +test "Screen: cursorScrollRegionUp recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 5, .rows = 5, .max_scrollback_bytes = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST"); + + // Simulate the top region row being part of a soft-wrapped, + // prompt-marked line. Its Row storage is recycled as the new + // blank cursor row and must not retain the metadata. + { + const rac = s.pages.getCell(.{ .active = .{} }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + s.cursorAbsolute(1, 2); + try s.cursorScrollRegionUp(2); + + { + const rac = s.pages.getCell(.{ .active = .{ .y = 2 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents); + } +} + +test "Screen: cursorScrollRegionUp cross-page recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10 }); + defer s.deinit(); + + // We need to get the active area to span two pages so that the + // scroll region does too, exercising the slow path (eraseRowBounded). + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + + // The first row of the last page is the Row storage that ends up + // recycled as the blank region-bottom row: the erased row's + // storage stays on the first page (receiving this row's content + // via clone) while this storage is cleared for the blank row. + { + const rac = s.pages.getCell(.{ .active = .{ .y = 3 } }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + // Region rows 1-3 with the cursor on the region bottom, which is + // on the second page while the region top is on the first page. + s.cursorAbsolute(0, 3); + try s.cursorScrollRegionUp(2); + + { + const rac = s.pages.getCell(.{ .active = .{ .y = 3 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents); + } +} + +test "Screen: cursorScrollAbove cross-page recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 10, .rows = 5, .max_scrollback_bytes = 10 }); + defer s.deinit(); + + // Get the cursor page and the last page to differ so that + // cursorScrollAbove takes the cross-page rotate path. + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); + for (0..first_page_size - 3) |_| try s.testWriteString("\n"); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); + try s.testWriteString("1A\n2B\n3C\n4D\n5E"); + s.cursorAbsolute(0, 1); + try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); + try testing.expect(s.pages.pages.first.?.next != null); + + // The last row of the cursor page is the Row storage that gets + // recycled as the new blank row below the cursor after its content + // is moved down to the next page. + { + const rac = s.pages.getCell(.{ .active = .{ .y = 2 } }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + try s.cursorScrollAbove(); + + // One row scrolled into history, so the blank row is at active + // y=1 (just below the cursor's original row). + { + const rac = s.pages.getCell(.{ .active = .{ .y = 1 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } + { + const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} }); + defer alloc.free(contents); + try testing.expectEqualStrings("2B\n\n3C\n4D\n5E", contents); + } +} + +test "Screen: cursorDownScroll no scrollback recycled row has default metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 5, .rows = 3, .max_scrollback_bytes = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); + + { + const rac = s.pages.getCell(.{ .active = .{} }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + s.cursorAbsolute(0, 2); + try s.cursorDownScroll(); + + { + const rac = s.pages.getCell(.{ .active = .{ .y = 2 } }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Screen: cursorDownScroll single row no scrollback resets metadata" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 5, .rows = 1, .max_scrollback_bytes = 0 }); + defer s.deinit(); + try s.testWriteString("1ABCD"); + + { + const rac = s.pages.getCell(.{ .active = .{} }).?; + rac.row.wrap = true; + rac.row.wrap_continuation = true; + rac.row.semantic_prompt = .prompt; + } + + try s.cursorDownScroll(); + + { + const rac = s.pages.getCell(.{ .active = .{} }).?; + try testing.expect(!rac.row.wrap); + try testing.expect(!rac.row.wrap_continuation); + try testing.expectEqual(.none, rac.row.semantic_prompt); + } +} + +test "Screen: selectLine does not join lines across a recycled row" { + const testing = std.testing; + const alloc = testing.allocator; + const io = testing.io; + + var s = try init(io, alloc, .{ .cols = 6, .rows = 5, .max_scrollback_bytes = 0 }); + defer s.deinit(); + + // A soft-wrapped line across rows 0 and 1: row 0 gets wrap=true. + try s.testWriteString("AAAAAAA"); + + // Scroll a region of rows 0-2 up by one: row 0 is discarded and + // its Row storage recycled as the blank row 2. + s.cursorAbsolute(0, 2); + try s.cursorScrollRegionUp(2); + + // Write unrelated single-line words on the recycled row and below. + s.cursorAbsolute(0, 2); + try s.testWriteString("world"); + s.cursorAbsolute(0, 3); + try s.testWriteString("hello"); + + // Selecting the line "world" must not extend into "hello": these + // are separate hard lines. A stale wrap flag on the recycled row + // would join them. + { + var sel = s.selectLine(.{ .pin = s.pages.pin(.{ .active = .{ + .x = 0, + .y = 2, + } }).? }).?; + defer sel.deinit(&s); + const contents = try s.selectionString(alloc, .{ + .sel = sel, + .trim = false, + }); + defer alloc.free(contents); + try testing.expectEqualStrings("world", contents); + } +} |
