diff options
| author | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:28:05 +0300 |
|---|---|---|
| committer | Ogulcan Celik <ogulcancelik@gmail.com> | 2026-07-16 23:29:28 +0300 |
| commit | 4a3302d19a2baa3a33e910e0088c24e780905b90 (patch) | |
| tree | dcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/PageList.zig | |
| parent | 550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff) | |
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/PageList.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/PageList.zig | 4215 |
1 files changed, 3495 insertions, 720 deletions
diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig index 63155962..79aa96f1 100644 --- a/vendor/libghostty-vt/src/terminal/PageList.zig +++ b/vendor/libghostty-vt/src/terminal/PageList.zig @@ -12,8 +12,10 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); +const compression = @import("compress.zig"); const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); +const terminal_mem = @import("mem.zig"); const point = @import("point.zig"); const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); @@ -44,8 +46,251 @@ pub const List = DoublyLinkedList(Node); const Node = struct { prev: ?*Node = null, next: ?*Node = null, - data: Page, + data: Data, serial: u64, + + /// How the backing memory of the embedded Page was allocated. Pool-owned + /// memory is always a full standard-size item from the memory + /// pool, regardless of the page layout size. Heap-owned memory + /// is allocated directly with the page allocator and is exactly + /// `Page.memory.len` bytes. + /// + /// This must never be inferred from the memory length: heap-owned + /// pages can be smaller than the standard size (e.g. compacted + /// pages), and returning one to the pool would corrupt it. + /// + /// This has no default on purpose so that every construction site + /// is forced to make an explicit decision. + owned: Owned, + + /// The physical representation of the page contents. + /// + /// Both states retain the same `Page.memory` virtual mapping and the same + /// `Page` metadata. A resident page can access the mapping directly since + /// it is, well, resident. + /// + /// A compressed page owns an encoded copy while the physical memory + /// behind the raw mapping is discarded. HOWEVER, we retain the + /// virtual allocation so that restoration is infallible. + const Data = union(enum) { + resident: Page, + compressed: compression.Page, + }; + + const Owned = enum { pool, heap }; + + /// The backing-memory representation currently stored by this node. + pub const Storage = enum { resident, compressed }; + + /// Return the terminal page stored in this node. + /// + /// WARNING: This will DECOMPRESS compressed pages! Only use this if + /// you need access to the underlying memory. If you only need access to + /// metadata (row, col counts etc) then use the other metadata functions. + pub inline fn page(self: *Node) *Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => self.restore(.preserve), + }; + } + + /// Return the terminal page only when its raw memory is resident. + /// + /// Unlike `page`, this never restores a compressed node. This is useful + /// for diagnostics which can display node metadata without touching the + /// discarded mapping, but may inspect page contents when they are already + /// available. + pub inline fn pageIfResident(self: *Node) ?*Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => null, + }; + } + + /// Read-only page with full memory access that doesn't change + /// this node's storage state: if a node is compressed it remains + /// compressed. + /// + /// Resident pages are borrowed directly. So they're basically free. + /// + /// Compressed pages are decoded into an independently owned Page + /// by the caller so callers can inspect their contents. Because resident + /// pages are still borrowed, you have to continue to have exclusive + /// access to the PageList during this operation. + pub const PreservedPage = union(enum) { + borrowed: *const Page, + owned: struct { + page: Page, + alloc: Allocator, + }, + + /// Return the read-only page represented by this value. + pub fn page(self: *const PreservedPage) *const Page { + return switch (self.*) { + .borrowed => |page_| page_, + .owned => |*owned| &owned.page, + }; + } + + /// Release storage owned by this preserved page. + pub fn deinit(self: *PreservedPage) void { + switch (self.*) { + .borrowed => {}, + // The clone buffer came from the caller's allocator rather + // than Page's OS allocator, so it must not use Page.deinit. + .owned => |*owned| owned.alloc.free(owned.page.memory), + } + self.* = undefined; + } + }; + + /// Return read-only page contents without changing this node's storage. + /// + /// `alloc` is unused for a resident node. A compressed node uses it for an + /// exact-sized, page-aligned decode buffer owned by the returned value. + /// + /// The caller must call `PreservedPage.deinit` when finished. See + /// `PreservedPage` for the synchronization required by its borrowed + /// resident representation. + pub fn pagePreservingState( + self: *const Node, + alloc: Allocator, + ) Allocator.Error!PreservedPage { + return switch (self.data) { + .resident => |*page_| .{ .borrowed = page_ }, + .compressed => |*compressed| owned: { + const memory = try alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + compressed.page.memory.len, + ); + + const page_ = compressed.cloneBuf(memory) catch |err| { + // The encoded data was produced by our codec and remains + // immutable while compressed. Failure is internal + // corruption, matching the normal restoration boundary. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + alloc.free(memory); + log.err("failed to clone compressed page err={}", .{err}); + @panic("failed to clone compressed terminal page"); + }; + + break :owned .{ .owned = .{ + .page = page_, + .alloc = alloc, + } }; + }, + }; + } + + /// Return the node's backing-memory representation without restoring it. + pub inline fn storage(self: *const Node) Storage { + return switch (self.data) { + .resident => .resident, + .compressed => .compressed, + }; + } + + /// Return a page which the caller knows is already resident. + /// + /// This avoids the representation check in hot paths which already hold + /// live pointers into the page mapping. Such pointers are only valid while + /// the page is resident. Prefer `page` unless the caller can establish + /// that invariant independently. + pub inline fn pageAssumeResident(self: *Node) *Page { + return &self.data.resident; + } + + /// Return the number of populated rows without accessing page memory. + pub inline fn rows(self: *const Node) size.CellCountInt { + return self.metadata().size.rows; + } + + /// Return the current column count without accessing page memory. + pub inline fn cols(self: *const Node) size.CellCountInt { + return self.metadata().size.cols; + } + + /// Return the page capacity without accessing page memory. + pub inline fn capacity(self: *const Node) Capacity { + return self.metadata().capacity; + } + + /// Return the embedded Page metadata without restoring its memory. + inline fn metadata(self: *const Node) *const Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => |*page_| &page_.page, + }; + } + + const RestoreMode = enum { + /// Decode the compressed representation back into the raw mapping. + preserve, + + /// Discard the compressed representation without decoding it. The + /// caller must not read the page contents before overwriting them. + discard, + }; + + /// Restore this node to the resident representation. + /// + /// Preserve mode reconstructs the page contents and is used by `page`. + /// Discard mode skips decoding for callers which will overwrite or destroy + /// the page. Both modes recommit the retained mapping, free the encoded + /// allocation, and leave the node in a valid resident state. + noinline fn restore(self: *Node, comptime mode: RestoreMode) *Page { + const compressed = switch (self.data) { + .resident => |*page_| return page_, + .compressed => |*page_| page_, + }; + + // Decommit only discarded the physical pages. Recommit prepares the + // still-reserved mapping for decoding or reuse by the caller. + terminal_mem.recommit(compressed.page.memory); + + const restored = switch (mode) { + .preserve => compressed.restore() catch |err| { + // Compressed nodes retain the immutable encoding and exact + // output mapping used to create them. Any decode error is an + // internal codec bug or memory corruption. Keep this switch + // exhaustive so new decoder errors require classification. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + log.err("failed to restore compressed page err={}", .{err}); + @panic("failed to restore compressed terminal page"); + }, + + .discard => compressed.page, + }; + + // Remove our compressed data + compressed.deinit(); + + // We're now a resident, non-compressed node. + self.data = .{ .resident = restored }; + return &self.data.resident; + } + + inline fn isCompressed(self: *const Node) bool { + return switch (self.data) { + .resident => false, + .compressed => true, + }; + } }; /// The memory pool we get page nodes from. @@ -120,29 +365,42 @@ pool: MemoryPool, /// The list of pages in the screen. pages: List, -/// A monotonically increasing serial number that is incremented each -/// time a page is allocated or reused as new. The serial is assigned to -/// the Node. +/// The next globally unique reference generation for this PageList. A +/// generation is assigned whenever a page is allocated, reused as new, or +/// changed in place such that existing page coordinates are no longer stable. /// /// The serial number can be used to detect whether the page is identical /// to the page that was originally referenced by a pointer. Since we reuse /// and pool memory, pointer stability is not guaranteed, but the serial -/// will always be different for different allocations. +/// will always be different for different page generations. /// /// Developer note: we never do overflow checking on this. If we created /// a new page every second it'd take 584 billion years to overflow. We're /// going to risk it. page_serial: u64, -/// The lowest still valid serial number that could exist. This allows -/// for quick comparisons to find invalid pages in references. -page_serial_min: u64, - -/// Byte size of the total amount of allocated pages. Note this does -/// not include the total allocated amount in the pool which may be more -/// than this due to preheating. +/// The first serial in the current whole-list validity epoch. Only `reset` +/// advances this value, immediately before rebuilding every page. It is not +/// the generation of the first page or the exact minimum live generation. +/// Page generations are not monotonic in list order: replacement and split +/// operations can put a fresh generation before older live pages. +/// +/// A generation below this epoch is definitely invalid, allowing O(1) +/// rejection in `nodeIsValid` and bulk removal of pre-reset search results. +/// A generation at or above it is only potentially valid and must still be +/// checked against the live list before its coordinates are used. +page_serial_epoch: u64, + +/// Byte size of the raw backing mappings owned by active page nodes. This is +/// logical scrollback accounting and does not change while a mapping is +/// decommitted. It excludes encoded storage and unused preheated pool items. page_size: usize, +/// Continuation state for incremental page compression. This allows +/// compress(.incremental) to work. More details on all that there and +/// in the state struct. +page_compression: IncrementalCompressionState = .{}, + /// Maximum size of the page allocation in bytes. This only includes pages /// that are used ONLY for scrollback. If the active area is still partially /// in a page that also includes scrollback, then that page is not included. @@ -403,7 +661,7 @@ pub fn init( .pool = pool, .pages = page_list, .page_serial = page_serial, - .page_serial_min = 0, + .page_serial_epoch = 0, .page_size = page_size, .explicit_max_size = max_size orelse std.math.maxInt(usize), .min_max_size = min_max_size, @@ -444,13 +702,14 @@ fn initPages( // redundant here for safety. assert(layout.total_size <= size.max_page_size); - // If we have an error, we need to clean up our non-standard pages + // If we have an error, we need to clean up our heap-owned pages // since they're not in the pool. errdefer { var it = page_list.first; while (it) |node| : (it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(node.page().memory), } } } @@ -463,7 +722,9 @@ fn initPages( const page_buf = if (pooled) buf: { try tw.check(.page_buf_std); - break :buf try pool.pages.create(); + const buf = try pool.pages.create(); + terminal_mem.recommit(buf); + break :buf buf; } else buf: { try tw.check(.page_buf_non_std); break :buf try page_alloc.alignedAlloc( @@ -488,11 +749,12 @@ fn initPages( // Initialize the first set of pages to contain our viewport so that // the top of the first page is always the active area. node.* = .{ - .data = .initBuf(.init(page_buf), layout), + .data = .{ .resident = .initBuf(.init(page_buf), layout) }, .serial = serial.*, + .owned = if (pooled) .pool else .heap, }; - node.data.size.rows = @min(rem, node.data.capacity.rows); - rem -= node.data.size.rows; + node.page().size.rows = @min(rem, node.capacity().rows); + rem -= node.rows(); // Add the page to the list page_list.append(node); @@ -553,15 +815,14 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { { var node_ = self.pages.first; while (node_) |node| { - actual_total += node.data.size.rows; + actual_total += node.rows(); node_ = node.next; - // While doing this traversal, verify no node has a serial - // number lower than our min. - if (node.serial < self.page_serial_min) { + // Every live node must belong to the current validity epoch. + if (node.serial < self.page_serial_epoch) { log.warn( - "PageList integrity violation: page serial too low serial={} min={}", - .{ node.serial, self.page_serial_min }, + "PageList integrity violation: page serial predates epoch serial={} epoch={}", + .{ node.serial, self.page_serial_epoch }, ); return IntegrityError.PageSerialInvalid; } @@ -588,7 +849,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { var offset: usize = 0; var node = self.pages.last; while (node) |n| : (node = n.prev) { - offset += n.data.size.rows; + offset += n.rows(); if (n == self.viewport_pin.node) { offset -= self.viewport_pin.y; break :offset self.total_rows - offset; @@ -624,8 +885,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { } } -/// Deinit the pagelist. If you own the memory pool (used clonePool) then -/// this will reset the pool and retain capacity. +/// Deinit the pagelist, freeing all page memory and the memory pool. pub fn deinit(self: *PageList) void { // Verify integrity before cleanup self.assertIntegrity(); @@ -634,12 +894,14 @@ pub fn deinit(self: *PageList) void { self.tracked_pins.deinit(self.pool.alloc); // Go through our linked list and deallocate all pages that are - // not standard size. + // heap-owned (not in the pool). const page_alloc = self.pool.pages.arena.child_allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + const page = node.restore(.discard); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(page.memory), } } @@ -658,10 +920,13 @@ pub fn deinit(self: *PageList) void { pub fn reset(self: *PageList) void { defer self.assertIntegrity(); - // Invalidate all external page refs to the previous list. The reset below - // rebuilds the page list from the pools, so old untracked refs must be - // rejected before any validation attempts to inspect their node pointers. - self.page_serial_min = self.page_serial; + // Reset discards all scrollback, so there is nothing left to compress. + self.page_compression.reset(); + + // Begin a new whole-list validity epoch before rebuilding from the pools. + // Every old reference now has a serial below the epoch and can be rejected + // in O(1), even if the node pool later reuses its pointer address. + self.page_serial_epoch = self.page_serial; // We need enough pages/nodes to keep our active area. This should // never fail since we by definition have allocated a page already @@ -678,14 +943,15 @@ pub fn reset(self: *PageList) void { ) catch unreachable; // Before resetting our pools we need to free any pages that - // are non-standard size since those were allocated outside - // the pool. + // are heap-owned since those were allocated outside the pool. { const page_alloc = self.pool.pages.arena.child_allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + const page = node.restore(.discard); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(page.memory), } } } @@ -825,8 +1091,9 @@ pub fn clone( const page_alloc = pool.pages.arena.child_allocator; var page_it = page_list.first; while (page_it) |node| : (page_it = node.next) { - if (node.data.memory.len > std_size) { - page_alloc.free(node.data.memory); + switch (node.owned) { + .pool => {}, + .heap => page_alloc.free(node.page().memory), } } } @@ -840,25 +1107,27 @@ pub fn clone( // we don't know if the source page has a standard size. const node = try createPageExt( &pool, - chunk.node.data.capacity, + .{ .cap = chunk.node.capacity() }, &page_serial, &page_size, ); - assert(node.data.capacity.rows >= chunk.end - chunk.start); - defer node.data.assertIntegrity(); - node.data.size.rows = chunk.end - chunk.start; - node.data.size.cols = chunk.node.data.size.cols; - try node.data.cloneFrom( - &chunk.node.data, + const dst_page = node.page(); + const src_page = chunk.node.page(); + assert(node.capacity().rows >= chunk.end - chunk.start); + defer dst_page.assertIntegrity(); + dst_page.size.rows = chunk.end - chunk.start; + dst_page.size.cols = chunk.node.cols(); + try dst_page.cloneFrom( + src_page, chunk.start, chunk.end, ); - node.data.dirty = chunk.node.data.dirty; + dst_page.dirty = src_page.dirty; page_list.append(node); - total_rows += node.data.size.rows; + total_rows += node.rows(); // Remap our tracked pins by changing the page and // offsetting the Y position based on the chunk start. @@ -887,7 +1156,7 @@ pub fn clone( .pool = pool, .pages = page_list, .page_serial = page_serial, - .page_serial_min = 0, + .page_serial_epoch = 0, .page_size = page_size, .explicit_max_size = self.explicit_max_size, .min_max_size = self.min_max_size, @@ -911,8 +1180,9 @@ pub fn clone( // now we rarely clone less than the active area and if we do // the area is by definition very small. const last = result.pages.last.?; - const row = &last.data.rows.ptr(last.data.memory)[last.data.size.rows - 1]; - last.data.clearCells(row, 0, result.cols); + const page = last.page(); + const row = &page.rows.ptr(page.memory)[last.rows() - 1]; + page.clearCells(row, 0, result.cols); } // Update our total rows to be our row size. @@ -952,6 +1222,12 @@ pub const Resize = struct { pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void { defer self.assertIntegrity(); + // Resizing forces all nodes to be decompressed today so we need to + // reschedule compression. + // TODO(mitchellh): Deferred reflow on non-viewport/non-active pages. + self.page_compression.reset(); + self.page_compression.markActivity(); + if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected // upstream @@ -1047,6 +1323,16 @@ fn resizeCols( const wrapped = wrapped: { var wrapped: usize = 0; + // If shrinking rows (in the .lt branch of resize, rows shrink + // before we get here) pushed the cursor pin above the new active + // area, there are no rows to count and iterating .left_up toward + // the active-area top would be an invalid (reversed) range. The + // preserved-cursor growth below already no-ops for a cursor that + // isn't in the active area, so we just count zero here. + if (active_pin) |ap| { + if (p.before(ap)) break :wrapped 0; + } + var row_it = p.rowIterator(.left_up, active_pin); while (row_it.next()) |next| { const row = next.rowAndCell().row; @@ -1075,7 +1361,7 @@ fn resizeCols( // Create the first node that contains our reflow. const first_rewritten_node = node: { - const page = &self.pages.first.?.data; + const page = self.pages.first.?.page(); const cap = page.capacity.adjust( .{ .cols = cols }, ) catch |err| err: { @@ -1092,8 +1378,8 @@ fn resizeCols( break :err cap; }; - const node = try self.createPage(cap); - node.data.size.rows = 1; + const node = try self.createPage(.{ .cap = cap }); + node.page().size.rows = 1; break :node node; }; @@ -1134,7 +1420,7 @@ fn resizeCols( // Once we're done reflowing a page, destroy it immediately. // This frees memory and makes it more likely in memory // constrained environments that the next reflow will work. - if (row.y == row.node.data.size.rows - 1) { + if (row.y == row.node.rows() - 1) { self.destroyNode(row.node); } } @@ -1150,7 +1436,7 @@ fn resizeCols( var node_it = self.pages.first; var total: usize = 0; while (node_it) |node| : (node_it = node.next) { - total += node.data.size.rows; + total += node.rows(); if (total >= self.rows) break; } else { for (total..self.rows) |_| _ = try self.grow(); @@ -1223,7 +1509,7 @@ const ReflowCursor = struct { total_rows: usize, fn init(node: *List.Node) ReflowCursor { - const page = &node.data; + const page = node.page(); const rows = page.rows.ptr(page.memory); return .{ .x = 0, @@ -1236,7 +1522,7 @@ const ReflowCursor = struct { .new_rows = 0, // Initially whatever size our input node is. - .total_rows = node.data.size.rows, + .total_rows = node.rows(), }; } @@ -1247,7 +1533,7 @@ const ReflowCursor = struct { row: Pin, cursor_pin: ?*Pin, ) Allocator.Error!void { - const src_page: *Page = &row.node.data; + const src_page: *Page = row.node.page(); const src_row = row.rowAndCell().row; const src_y = row.y; const cells = src_row.cells.ptr(src_page.memory)[0..src_page.size.cols]; @@ -1270,7 +1556,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y) continue; if (cursor_pin != null and p == cursor_pin.?) continue; @@ -1293,7 +1579,7 @@ const ReflowCursor = struct { // If the cursor is after blanks on the right, those cells are still // before the next write and must reflow with it. if (cursor_pin) |p| { - if (&p.node.data == src_page and p.y == src_y) { + if (p.node.page() == src_page and p.y == src_y) { cols_len = @max(cols_len, p.x + 1); } } @@ -1344,7 +1630,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y or p.x != x) continue; @@ -1369,7 +1655,7 @@ const ReflowCursor = struct { // since we won't process that cell in the loop. const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y or p.x != x + 1) continue; @@ -1853,7 +2139,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != old_page or + if (p.node.page() != old_page or p.y != old_page.size.rows - 1) continue; p.node = self.node; @@ -1954,9 +2240,9 @@ const ReflowCursor = struct { // after reinitializing our cursor on the new page. const new_rows = self.new_rows; - const node = try list.createPage(cap); + const node = try list.createPage(.{ .cap = cap }); errdefer comptime unreachable; - node.data.size.rows = 1; + node.page().size.rows = 1; list.pages.insertAfter(self.node, node); self.* = .init(node); @@ -2056,7 +2342,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { .lt => { var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); defer page.assertIntegrity(); const rows = page.rows.ptr(page.memory); for (0..page.size.rows) |i| { @@ -2153,12 +2439,17 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { // just update our rows and we're done. This effectively // "pulls down" scrollback. // + // This traversal intentionally reads only node metadata. A + // compressed history page pulled into the active area remains + // compressed until a renderer or other content consumer goes + // through `Node.page`, which restores it transparently. + // // If we don't have enough scrollback, we add the difference, // to the active area. var count: usize = 0; var page = self.pages.first; while (page) |p| : (page = p.next) { - count += p.data.size.rows; + count += p.rows(); if (count >= rows) break; } else { assert(count < rows); @@ -2189,7 +2480,7 @@ fn resizeWithoutReflowGrowCols( chunk: PageIterator.Chunk, ) Allocator.Error!void { assert(cols > self.cols); - const page = &chunk.node.data; + const page = chunk.node.page(); // Update our col count const old_cols = self.cols; @@ -2263,7 +2554,7 @@ fn resizeWithoutReflowGrowCols( // This can fail for many reasons (can't fit styles/graphemes, etc.) so // if it fails then we give up and drop back into creating new pages. if (prev) |prev_node| prev: { - const prev_page = &prev_node.data; + const prev_page = prev_node.page(); // We only want scenarios where we have excess capacity. if (prev_page.size.rows >= prev_page.capacity.rows) break :prev; @@ -2308,7 +2599,7 @@ fn resizeWithoutReflowGrowCols( // If we have an error, we clear the rows we just added to our prev page. const prev_copied = copied; errdefer if (prev_copied > 0) { - const prev_page = &prev.?.data; + const prev_page = prev.?.page(); const prev_size = prev_page.size.rows - prev_copied; const prev_rows = prev_page.rows.ptr(prev_page.memory)[prev_size..prev_page.size.rows]; for (prev_rows) |*row| prev_page.clearCells( @@ -2333,8 +2624,9 @@ fn resizeWithoutReflowGrowCols( // We need to loop because our col growth may force us // to split pages. while (copied < page.size.rows) { - const new_node = try self.createPage(cap); - defer new_node.data.assertIntegrity(); + const new_node = try self.createPage(.{ .cap = cap }); + const new_page = new_node.page(); + defer new_page.assertIntegrity(); // The length we can copy into the new page is at most the number // of rows in our cap. But if we can finish our source page we use that. @@ -2343,10 +2635,10 @@ fn resizeWithoutReflowGrowCols( // Perform the copy const y_start = copied; const src_rows = page.rows.ptr(page.memory)[y_start .. copied + len]; - const dst_rows = new_node.data.rows.ptr(new_node.data.memory)[0..len]; + const dst_rows = new_page.rows.ptr(new_page.memory)[0..len]; for (dst_rows, src_rows) |*dst_row, *src_row| { - new_node.data.size.rows += 1; - if (new_node.data.cloneRowFrom( + new_page.size.rows += 1; + if (new_page.cloneRowFrom( page, dst_row, src_row, @@ -2364,7 +2656,7 @@ fn resizeWithoutReflowGrowCols( // We can actually safely handle this though by exiting // this loop early and cutting our copy short. - new_node.data.size.rows -= 1; + new_page.size.rows -= 1; break; } } @@ -2405,12 +2697,13 @@ fn trailingBlankLines( // Go through our pages backwards since we're counting trailing blanks. var it = self.pages.last; - while (it) |page| : (it = page.prev) { - const len = page.data.size.rows; - const rows = page.data.rows.ptr(page.data.memory)[0..len]; + while (it) |node| : (it = node.prev) { + const page = node.page(); + const len = node.rows(); + const rows = page.rows.ptr(page.memory)[0..len]; for (0..len) |i| { const rev_i = len - i - 1; - const cells = rows[rev_i].cells.ptr(page.data.memory)[0..page.data.size.cols]; + const cells = rows[rev_i].cells.ptr(page.memory)[0..node.cols()]; // If the row has any text then we're done. if (pagepkg.Cell.hasTextAny(cells)) return count; @@ -2436,6 +2729,7 @@ fn trimTrailingBlankRows( max: size.CellCountInt, ) size.CellCountInt { var trimmed: size.CellCountInt = 0; + var invalidated_node: ?*List.Node = null; const bl_pin = self.getBottomRight(.screen).?; var it = bl_pin.rowIterator(.left_up, null); while (it.next()) |row_pin| { @@ -2457,11 +2751,16 @@ fn trimTrailingBlankRows( // No text, we can trim this row. Because it has // no text we can also be sure it has no styling // so we don't need to worry about memory. - row_pin.node.data.size.rows -= 1; - if (row_pin.node.data.size.rows == 0) { + if (row_pin.node.rows() > 1 and invalidated_node != row_pin.node) { + // Shrinking a retained page changes its valid row-coordinate range. + self.invalidateNodeLayout(row_pin.node); + invalidated_node = row_pin.node; + } + row_pin.node.page().size.rows -= 1; + if (row_pin.node.page().size.rows == 0) { self.erasePage(row_pin.node); } else { - row_pin.node.data.assertIntegrity(); + row_pin.node.page().assertIntegrity(); } trimmed += 1; @@ -2514,6 +2813,14 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { return; } + // Moving the viewport changes which historical pages are visible. Restart + // traversal so pages which leave the viewport are reconsidered after the + // renderer's idle delay. False positives from clamped scrolling are cheap. + defer { + self.page_compression.reset(); + self.page_compression.markActivity(); + } + switch (behavior) { .active => self.viewport = .active, .top => self.viewport = .top, @@ -2572,44 +2879,38 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { if (n < midpoint) { // Iterate forward from the first node. var node_it = self.pages.first; - var rem: size.CellCountInt = std.math.cast( - size.CellCountInt, - n, - ) orelse { - self.viewport = .active; - break :row; - }; + var rem: usize = n; while (node_it) |node| : (node_it = node.next) { - if (rem < node.data.size.rows) { + if (rem < node.rows()) { self.viewport_pin.* = .{ .node = node, - .y = rem, + .y = std.math.cast(size.CellCountInt, rem) orelse { + self.viewport = .active; + break :row; + }, }; break :row; } - rem -= node.data.size.rows; + rem -= node.rows(); } } else { // Iterate backwards from the last node. var node_it = self.pages.last; - var rem: size.CellCountInt = std.math.cast( - size.CellCountInt, - self.total_rows - n, - ) orelse { - self.viewport = .active; - break :row; - }; + var rem: usize = self.total_rows - n; while (node_it) |node| : (node_it = node.prev) { - if (rem <= node.data.size.rows) { + if (rem <= node.rows()) { self.viewport_pin.* = .{ .node = node, - .y = node.data.size.rows - rem, + .y = std.math.cast(size.CellCountInt, node.rows() - rem) orelse { + self.viewport = .active; + break :row; + }, }; break :row; } - rem -= node.data.size.rows; + rem -= node.rows(); } } @@ -2619,6 +2920,8 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { }, .delta_prompt => |n| self.scrollPrompt(n), .delta_row => |n| delta_row: { + const amount: usize = @abs(n); + switch (self.viewport) { // If we're at the top and we're scrolling backwards, // we don't have to do anything, because there's nowhere to go. @@ -2635,11 +2938,11 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { .pin => switch (std.math.order(n, 0)) { .eq => break :delta_row, - .lt => switch (self.viewport_pin.upOverflow(@intCast(-n))) { + .lt => switch (self.viewport_pin.upOverflow(amount)) { .offset => |new_pin| { self.viewport_pin.* = new_pin; if (self.viewport_pin_row_offset) |*v| { - v.* -= @as(usize, @intCast(-n)); + v.* -= amount; } break :delta_row; }, @@ -2651,7 +2954,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { }, }, - .gt => switch (self.viewport_pin.downOverflow(@intCast(n))) { + .gt => switch (self.viewport_pin.downOverflow(amount)) { // If we offset its a valid pin but we still have to // check if we're in the active area. .offset => |new_pin| { @@ -2660,7 +2963,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { } else { self.viewport_pin.* = new_pin; if (self.viewport_pin_row_offset) |*v| { - v.* += @intCast(n); + v.* += amount; } } break :delta_row; @@ -2678,10 +2981,10 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { // Slow path: we have to calculate the new pin by moving // from our viewport. const top = self.getTopLeft(.viewport); - const p: Pin = if (n < 0) switch (top.upOverflow(@intCast(-n))) { + const p: Pin = if (n < 0) switch (top.upOverflow(amount)) { .offset => |v| v, .overflow => |v| v.end, - } else switch (top.downOverflow(@intCast(n))) { + } else switch (top.downOverflow(amount)) { .offset => |v| v, .overflow => |v| v.end, }; @@ -2719,7 +3022,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { fn scrollPrompt(self: *PageList, delta: isize) void { // If we aren't jumping any prompts then we don't need to do anything. if (delta == 0) return; - const delta_start: usize = @intCast(if (delta > 0) delta else -delta); + const delta_start: usize = @abs(delta); var delta_rem: usize = delta_start; // We start at the row before or after our viewport depending on the @@ -2781,11 +3084,12 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void { var page = self.pages.last.?; var n: usize = 0; while (true) { - const rows: [*]Row = page.data.rows.ptr(page.data.memory); - for (0..page.data.size.rows) |i| { - const rev_i = page.data.size.rows - i - 1; + const current_page = page.page(); + const rows: [*]Row = current_page.rows.ptr(current_page.memory); + for (0..page.rows()) |i| { + const rev_i = page.rows() - i - 1; const row = rows[rev_i]; - const cells = row.cells.ptr(page.data.memory)[0..self.cols]; + const cells = row.cells.ptr(current_page.memory)[0..self.cols]; for (cells) |cell| { if (!cell.isEmpty()) break :non_empty self.rows - n; } @@ -2802,40 +3106,90 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void { for (0..non_empty) |_| _ = try self.grow(); } +/// Give a live node a new generation before changing its coordinate layout in +/// place. +/// +/// This must be called when a node remains at the same address and stays in the +/// list, but a mutation changes which logical row a `(node, y)` coordinate +/// identifies or whether that coordinate is still in range. Examples include +/// rotating rows after an erase, truncating a page's row range, reinitializing +/// the sole remaining page, or shortening the source page of a split. Without +/// a new generation, a cached pointer, serial, and coordinate could still pass +/// `nodeIsValid` while referring to a different row than it originally did. +/// Screen and Terminal fast paths which manipulate Page rows directly must use +/// this because they bypass PageList's own row-mutation helpers. +/// +/// The caller must pass a node which is currently live in this PageList. Call +/// this at the point the operation commits to changing the layout and before +/// the first such change. When an operation is intended to be atomic, finish +/// its failable preparation first so a failed operation does not needlessly +/// invalidate references. One call per affected node is sufficient when an +/// operation performs several layout changes without exposing intermediate +/// references. +/// +/// This helper is only needed for in-place changes. Removing or replacing a +/// node already invalidates old references through the live-list check in +/// `nodeIsValid`, and newly allocated or reused nodes receive a fresh serial as +/// part of their initialization. Ordinary cell or style changes which preserve +/// the meaning of page coordinates do not require a new generation solely for +/// that reason. +/// +/// The only state changed here is `node.serial` and the next-generation counter +/// `page_serial`. Consequently, every previously captured pointer-plus-serial +/// pair for this node becomes invalid while new references can capture the new +/// generation. This does not advance `page_serial_epoch`: only `reset` starts a +/// new whole-list validity epoch. +/// +/// This also does not mutate the page, update tracked pins or viewport state, +/// adjust row or memory accounting, mark cells dirty, or notify incremental +/// compression. The surrounding operation remains responsible for all such +/// bookkeeping required by its layout change. +pub fn invalidateNodeLayout(self: *PageList, node: *List.Node) void { + node.serial = self.page_serial; + self.page_serial += 1; +} + /// Compact a page to use the minimum required memory for the contents /// it stores. Returns the new node pointer if compaction occurred, or null -/// if the page was already compact or compaction would not provide meaningful +/// if the page was already compact or compaction would not provide any /// savings. /// -/// The current design of PageList at the time of writing this doesn't -/// allow for smaller than `std_size` nodes so if the current node's backing -/// page is standard size or smaller, no compaction will occur. In the -/// future we should fix this up. +/// The compacted page is always an exact-size heap allocation, never +/// a pool item, since a pool item always retains a full std_size +/// buffer regardless of the page layout. Note that this means that +/// when compacting a pool-owned node, the freed pool item is returned +/// to the pool free list, so the memory savings are only fully +/// realized once the pool itself is reset or freed. /// /// If this returns OOM, the PageList is left unchanged and no dangling /// memory references exist. It is safe to ignore the error and continue using /// the uncompacted page. pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { defer self.assertIntegrity(); - const page: *Page = &node.data; + const page: *Page = node.page(); // We should never have empty rows in our pagelist anyways... assert(page.size.rows > 0); - // We never compact standard size or smaller pages because changing - // the capacity to something smaller won't save memory. - if (page.memory.len <= std_size) return null; - // Compute the minimum capacity required for this page's content const req_cap = page.exactRowCapacity(0, page.size.rows); const new_size = Page.layout(req_cap).total_size; - const old_size = page.memory.len; + + // The memory this node currently retains. A pool-owned node always + // retains a full pool item no matter its layout size. + const old_size: usize = switch (node.owned) { + .pool => PagePool.item_size, + .heap => page.memory.len, + }; if (new_size >= old_size) return null; // Create the new smaller page - const new_node = try self.createPage(req_cap); + const new_node = try self.createPage(.{ + .cap = req_cap, + .exact_size = true, + }); errdefer self.destroyNode(new_node); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); new_page.size = page.size; new_page.dirty = page.dirty; new_page.cloneFrom( @@ -2866,6 +3220,7 @@ pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { self.pages.insertBefore(node, new_node); self.pages.remove(node); self.destroyNode(node); + self.page_compression.markActivity(); new_page.assertIntegrity(); return new_node; @@ -2896,7 +3251,7 @@ pub fn split( // when we modify it later it updates our p here. Copying the node // fixes this. const original_node = p.node; - const page: *Page = &original_node.data; + const page: *Page = original_node.page(); // A page that is already 1 row can't be split. In the future we can // theoretically maybe split by soft-wrapping multiple pages but that @@ -2912,18 +3267,18 @@ pub fn split( defer self.assertIntegrity(); // Create a new node with the same capacity of managed memory. - const target = try self.createPage(page.capacity); + const target = try self.createPage(.{ .cap = page.capacity }); errdefer self.destroyNode(target); // Determine how many rows we're copying const y_start = p.y; const y_end = page.size.rows; - target.data.size.rows = y_end - y_start; - assert(target.data.size.rows <= target.data.capacity.rows); + target.page().size.rows = y_end - y_start; + assert(target.rows() <= target.capacity().rows); // Copy our old data. This should NOT fail because we have the // capacity of the old page which already fits the data we requested. - target.data.cloneFrom(page, y_start, y_end) catch |err| { + target.page().cloneFrom(page, y_start, y_end) catch |err| { log.err( "error cloning rows for split err={}", .{err}, @@ -2939,9 +3294,13 @@ pub fn split( // error handling. It is possible but we haven't written it. errdefer comptime unreachable; + // Failable split work is complete; shortening the source changes its row range. + self.invalidateNodeLayout(original_node); + self.page_compression.markActivity(); + // Move any tracked pins from the copied rows for (self.tracked_pins.keys()) |tracked| { - if (&tracked.node.data != page or + if (tracked.node.page() != page or tracked.y < p.y) continue; tracked.node = target; @@ -3008,9 +3367,11 @@ pub const Scrollbar = struct { /// Return the scrollbar state for this PageList. /// -/// This may be expensive to calculate depending on where the viewport -/// is (arbitrary pins are expensive). The caller should take care to only -/// call this as needed and not too frequently. +/// This is amortized O(1): the total is maintained incrementally and +/// the viewport offset is cached. The first call after the viewport +/// moves to an arbitrary pin (e.g. scrolling to a selection) may cost +/// O(pages) to compute the offset, after which it is cached again. +/// See viewportRowOffset for more details. pub fn scrollbar(self: *PageList) Scrollbar { // If we have no scrollback, special case no scrollbar. // We need to do this because the way PageList works is that @@ -3057,9 +3418,9 @@ fn viewportRowOffset(self: *PageList) usize { var offset: usize = 0; var node = self.pages.last; while (node) |n| : (node = n.prev) { - offset += n.data.size.rows; + offset += n.rows(); if (n == self.viewport_pin.node) { - assert(n.data.size.rows > self.viewport_pin.y); + assert(n.rows() > self.viewport_pin.y); offset -= self.viewport_pin.y; break :offset self.total_rows - offset; } @@ -3134,11 +3495,15 @@ pub fn maxSize(self: *const PageList) usize { pub fn grow(self: *PageList) Allocator.Error!?*List.Node { defer self.assertIntegrity(); + // Growing can move a complete page behind the active boundary. + self.page_compression.markActivity(); + const last = self.pages.last.?; - if (last.data.capacity.rows > last.data.size.rows) { + if (last.capacity().rows > last.rows()) { // Fast path: we have capacity in the last page. - last.data.size.rows += 1; - last.data.assertIntegrity(); + const page = last.page(); + page.size.rows += 1; + page.assertIntegrity(); // Increase our total rows by one self.total_rows += 1; @@ -3168,7 +3533,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { assert(first != last); // Decrease our total row count from the pruned page - self.total_rows -= first.data.size.rows; + self.total_rows -= first.rows(); // If our total row count is now less than our required // rows then we can't prune. The "+ 1" is because we'll add one @@ -3176,7 +3541,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { if (self.total_rows + 1 < self.rows) { self.pages.prepend(first); assert(self.pages.first == first); - self.total_rows += first.data.size.rows; + self.total_rows += first.rows(); break :prune; } @@ -3185,14 +3550,14 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { if (self.viewport_pin_row_offset) |*v| { // If our offset is less than the number of rows in the // pruned page, then we are now at the top. - if (v.* < first.data.size.rows) { + if (v.* < first.rows()) { self.viewport = .top; break :viewport; } // Otherwise, our viewport pin is below what we pruned // so we just decrement our offset. - v.* -= first.data.size.rows; + v.* -= first.rows(); } } @@ -3208,48 +3573,56 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { } self.viewport_pin.garbage = false; - // Non-standard pages can't be reused, just destroy them. - if (first.data.memory.len > std_size) { - self.destroyNode(first); - break :prune; + switch (first.owned) { + // Pool-owned pages are reused below. + .pool => {}, + + // Heap-owned pages can't be reused because they may be + // any size (larger or smaller than a standard page), so + // just destroy them. + .heap => { + self.destroyNode(first); + break :prune; + }, } // Reset our memory - const buf = first.data.memory; + const buf = first.restore(.discard).memory; @memset(buf, 0); assert(buf.len <= std_size); // Initialize our new page and reinsert it as the last - first.data = .initBuf(.init(buf), Page.layout(cap)); - first.data.size.rows = 1; + first.data = .{ .resident = .initBuf(.init(buf), Page.layout(cap)) }; + const page = first.page(); + page.size.rows = 1; self.pages.insertAfter(last, first); self.total_rows += 1; - // We also need to reset the serial number. Since this is the only - // place we ever reuse a serial number, we also can safely set - // page_serial_min to be one more than the old serial because we - // only ever prune the oldest pages. - self.page_serial_min = first.serial + 1; + // Reusing the node gives it a fresh generation. Do not begin a new + // page_serial_epoch here: generations are not monotonic in list order, + // so older live successors may have lower generations. The epoch only + // advances when reset invalidates the entire list. first.serial = self.page_serial; self.page_serial += 1; // In this case we do NOT need to update page_size because // we're reusing an existing page so nothing has changed. - first.data.assertIntegrity(); + page.assertIntegrity(); return first; } // We need to allocate a new memory buffer. - const next_node = try self.createPage(cap); + const next_node = try self.createPage(.{ .cap = cap }); // we don't errdefer this because we've added it to the linked // list and its fine to have dangling unused pages. self.pages.append(next_node); - next_node.data.size.rows = 1; + const page = next_node.page(); + page.size.rows = 1; // We should never be more than our max size here because we've // verified the case above. - next_node.data.assertIntegrity(); + page.assertIntegrity(); // Record the increased row count self.total_rows += 1; @@ -3296,7 +3669,7 @@ pub fn increaseCapacity( adjustment: ?IncreaseCapacity, ) IncreaseCapacityError!*List.Node { defer self.assertIntegrity(); - const page: *Page = &node.data; + const page: *Page = node.page(); // Apply our adjustment var cap = page.capacity; @@ -3306,21 +3679,36 @@ pub fn increaseCapacity( const Int = @FieldType(Capacity, field_name); const old = @field(cap, field_name); - // We use checked math to prevent overflow. If there is an - // overflow it means we're out of space in this dimension, - // since pages can take up to their maxInt capacity in any - // category. - const new = std.math.mul( - Int, - old, - 2, - ) catch |err| overflow: { - comptime assert(@TypeOf(err) == error{Overflow}); - // Our final doubling would overflow since maxInt is - // 2^N - 1 for an unsignged int of N bits. So, if we overflow - // and we haven't used all the bits, use all the bits. - if (old < std.math.maxInt(Int)) break :overflow std.math.maxInt(Int); - return error.OutOfSpace; + const new: Int = new: { + // A dimension can be zero for pages with exact + // capacities (see compact). Doubling zero stays zero, + // which would break our guarantee that we always + // increase by at least one unit and turn caller retry + // loops into infinite loops. Jump straight to the + // standard default instead: it is what all standard + // pages start with, so retrying callers are guaranteed + // enough room for their pending allocation. + if (old == 0) { + const default: Capacity = .{ .cols = 0, .rows = 0 }; + break :new @field(default, field_name); + } + + // We use checked math to prevent overflow. If there is + // an overflow it means we're out of space in this + // dimension, since pages can take up to their maxInt + // capacity in any category. + break :new std.math.mul( + Int, + old, + 2, + ) catch |err| overflow: { + comptime assert(@TypeOf(err) == error{Overflow}); + // Our final doubling would overflow since maxInt is + // 2^N - 1 for an unsignged int of N bits. So, if we overflow + // and we haven't used all the bits, use all the bits. + if (old < std.math.maxInt(Int)) break :overflow std.math.maxInt(Int); + return error.OutOfSpace; + }; }; @field(cap, field_name) = new; @@ -3337,9 +3725,9 @@ pub fn increaseCapacity( log.info("adjusting page capacity={}", .{cap}); // Create our new page and clone the old page into it. - const new_node = try self.createPage(cap); + const new_node = try self.createPage(.{ .cap = cap }); errdefer self.destroyNode(new_node); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); assert(new_page.capacity.rows >= page.capacity.rows); assert(new_page.capacity.cols >= page.capacity.cols); new_page.size.rows = page.size.rows; @@ -3375,21 +3763,35 @@ pub fn increaseCapacity( self.pages.insertBefore(node, new_node); self.pages.remove(node); self.destroyNode(node); + self.page_compression.markActivity(); new_page.assertIntegrity(); return new_node; } +/// Options for createPage and createPageExt. +const CreatePage = struct { + /// The capacity to allocate the page with. + cap: Capacity, + + /// Force the page backing memory to be an exact-size heap + /// allocation even if it would fit within a standard-size pool + /// item. This is used when compacting pages to their minimum + /// size, since a pool item always retains a full std_size buffer + /// regardless of the page layout. + exact_size: bool = false, +}; + /// Create a new page node. This does not add it to the list and this /// does not do any memory size accounting with max_size/page_size. inline fn createPage( self: *PageList, - cap: Capacity, + opts: CreatePage, ) Allocator.Error!*List.Node { - // log.debug("create page cap={}", .{cap}); + // log.debug("create page cap={}", .{opts.cap}); return try createPageExt( &self.pool, - cap, + opts, &self.page_serial, &self.page_size, ); @@ -3397,15 +3799,15 @@ inline fn createPage( inline fn createPageExt( pool: *MemoryPool, - cap: Capacity, + opts: CreatePage, serial: *u64, total_size: ?*usize, ) Allocator.Error!*List.Node { var page = try pool.nodes.create(); errdefer pool.nodes.destroy(page); - const layout = Page.layout(cap); - const pooled = layout.total_size <= std_size; + const layout = Page.layout(opts.cap); + const pooled = !opts.exact_size and layout.total_size <= std_size; const page_alloc = pool.pages.arena.child_allocator; // It would be better to encode this into the Zig error handling @@ -3416,14 +3818,15 @@ inline fn createPageExt( // Our page buffer comes from our standard memory pool if it // is within our standard size since this is what the pool // dispenses. Otherwise, we use the heap allocator to allocate. - const page_buf = if (pooled) - try pool.pages.create() - else - try page_alloc.alignedAlloc( - u8, - .fromByteUnits(std.heap.page_size_min), - layout.total_size, - ); + const page_buf = if (pooled) buf: { + const buf = try pool.pages.create(); + terminal_mem.recommit(buf); + break :buf buf; + } else try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + layout.total_size, + ); errdefer if (pooled) pool.pages.destroy(page_buf) else @@ -3431,14 +3834,23 @@ inline fn createPageExt( // In runtime safety modes, allocators fill with 0xAA. On freestanding // (WASM), the WasmAllocator reuses freed slots without zeroing. + // + // Otherwise, we rely on pool item buffers being zeroed: fresh items + // come from the OS page allocator (zeroed pages) and destroyNodeExt + // zeroes buffers before returning them to the pool. The one + // exception is the first pointer-size bytes, which hold the pool's + // free list node while a buffer is in the free list; initBuf below + // always overwrites those since the page rows start at offset 0 + // (comptime-asserted in Page). if (comptime std.debug.runtime_safety or builtin.os.tag == .freestanding) @memset(page_buf, 0); page.* = .{ - .data = .initBuf(.init(page_buf), layout), + .data = .{ .resident = .initBuf(.init(page_buf), layout) }, .serial = serial.*, + .owned = if (pooled) .pool else .heap, }; - page.data.size.rows = 0; + page.page().size.rows = 0; serial.* += 1; if (total_size) |v| { @@ -3451,6 +3863,410 @@ inline fn createPageExt( return page; } +/// Temporary output memory used while creating a compressed page. +/// +/// Standard-sized output borrows a page-pool item so repeated compression can +/// reuse the same virtual mapping. Oversized pages use a temporary allocation +/// from the page allocator and release it immediately after compression. +const CompressionScratch = union(enum) { + pooled: *align(std.heap.page_size_min) [std_size]u8, + allocated: []align(std.heap.page_size_min) u8, + + fn init( + pool: *MemoryPool, + required: usize, + raw_len: usize, + ) Allocator.Error!CompressionScratch { + assert(required <= raw_len); + + if (required <= std_size) { + const memory = try pool.pages.create(); + terminal_mem.recommit(memory); + return .{ .pooled = memory }; + } + + const page_alloc = pool.pages.arena.child_allocator; + return .{ .allocated = try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + raw_len, + ) }; + } + + fn bytes(self: *CompressionScratch) []u8 { + return switch (self.*) { + .pooled => |memory| memory, + .allocated => |memory| memory, + }; + } + + fn deinit(self: *CompressionScratch, pool: *MemoryPool) void { + switch (self.*) { + .pooled => |memory| { + _ = terminal_mem.decommit(.zero, memory, memory.len); + pool.pages.destroy(memory); + }, + .allocated => |memory| { + const page_alloc = pool.pages.arena.child_allocator; + page_alloc.free(memory); + }, + } + } +}; + +/// PageList-owned state for incremental compression. +/// +/// The position is stored as a page serial rather than a node pointer so it +/// remains safe when PageList operations destroy, replace, or reuse nodes +/// between steps. It also records the first serial which was unallocated at +/// the prior step so new nodes before a valid marker restart safely. If the +/// exact serial no longer exists in the cold prefix, the next step also +/// restarts at the first page. +/// +/// A completed pass is followed by another pass from the oldest cold page. +/// Compression becomes idle only when that verification pass compresses no +/// pages. This converges after pages are restored between incremental steps +/// without requiring consumers to maintain a separate attempt cursor. +const IncrementalCompressionState = struct { + flags: packed struct { + /// Set after any page is compressed during the current traversal. + /// We always run incremental compression until we get a fully + /// no-compression pass (the verification pass). This lets us + /// recompress decompressed pages due to search, scrolling, etc. + did_compress: bool = false, + + /// Set after a traversal first reaches the active boundary. When this + /// verification traversal reaches the boundary, `did_compress` + /// determines whether to restart once more or finish the pass. + verifying: bool = false, + } = .{}, + + /// Changes whenever PageList activity may affect compression work. + /// Callers use this value (via Terminal.compressionActivity) to + /// determine whether to recompress. + /// + /// The directionality of this doesn't matter. We overflow and + /// wrap when maxxed. The comparison of this value to your saved + /// value is all that matters. There is a possible edge case where you + /// don't compress, a full wraparound happens, and you get the same value, + /// but its so unlikely. + /// + /// This is 48-bits so that 16-bits can be reserved for Terminal to + /// add extra state. + activity_serial: u48 = 0, + + /// Serial of the last page inspected by the traversal. This is + /// intentionally an implementation detail and is reset by PageList + /// operations which restart traversal progress. + last_serial: ?u64 = null, + + /// First node serial which had not been allocated at the prior step. A + /// node at or above this value before the saved marker requires a restart. + next_serial: u64 = 0, + + /// Record activity without disturbing valid traversal progress. + fn markActivity(self: *IncrementalCompressionState) void { + self.activity_serial +%= 1; + } + + /// Discard traversal progress without changing the activity token. + fn reset(self: *IncrementalCompressionState) void { + const activity_serial: u48 = self.activity_serial; + self.* = .{ .activity_serial = activity_serial }; + } +}; + +/// Result of one incremental compression step. +pub const IncrementalCompressionResult = enum { + /// Strict retained-mapping reclamation is unavailable on this target. + unsupported, + + /// More cold pages or a verification pass remain after this invocation's + /// candidate-bounded work. + pending, + + /// A complete verification pass compressed zero pages. + complete, +}; + +/// Iterate complete historical pages which do not intersect the viewport. +/// +/// All boundaries come from PageList pins and Page metadata. Advancing this +/// iterator never restores a compressed page or reads its backing memory. +const CompressionIterator = struct { + current: *List.Node, + active: *List.Node, + viewport_first: *List.Node, + viewport_last: *List.Node, + + fn init(self: *const PageList) CompressionIterator { + return .{ + .current = self.pages.first.?, + .active = self.getTopLeft(.active).node, + .viewport_first = self.getTopLeft(.viewport).node, + .viewport_last = self.getBottomRight(.viewport).?.node, + }; + } + + fn next(self: *CompressionIterator) ?*List.Node { + while (self.current != self.active) { + // The viewport is a contiguous node range. Once traversal reaches + // its first node, advance through the complete visible range and + // resume at the next offscreen page. + if (self.current == self.viewport_first) { + while (self.current != self.active and + self.current != self.viewport_last) + { + self.current = self.current.next.?; + } + + if (self.current == self.active) return null; + self.current = self.current.next.?; + continue; + } + + const node = self.current; + self.current = node.next.?; + return node; + } + + return null; + } + + fn done(self: *const CompressionIterator) bool { + return self.current == self.active; + } +}; + +/// Bound candidate inspection independently from compression work. Skipping +/// an already-compressed page is cheap, but still counts toward this limit. +const incremental_compression_max_inspected = 8; + +/// Failure injection for the final reclamation step. The operating-system +/// failure path cannot otherwise be exercised by tests because terminal_mem +/// deliberately simulates successful decommit in test builds. +const compressPage_tw = tripwire.module( + enum { decommit }, + error{DecommitFailed}, +); + +/// Compress eligible nodes, saving a significant amount of memory. +/// +/// Eligible nodes are complete pages before the active boundary which do not +/// intersect the viewport. The boundary page is excluded because it may +/// contain both scrollback and active rows; visible pages remain resident for +/// immediate redraw and scrolling. +/// +/// Compression requires a system that supports reclaiming physical memory for +/// virtual allocations while retaining their address ranges. +/// +/// Compression is SLOW (relatively), so incremental compression during idle +/// periods is recommended. Incremental mode performs one bounded step and its +/// result specifies whether to continue immediately. Drain mode performs +/// incremental steps until the pass and its verification pass finish. Full +/// mode visits every currently eligible node once without using incremental +/// state. +/// +/// PageList tracks mutations which require a later incremental pass in its +/// compression state. On supported targets, full compression returns +/// `complete`, indicating that it has no continuation to schedule rather than +/// that every page was compressed. +pub fn compress( + self: *PageList, + mode: enum { incremental, drain, full }, +) IncrementalCompressionResult { + return switch (mode) { + .incremental => self.compressIncremental(), + .drain => while (true) switch (self.compressIncremental()) { + .pending => continue, + .complete => break .complete, + .unsupported => break .unsupported, + }, + .full => full: { + // Match incremental mode's unsupported result. Full compression + // has no useful work to perform without strict reclamation. + if (!terminal_mem.canReclaim(.strict)) { + self.page_compression.reset(); + break :full .unsupported; + } + + self.compressFull(); + + // Full compression has no continuation. Discard any partial + // incremental cursor so later activity starts at the oldest page. + self.page_compression.reset(); + + break :full .complete; + }, + }; +} + +/// Perform one candidate-bounded incremental cold-history compression step. +fn compressIncremental(self: *PageList) IncrementalCompressionResult { + const state = &self.page_compression; + + // If we can't reclaim virtual memory, compression is unsupported. + if (!terminal_mem.canReclaim(.strict)) { + state.reset(); + return .unsupported; + } + + // Find the node following the exact continuation marker within the cold + // prefix. A missing marker means the list changed between steps, so begin + // again at the current first page. This lookup does not touch page memory. + var it: CompressionIterator = .init(self); + if (state.last_serial) |last_serial| continuation: { + while (it.next()) |node| { + // A newly allocated or replacement node appeared before the + // marker. Restart immediately so that node cannot be skipped. + if (node.serial >= state.next_serial) break; + + // Not a match? Keep looking + if (node.serial != last_serial) continue; + + // Match! The iterator already points to the next offscreen node. + break :continuation; + } + + // Not found or otherwise invalid. Reset + state.last_serial = null; + it = .init(self); + } + + // Keep track of our next_serial + state.next_serial = self.page_serial; + + // We cap the number of pages we look at to do our best to + // time-bound the incremental compression. + var inspected_pages: usize = 0; + while (inspected_pages < incremental_compression_max_inspected) { + const node = it.next() orelse break; + + state.last_serial = node.serial; + inspected_pages += 1; + + // If this page is already compressed, ignore it. + if (node.isCompressed()) continue; + + // Compression is substantially more expensive even if it fails. + // So we just try it. + if (self.compressPage(node)) state.flags.did_compress = true; + break; + } + + // If we didn't reach our active node, then we still have work to do. + if (!it.done()) return .pending; + + // We reached our active node. So we're done, except that we always + // do one pass after the first success so we can recompress nodes that + // were possibly decompressed (e.g. by search, inspector, whatever). + if (!state.flags.verifying or state.flags.did_compress) { + const activity_serial: u48 = state.activity_serial; + state.* = .{ + .flags = .{ .verifying = true }, + .activity_serial = activity_serial, + }; + return .pending; + } + + // Leave the state fresh while idle so later activity naturally begins at + // the oldest cold page, including pages restored after this pass. + state.reset(); + return .complete; +} + +/// Compress every fully historical resident page which is currently cold. +fn compressFull(self: *PageList) void { + var it: CompressionIterator = .init(self); + while (it.next()) |node| { + + // Don't restore an already-compressed page just to recompress it. + if (node.isCompressed()) continue; + + // Failure leaves this node resident and unchanged. + _ = self.compressPage(node); + } +} + +/// Attempt to compress one resident page while retaining its raw mapping. +/// +/// Compression is opportunistic: every failure leaves the page resident and +/// usable. Candidate selection and retry policy belong to `compress`; this +/// primitive only performs one state transition. +fn compressPage(self: *PageList, node: *List.Node) bool { + // Recompression requires first restoring the raw page and is a policy + // decision, so this primitive only accepts resident nodes. + if (node.isCompressed()) return false; + + const page = node.page(); + + // The scratch size is capped just below the representation's break-even + // point. Codec limits and pages too small to cover the compressed-state + // overhead simply make this page ineligible for compression. + const required = compression.Page.requiredScratch(page.memory.len) catch |err| + switch (err) { + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + if (required == 0) return false; + + // Build the compressed candidate without changing the node. This scope is + // intentional: its defer releases the borrowed or temporary scratch before + // we attempt to discard the source mapping below. Only the candidate's + // exact-sized encoded allocation survives the scope. + const candidate = candidate: { + var scratch = CompressionScratch.init( + &self.pool, + required, + page.memory.len, + ) catch |err| switch (err) { + error.OutOfMemory => return false, + }; + defer scratch.deinit(&self.pool); + + var table: compression.lz4.HashTable = undefined; + break :candidate compression.Page.init( + self.pool.alloc, + page, + scratch.bytes()[0..required], + &table, + ) catch |err| switch (err) { + error.OutOfMemory, + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + }; + + // Null means compression crossed the break-even point. The node and its + // resident mapping are still untouched in this case. + var compressed = candidate orelse return false; + + // Strict decommit is the final fallible step. It either discards the whole + // raw mapping or leaves it untouched, so failure can safely free the + // candidate and preserve the resident node exactly as it was. + const decommit_allowed: bool = allowed: { + compressPage_tw.check(.decommit) catch |err| switch (err) { + error.DecommitFailed => break :allowed false, + }; + break :allowed true; + }; + if (!decommit_allowed or !terminal_mem.decommit( + .strict, + compressed.page.memory, + compressed.page.memory.len, + )) { + compressed.deinit(); + return false; + } + + // Publish the new state only after both the encoded allocation and the + // retained-mapping decommit have succeeded. + node.data = .{ .compressed = compressed }; + return true; +} + /// Destroy the memory of the given node in the PageList linked list /// and return it to the pool. The node is assumed to already be removed /// from the linked list. @@ -3467,18 +4283,33 @@ fn destroyNodeExt( node: *List.Node, total_size: ?*usize, ) void { - const page: *Page = &node.data; + const page = node.restore(.discard); + + // Update our accounting for page size. This must mirror what was + // added at creation time: a pool-owned page always accounts for a + // full pool item even if its layout is smaller, while a heap-owned + // page accounts for its exact memory length. + if (total_size) |v| v.* -= switch (node.owned) { + .pool => PagePool.item_size, + .heap => page.memory.len, + }; - // Update our accounting for page size - if (total_size) |v| v.* -= page.memory.len; + switch (node.owned) { + .pool => { + assert(page.memory.len <= std_size); - if (page.memory.len <= std_size) { - // Reset the memory to zero so it can be reused - @memset(page.memory, 0); - pool.pages.destroy(@ptrCast(page.memory.ptr)); - } else { - const page_alloc = pool.pages.arena.child_allocator; - page_alloc.free(page.memory); + // Reset the memory to zero (and decommit it, where + // supported) so it can be reused. + const item: *align(std.heap.page_size_min) [std_size]u8 = + @ptrCast(@alignCast(page.memory.ptr)); + _ = terminal_mem.decommit(.zero, item, page.memory.len); + pool.pages.destroy(item); + }, + + .heap => { + const page_alloc = pool.pages.arena.child_allocator; + page_alloc.free(page.memory); + }, } pool.nodes.destroy(node); @@ -3499,12 +4330,20 @@ pub fn eraseRow( const pn = self.pin(pt).?; var node = pn.node; - var rows = node.data.rows.ptr(node.data.memory.ptr); + var page = node.page(); + var rows = page.rows.ptr(page.memory.ptr); + + // Erasing history may restore compressed pages. Mark unconditionally + // because incrementing the activity token is cheaper than locating the + // active boundary. + self.page_compression.markActivity(); // In order to move the following rows up we rotate the rows array by 1. // The rotate operation turns e.g. [ 0 1 2 3 ] in to [ 1 2 3 0 ], which // works perfectly to move all of our elements where they belong. - fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); + // Rotating rows changes which logical row cached coordinates identify. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[pn.y..node.rows()]); // We adjust the tracked pins in this page, moving up any that were below // the removed row. @@ -3522,12 +4361,13 @@ pub fn eraseRow( // // Technically we only need to mark rows from the erased row to the end // of the page as dirty, but that's slower and this is a hot function. - node.data.dirty = true; + page.dirty = true; // We iterate through all of the following pages in order to move their // rows up by 1 as well. while (node.next) |next| { - const next_rows = next.data.rows.ptr(next.data.memory.ptr); + const next_page = next.page(); + const next_rows = next_page.rows.ptr(next_page.memory.ptr); // We take the top row of the page and clone it in to the bottom // row of the previous page, which gets rid of the top row that was @@ -3544,19 +4384,22 @@ pub fn eraseRow( // 5 5 5 | 6 // 6 6 6 | 7 // 7 7 7 <' 4 - try node.data.cloneRowFrom( - &next.data, - &rows[node.data.size.rows - 1], + try page.cloneRowFrom( + next_page, + &rows[node.rows() - 1], &next_rows[0], ); node = next; + page = next_page; rows = next_rows; - fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); + // Rotating this page moves every cached row coordinate up by one. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[0..node.rows()]); // Mark the whole page as dirty. - node.data.dirty = true; + page.dirty = true; // Our tracked pins for this page need to be updated. // If the pin is in row 0 that means the corresponding row has @@ -3566,7 +4409,7 @@ pub fn eraseRow( if (p.node != node) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3574,7 +4417,7 @@ pub fn eraseRow( } // Clear the final row which was rotated from the top of the page. - node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols); + page.clearCells(&rows[node.rows() - 1], 0, node.cols()); } /// A variant of eraseRow that shifts only a bounded number of following @@ -3598,20 +4441,28 @@ pub fn eraseRowBounded( const pn = self.pin(pt).?; var node: *List.Node = pn.node; - var rows = node.data.rows.ptr(node.data.memory.ptr); + var page = node.page(); + var rows = page.rows.ptr(page.memory.ptr); + + // Erasing history may restore compressed pages. Mark unconditionally + // because incrementing the activity token is cheaper than locating the + // active boundary. + self.page_compression.markActivity(); // If the row limit is less than the remaining rows before the end of the // page, then we clear the row, rotate it to the end of the boundary limit // and update our pins. - if (node.data.size.rows - pn.y > limit) { - node.data.clearCells(&rows[pn.y], 0, node.data.size.cols); + if (node.rows() - pn.y > limit) { + // Rotating this bounded region changes its cached row coordinates. + self.invalidateNodeLayout(node); + page.clearCells(&rows[pn.y], 0, node.cols()); fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); // Mark the whole page as dirty. // // Technically we only need to mark from the erased row to the // limit but this is a hot function, so we want to minimize work. - node.data.dirty = true; + page.dirty = true; // If our viewport is a pin and our pin is within the erased // region we need to maybe shift our cache up. We do this here instead @@ -3646,18 +4497,20 @@ pub fn eraseRowBounded( return; } - fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); + // Rotating this suffix changes which logical row its coordinates identify. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[pn.y..node.rows()]); // Mark the whole page as dirty. // // Technically we only need to mark rows from the erased row to the end // of the page as dirty, but that's slower and this is a hot function. - node.data.dirty = true; + page.dirty = true; // We need to keep track of how many rows we've shifted so that we can // determine at what point we need to do a partial shift on subsequent // pages. - var shifted: usize = node.data.size.rows - pn.y; + var shifted: usize = node.rows() - pn.y; // Update tracked pins. { @@ -3686,15 +4539,17 @@ pub fn eraseRowBounded( } while (node.next) |next| { - const next_rows = next.data.rows.ptr(next.data.memory.ptr); + const next_page = next.page(); + const next_rows = next_page.rows.ptr(next_page.memory.ptr); - try node.data.cloneRowFrom( - &next.data, - &rows[node.data.size.rows - 1], + try page.cloneRowFrom( + next_page, + &rows[node.rows() - 1], &next_rows[0], ); node = next; + page = next_page; rows = next_rows; // We check to see if this page contains enough rows to satisfy the @@ -3703,15 +4558,17 @@ pub fn eraseRowBounded( // // The logic here is very similar to the one before the loop. const shifted_limit = limit - shifted; - if (node.data.size.rows > shifted_limit) { - node.data.clearCells(&rows[0], 0, node.data.size.cols); + if (node.rows() > shifted_limit) { + // Rotating this bounded prefix changes its cached row coordinates. + self.invalidateNodeLayout(node); + page.clearCells(&rows[0], 0, node.cols()); fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); // Mark the whole page as dirty. // // Technically we only need to mark from the erased row to the // limit but this is a hot function, so we want to minimize work. - node.data.dirty = true; + page.dirty = true; // See the other places we do something similar in this function // for a detailed explanation. @@ -3730,7 +4587,7 @@ pub fn eraseRowBounded( if (p.node != node or p.y > shifted_limit) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3739,13 +4596,15 @@ pub fn eraseRowBounded( return; } - fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); + // Rotating the whole page moves every cached row coordinate up by one. + self.invalidateNodeLayout(node); + fastmem.rotateOnce(Row, rows[0..node.rows()]); // Mark the whole page as dirty. - node.data.dirty = true; + page.dirty = true; // Account for the rows shifted in this node. - shifted += node.data.size.rows; + shifted += node.rows(); // See the other places we do something similar in this function // for a detailed explanation. @@ -3763,7 +4622,7 @@ pub fn eraseRowBounded( if (p.node != node) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3772,7 +4631,7 @@ pub fn eraseRowBounded( // We reached the end of the page list before the limit, so we clear // the final row since it was rotated down from the top of this page. - node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols); + page.clearCells(&rows[node.rows() - 1], 0, node.cols()); } /// Erase all history rows, optionally up to a bottom-left bound. @@ -3800,9 +4659,8 @@ pub fn eraseActive( /// and the page becomes underutilized (size < capacity). /// /// Callers must ensure that the erased range only removes pages from -/// the front or back of the linked list, never the middle. Middle-page -/// erasure would create serial gaps that page_serial_min cannot -/// represent, leaving dangling references in consumers such as search. +/// the front or back of the linked list, never the middle. The pin and row +/// accounting in this operation is only defined for those boundary ranges. /// Use the public eraseHistory/eraseActive wrappers which enforce this. fn eraseRows( self: *PageList, @@ -3810,6 +4668,7 @@ fn eraseRows( bl_pt: ?point.Point, ) void { defer self.assertIntegrity(); + self.page_compression.markActivity(); // The count of rows that was erased. var erased: usize = 0; @@ -3827,27 +4686,33 @@ fn eraseRows( // page so to handle this we reinit this page, set it to zero // size which will let us grow our active area back. if (chunk.node.next == null and chunk.node.prev == null) { - const page = &chunk.node.data; + // Reinitializing the sole page invalidates every coordinate in it. + self.invalidateNodeLayout(chunk.node); + const page = chunk.node.page(); erased += page.size.rows; page.reinit(); page.size.rows = 0; break; } - erased += chunk.node.data.size.rows; + erased += chunk.node.rows(); self.erasePage(chunk.node); continue; } + // Moving the retained suffix changes every captured row coordinate. + self.invalidateNodeLayout(chunk.node); + // We are modifying our chunk so make sure it is in a good state. - defer chunk.node.data.assertIntegrity(); + const page = chunk.node.page(); + defer page.assertIntegrity(); // The chunk is not a full page so we need to move the rows. // This is a cheap operation because we're just moving cell offsets, // not the actual cell contents. assert(chunk.start == 0); - const rows = chunk.node.data.rows.ptr(chunk.node.data.memory); - const scroll_amount = chunk.node.data.size.rows - chunk.end; + const rows = page.rows.ptr(page.memory); + const scroll_amount = chunk.node.rows() - chunk.end; for (0..scroll_amount) |i| { const src: *Row = &rows[i + chunk.end]; const dst: *Row = &rows[i]; @@ -3861,12 +4726,12 @@ fn eraseRows( // Clear our remaining cells that we didn't shift or swapped // in case we grow back into them. - for (scroll_amount..chunk.node.data.size.rows) |i| { + for (scroll_amount..chunk.node.rows()) |i| { const row: *Row = &rows[i]; - chunk.node.data.clearCells( + page.clearCells( row, 0, - chunk.node.data.size.cols, + chunk.node.cols(), ); } @@ -3884,7 +4749,7 @@ fn eraseRows( } // Our new size is the amount we scrolled - chunk.node.data.size.rows = @intCast(scroll_amount); + page.size.rows = @intCast(scroll_amount); erased += chunk.end; } @@ -3920,17 +4785,10 @@ fn erasePage(self: *PageList, node: *List.Node) void { // Must not be the final page. assert(node.next != null or node.prev != null); - // We only support erasing from the front or back, never the middle. - // Middle erasure would create serial gaps that page_serial_min can't - // represent. If this ever needs to change, we'll need a more - // sophisticated invalidation mechanism. + // We only support erasing from the front or back, never the middle. The + // public erase operations maintain this contract by construction. assert(node.prev == null or node.next == null); - // If we're erasing the first page, update page_serial_min so that - // any external references holding this page's serial will know it - // has been invalidated. - if (node.prev == null) self.page_serial_min = node.next.?.serial; - // Update any tracked pins to move to the previous or next page. const pin_keys = self.tracked_pins.keys(); for (pin_keys) |p| { @@ -3962,6 +4820,9 @@ pub fn pin(self: *const PageList, pt: point.Point) ?Pin { // Grab the top left and move to the point. var p = self.getTopLeft(pt).down(pt.coord().y) orelse return null; + // Incomplete reflow can leave a page narrower than the desired width. + // Never manufacture an out-of-bounds pin for that page. + if (x >= p.node.cols()) return null; p.x = x; return p; } @@ -4014,8 +4875,28 @@ pub fn pinIsValid(self: *const PageList, p: Pin) bool { var it = self.pages.first; while (it) |node| : (it = node.next) { if (node != p.node) continue; - return p.y < node.data.size.rows and - p.x < node.data.size.cols; + return p.y < node.rows() and + p.x < node.cols(); + } + + return false; +} + +/// Returns whether a node pointer and serial still identify the same page in +/// this list. The node pointer is only compared and is safe even if the node +/// has been destroyed or reused since the serial was captured. +pub fn nodeIsValid( + self: *const PageList, + target: *List.Node, + serial: u64, +) bool { + // Reset invalidates the whole prior epoch, so reject those generations + // without scanning the live list. + if (serial < self.page_serial_epoch) return false; + + var it = self.pages.first; + while (it) |node| : (it = node.next) { + if (node == target) return node.serial == serial; } return false; @@ -4069,15 +4950,23 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point if (tl.y > p.y) return null; coord.y = p.y - tl.y; } else { - coord.y += tl.node.data.size.rows - tl.y; + coord.y = std.math.add( + u32, + coord.y, + tl.node.rows() - tl.y, + ) catch return null; var node_ = tl.node.next; while (node_) |node| : (node_ = node.next) { if (node == p.node) { - coord.y += p.y; + coord.y = std.math.add(u32, coord.y, p.y) catch return null; break; } - coord.y += node.data.size.rows; + coord.y = std.math.add( + u32, + coord.y, + node.rows(), + ) catch return null; } else { // We never saw our node, meaning we're outside the range. return null; @@ -4099,7 +4988,7 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point /// Warning: this is slow and should not be used in performance critical paths pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { const pt_pin = self.pin(pt) orelse return null; - const rac = pt_pin.node.data.getRowAndCell(pt_pin.x, pt_pin.y); + const rac = pt_pin.node.page().getRowAndCell(pt_pin.x, pt_pin.y); return .{ .node = pt_pin.node, .row = rac.row, @@ -4142,13 +5031,13 @@ pub fn diagram( var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| : (page_index += 1) { - cols = chunk.node.data.size.cols; + cols = chunk.node.cols(); // Whether we've just skipped some number of rows and drawn // an ellipsis row (this is reset when a row is not skipped). var skipped = false; - for (0..chunk.node.data.size.rows) |y| { + for (0..chunk.node.rows()) |y| { // Active header if (!active and chunk.node == active_pin.node and @@ -4173,8 +5062,8 @@ pub fn diagram( // Row contents { - const row = chunk.node.data.getRow(y); - const cells = chunk.node.data.getCells(row)[0..cols]; + const row = chunk.node.page().getRow(y); + const cells = chunk.node.page().getCells(row)[0..cols]; var row_has_content = false; @@ -4223,7 +5112,7 @@ pub fn diagram( } try writer.print("{u}", .{cell.codepoint()}); if (cell.hasGrapheme()) { - const grapheme = chunk.node.data.lookupGrapheme(cell).?; + const grapheme = chunk.node.page().lookupGrapheme(cell).?; for (grapheme) |cp| { try writer.print("{u}", .{cp}); } @@ -4351,7 +5240,7 @@ pub fn highlightSemanticContent( // two prompts here. if (it.next()) |next| next: { var prev = next.up(1) orelse break :next; - prev.x = prev.node.data.size.cols - 1; + prev.x = prev.node.cols() - 1; break :end prev; } @@ -4642,7 +5531,7 @@ pub const CellIterator = struct { switch (self.row_it.page_it.direction) { .right_down => { - if (cell.x + 1 < cell.node.data.size.cols) { + if (cell.x + 1 < cell.node.cols()) { // We still have cells in this row, increase x. var copy = cell; copy.x += 1; @@ -4663,7 +5552,7 @@ pub const CellIterator = struct { // We need to move to the previous row and last col if (self.row_it.next()) |next_cell| { var copy = next_cell; - copy.x = next_cell.node.data.size.cols - 1; + copy.x = next_cell.node.cols() - 1; self.cell = copy; } else { self.cell = null; @@ -4796,24 +5685,21 @@ pub const PageIterator = struct { break :none .{ .node = row.node, .start = row.y, - .end = row.node.data.size.rows, + .end = row.node.rows(), }; }, .count => |*limit| count: { assert(limit.* > 0); // should be handled already - const len = @min(row.node.data.size.rows - row.y, limit.*); - if (len > limit.*) { - self.row = row.down(len); - limit.* -= len; - } else { - self.row = null; - } + const available: usize = row.node.rows() - row.y; + const len = @min(available, limit.*); + limit.* -= len; + self.row = if (limit.* > 0) row.down(len) else null; break :count .{ .node = row.node, .start = row.y, - .end = row.y + len, + .end = @intCast(@as(usize, row.y) + len), }; }, @@ -4829,7 +5715,7 @@ pub const PageIterator = struct { break :row .{ .node = row.node, .start = row.y, - .end = row.node.data.size.rows, + .end = row.node.rows(), }; } @@ -4858,7 +5744,7 @@ pub const PageIterator = struct { const next_page = row.node.prev orelse break :next null; break :next .{ .node = next_page, - .y = next_page.data.size.rows - 1, + .y = next_page.rows() - 1, }; }; @@ -4871,18 +5757,15 @@ pub const PageIterator = struct { .count => |*limit| count: { assert(limit.* > 0); // should be handled already - const len = @min(row.y, limit.*); - if (len > limit.*) { - self.row = row.up(len); - limit.* -= len; - } else { - self.row = null; - } + const available: usize = @as(usize, row.y) + 1; + const len = @min(available, limit.*); + limit.* -= len; + self.row = if (limit.* > 0) row.up(len) else null; break :count .{ .node = row.node, - .start = row.y - len, - .end = row.y - 1, + .start = @intCast(available - len), + .end = @intCast(available), }; }, @@ -4894,7 +5777,7 @@ pub const PageIterator = struct { const next_page = row.node.prev orelse break :next null; break :next .{ .node = next_page, - .y = next_page.data.size.rows - 1, + .y = next_page.rows() - 1, }; }; @@ -4928,13 +5811,14 @@ pub const PageIterator = struct { end: size.CellCountInt, pub fn rows(self: Chunk) []Row { - const rows_ptr = self.node.data.rows.ptr(self.node.data.memory); + const page = self.node.page(); + const rows_ptr = page.rows.ptr(page.memory); return rows_ptr[self.start..self.end]; } /// Returns true if this chunk represents every row in the page. pub fn fullPage(self: Chunk) bool { - return self.start == 0 and self.end == self.node.data.size.rows; + return self.start == 0 and self.end == self.node.rows(); } /// Returns true if this chunk overlaps with the given other chunk @@ -5006,12 +5890,12 @@ pub fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { var rem = self.rows; var it = self.pages.last; while (it) |node| : (it = node.prev) { - if (rem <= node.data.size.rows) break :active .{ + if (rem <= node.rows()) break :active .{ .node = node, - .y = node.data.size.rows - rem, + .y = node.rows() - rem, }; - rem -= node.data.size.rows; + rem -= node.rows(); } unreachable; // assertion: we always have enough rows for active @@ -5028,22 +5912,22 @@ pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { const node = self.pages.last.?; break :last .{ .node = node, - .y = node.data.size.rows - 1, - .x = node.data.size.cols - 1, + .y = node.rows() - 1, + .x = node.cols() - 1, }; }, .viewport => viewport: { var br = self.getTopLeft(.viewport); br = br.down(self.rows - 1).?; - br.x = br.node.data.size.cols - 1; + br.x = br.node.cols() - 1; break :viewport br; }, .history => active: { var br = self.getTopLeft(.active); br = br.up(1) orelse return null; - br.x = br.node.data.size.cols - 1; + br.x = br.node.cols() - 1; break :active br; }, }; @@ -5058,7 +5942,7 @@ fn totalRows(self: *const PageList) usize { var rows: usize = 0; var node_ = self.pages.first; while (node_) |node| { - rows += node.data.size.rows; + rows += node.rows(); node_ = node.next; } @@ -5078,6 +5962,88 @@ pub fn totalPages(self: *const PageList) usize { return pages; } +/// Snapshot of the storage used by page nodes in this list. +/// +/// The raw byte counts describe page backing mappings only. They exclude +/// nodes, allocator metadata, unused preheated pool items, and the small +/// representation values stored in each node. A compressed page retains its +/// raw mapping as virtual address space, but its bytes are counted as +/// decommitted because strict reclamation succeeded before the state was +/// published. +pub const MemoryStats = struct { + /// Pages whose raw backing mappings are resident. + resident_pages: usize = 0, + + /// Pages represented by encoded storage and a decommitted raw mapping. + compressed_pages: usize = 0, + + /// Logical bytes in every raw page mapping. + raw_bytes: usize = 0, + + /// Raw mapping bytes which remain resident. + resident_raw_bytes: usize = 0, + + /// Raw mapping bytes discarded for compressed pages. + decommitted_raw_bytes: usize = 0, + + /// Raw allocation bytes which remain physically resident. + /// + /// This can exceed `resident_raw_bytes` because a pool-owned page uses + /// only part of a standard pool item. Compressing such a page decommits + /// its initialized range, but the unused tail of the item stays resident. + resident_backing_bytes: usize = 0, + + /// Exact encoded allocations retained for compressed pages. + encoded_bytes: usize = 0, + + /// Estimate resident page backing storage after compression. + pub fn estimatedResidentBytes(self: MemoryStats) usize { + return self.resident_backing_bytes + self.encoded_bytes; + } + + /// Estimate physical bytes avoided by compressed page backing storage. + pub fn estimatedSavings(self: MemoryStats) usize { + return self.decommitted_raw_bytes -| self.encoded_bytes; + } +}; + +/// Return a metadata-only snapshot of page backing storage. +/// +/// This never restores compressed pages. It is intended for diagnostics and +/// other infrequent reporting because it traverses the complete page list. +pub fn memoryStats(self: *const PageList) MemoryStats { + var result: MemoryStats = .{}; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + const raw_len = node.metadata().memory.len; + const backing_len = switch (node.owned) { + .pool => PagePool.item_size, + .heap => raw_len, + }; + assert(backing_len >= raw_len); + result.raw_bytes += raw_len; + + switch (node.data) { + .resident => { + result.resident_pages += 1; + result.resident_raw_bytes += raw_len; + result.resident_backing_bytes += backing_len; + }, + + .compressed => |compressed| { + result.compressed_pages += 1; + result.decommitted_raw_bytes += raw_len; + // Strict reclamation covers only Page.memory. A standard pool + // item can have an unused tail which remains resident. + result.resident_backing_bytes += backing_len - raw_len; + result.encoded_bytes += compressed.encoded.len; + }, + } + } + + return result; +} + /// Grow the number of rows available in the page list by n. /// This is only used for testing so it isn't optimized in any way. fn growRows(self: *PageList, n: usize) Allocator.Error!void { @@ -5089,8 +6055,9 @@ fn growRows(self: *PageList, n: usize) Allocator.Error!void { pub fn clearDirty(self: *PageList) void { var page = self.pages.first; while (page) |p| : (page = p.next) { - p.data.dirty = false; - for (p.data.rows.ptr(p.data.memory)[0..p.data.size.rows]) |*row| { + const current_page = p.page(); + current_page.dirty = false; + for (current_page.rows.ptr(current_page.memory)[0..p.rows()]) |*row| { row.dirty = false; } } @@ -5137,7 +6104,7 @@ pub const Pin = struct { row: *pagepkg.Row, cell: *pagepkg.Cell, } { - const rac = self.node.data.getRowAndCell(self.x, self.y); + const rac = self.node.page().getRowAndCell(self.x, self.y); return .{ .row = rac.row, .cell = rac.cell }; } @@ -5147,8 +6114,9 @@ pub const Pin = struct { /// what subset of the cells are returned. The "left/right" subsets are /// inclusive of the x coordinate of the pin. pub inline fn cells(self: Pin, subset: CellSubset) []pagepkg.Cell { - const rac = self.rowAndCell(); - const all = self.node.data.getCells(rac.row); + const page = self.node.page(); + const rac = page.getRowAndCell(self.x, self.y); + const all = page.getCells(rac.row); return switch (subset) { .all => all, .left => all[0 .. self.x + 1], @@ -5159,21 +6127,23 @@ pub const Pin = struct { /// Returns the grapheme codepoints for the given cell. These are only /// the EXTRA codepoints and not the first codepoint. pub inline fn grapheme(self: Pin, cell: *const pagepkg.Cell) ?[]u21 { - return self.node.data.lookupGrapheme(cell); + return self.node.page().lookupGrapheme(cell); } /// Returns the style for the given cell in this pin. pub inline fn style(self: Pin, cell: *const pagepkg.Cell) stylepkg.Style { if (cell.style_id == stylepkg.default_id) return .{}; - return self.node.data.styles.get( - self.node.data.memory, + const page = self.node.page(); + return page.styles.get( + page.memory, cell.style_id, ).*; } /// Check if this pin is dirty. pub inline fn isDirty(self: Pin) bool { - return self.node.data.dirty or self.rowAndCell().row.dirty; + const page = self.node.page(); + return page.dirty or page.getRowAndCell(self.x, self.y).row.dirty; } /// Mark this pin location as dirty. @@ -5354,7 +6324,7 @@ pub const Pin = struct { /// Move the pin right n columns. n must fit within the size. pub inline fn right(self: Pin, n: usize) Pin { - assert(self.x + n < self.node.data.size.cols); + assert(self.x + n < self.node.cols()); var result = self; result.x +|= std.math.cast(size.CellCountInt, n) orelse std.math.maxInt(size.CellCountInt); @@ -5371,7 +6341,7 @@ pub const Pin = struct { /// Move the pin right n columns, stopping at the end of the row. pub inline fn rightClamp(self: Pin, n: size.CellCountInt) Pin { var result = self; - result.x = @min(self.x +| n, self.node.data.size.cols - 1); + result.x = @min(self.x +| n, self.node.cols() - 1); return result; } @@ -5381,25 +6351,18 @@ pub const Pin = struct { /// /// TODO: Unit tests. pub fn leftWrap(self: Pin, n: usize) ?Pin { - // NOTE: This assumes that all pages have the same width, which may - // be violated under certain circumstances by incomplete reflow. - const cols = self.node.data.size.cols; - const remaining_in_row = self.x; - - if (n <= remaining_in_row) return self.left(n); - - const extra_after_remaining = n - remaining_in_row; - - const rows_off = 1 + extra_after_remaining / cols; - - switch (self.upOverflow(rows_off)) { - .offset => |v| { - var result = v; - result.x = @intCast(cols - extra_after_remaining % cols); - return result; - }, - .overflow => return null, + var result = self; + var remaining = n; + while (remaining > result.x) { + remaining -= @as(usize, result.x) + 1; + result = result.up(1) orelse return null; + // Crossing a row boundary lands on that destination row's final + // cell, whose width may differ from ours during reflow. + result.x = result.node.cols() - 1; } + + result.x -= @intCast(remaining); + return result; } /// Move the pin right n cells, wrapping to the next row as needed. @@ -5408,24 +6371,18 @@ pub const Pin = struct { /// /// TODO: Unit tests. pub fn rightWrap(self: Pin, n: usize) ?Pin { - // NOTE: This assumes that all pages have the same width, which may - // be violated under certain circumstances by incomplete reflow. - const cols = self.node.data.size.cols; - const remaining_in_row = cols - self.x - 1; - - if (n <= remaining_in_row) return self.right(n); - - const extra_after_remaining = n - remaining_in_row; - - const rows_off = 1 + extra_after_remaining / cols; - - switch (self.downOverflow(rows_off)) { - .offset => |v| { - var result = v; - result.x = @intCast(extra_after_remaining % cols - 1); + var result = self; + var remaining = n; + while (true) { + const row_remaining = result.node.cols() - result.x - 1; + if (remaining <= row_remaining) { + result.x += @intCast(remaining); return result; - }, - .overflow => return null, + } + + remaining -= @as(usize, row_remaining) + 1; + result = result.down(1) orelse return null; + result.x = 0; } } @@ -5457,7 +6414,7 @@ pub const Pin = struct { }, } { // Index fits within this page - const rows = self.node.data.size.rows - (self.y + 1); + const rows = self.node.rows() - (self.y + 1); if (n <= rows) return .{ .offset = .{ .node = self.node, .y = std.math.cast(size.CellCountInt, self.y + n) orelse @@ -5472,18 +6429,18 @@ pub const Pin = struct { node = node.next orelse return .{ .overflow = .{ .end = .{ .node = node, - .y = node.data.size.rows - 1, - .x = self.x, + .y = node.rows() - 1, + .x = @min(self.x, node.cols() - 1), }, .remaining = n_left, } }; - if (n_left <= node.data.size.rows) return .{ .offset = .{ + if (n_left <= node.rows()) return .{ .offset = .{ .node = node, .y = std.math.cast(size.CellCountInt, n_left - 1) orelse std.math.maxInt(size.CellCountInt), - .x = self.x, + .x = @min(self.x, node.cols() - 1), } }; - n_left -= node.data.size.rows; + n_left -= node.rows(); } } @@ -5509,20 +6466,111 @@ pub const Pin = struct { var n_left: usize = n - self.y; while (true) { node = node.prev orelse return .{ .overflow = .{ - .end = .{ .node = node, .y = 0, .x = self.x }, + .end = .{ + .node = node, + .y = 0, + .x = @min(self.x, node.cols() - 1), + }, .remaining = n_left, } }; - if (n_left <= node.data.size.rows) return .{ .offset = .{ + if (n_left <= node.rows()) return .{ .offset = .{ .node = node, - .y = std.math.cast(size.CellCountInt, node.data.size.rows - n_left) orelse + .y = std.math.cast(size.CellCountInt, node.rows() - n_left) orelse std.math.maxInt(size.CellCountInt), - .x = self.x, + .x = @min(self.x, node.cols() - 1), } }; - n_left -= node.data.size.rows; + n_left -= node.rows(); } } }; +fn mixedWidthPinListForTest(alloc: Allocator) !PageList { + var result = try init(alloc, 2, 1, null); + errdefer result.deinit(); + + // This deliberately constructs a layout that normal PageList operations + // do not expose yet. Keep integrity checks paused through deinit so the + // fixture can exercise mixed-width traversal in isolation. + result.pauseIntegrityChecks(true); + + inline for (.{ 4, 3 }) |cols| { + const node = try result.createPage(.{ .cap = .{ + .cols = cols, + .rows = 1, + } }); + node.page().size.rows = 1; + result.pages.append(node); + result.total_rows += 1; + } + + // Desired geometry is wider than the first and last stored pages. + result.cols = 4; + return result; +} + +test "PageList Pin row movement clamps across mixed-width pages" { + const testing = std.testing; + + var s = try mixedWidthPinListForTest(testing.allocator); + defer s.deinit(); + + const first = s.pages.first.?; + const second = first.next.?; + const third = second.next.?; + + try testing.expect((Pin{ .node = third, .x = 2 }).eql( + (Pin{ .node = second, .x = 3 }).down(1).?, + )); + try testing.expect((Pin{ .node = first, .x = 1 }).eql( + (Pin{ .node = second, .x = 3 }).up(1).?, + )); + + switch ((Pin{ .node = second, .x = 3 }).downOverflow(10)) { + .offset => try testing.expect(false), + .overflow => |overflow| try testing.expect( + (Pin{ .node = third, .x = 2 }).eql(overflow.end), + ), + } + switch ((Pin{ .node = second, .x = 3 }).upOverflow(10)) { + .offset => try testing.expect(false), + .overflow => |overflow| try testing.expect( + (Pin{ .node = first, .x = 1 }).eql(overflow.end), + ), + } +} + +test "PageList Pin wrapping crosses mixed-width pages" { + const testing = std.testing; + + var s = try mixedWidthPinListForTest(testing.allocator); + defer s.deinit(); + + const first = s.pages.first.?; + const second = first.next.?; + const third = second.next.?; + + try testing.expect((Pin{ .node = third, .x = 2 }).eql( + (Pin{ .node = first, .x = 1 }).rightWrap(7).?, + )); + try testing.expect((Pin{ .node = second, .x = 0 }).eql( + (Pin{ .node = third, .x = 2 }).leftWrap(6).?, + )); + try testing.expect((Pin{ .node = first }).leftWrap(1) == null); + try testing.expect((Pin{ .node = third, .x = 2 }).rightWrap(1) == null); +} + +test "PageList Pin rejects columns beyond mixed-width page bounds" { + const testing = std.testing; + + var s = try mixedWidthPinListForTest(testing.allocator); + defer s.deinit(); + + try testing.expect(s.pin(.{ .screen = .{ .x = 1, .y = 0 } }) != null); + try testing.expect(s.pin(.{ .screen = .{ .x = 2, .y = 0 } }) == null); + try testing.expect(s.pin(.{ .screen = .{ .x = 3, .y = 1 } }) != null); + try testing.expect(s.pin(.{ .screen = .{ .x = 3, .y = 2 } }) == null); +} + pub const Cell = struct { node: *List.Node, row: *pagepkg.Row, @@ -5535,7 +6583,7 @@ pub const Cell = struct { /// This is not very performant this is primarily used for assertions /// and testing. pub fn isDirty(self: Cell) bool { - return self.node.data.dirty or self.row.dirty; + return self.node.page().dirty or self.row.dirty; } /// Get the cell style. @@ -5543,8 +6591,9 @@ pub const Cell = struct { /// Not meant for non-test usage since this is inefficient. pub fn style(self: Cell) stylepkg.Style { if (self.cell.style_id == stylepkg.default_id) return .{}; - return self.node.data.styles.get( - self.node.data.memory, + const page = self.node.page(); + return page.styles.get( + page.memory, self.cell.style_id, ).*; } @@ -5556,10 +6605,10 @@ pub const Cell = struct { /// this file then consider a different approach and ask yourself very /// carefully if you really need this. pub fn screenPoint(self: Cell) point.Point { - var y: size.CellCountInt = self.row_idx; + var y: u32 = self.row_idx; var node_ = self.node; while (node_.prev) |node| { - y += node.data.size.rows; + y += node.rows(); node_ = node; } @@ -5570,6 +6619,1293 @@ pub const Cell = struct { } }; +/// Grow a test PageList until it contains at least `count` complete history +/// pages. The production cold-page boundary is intentionally reused here so +/// tests do not duplicate the row-to-page arithmetic. +fn growColdPagesForTest(self: *PageList, count: usize) !void { + while (true) { + const active_node = self.getTopLeft(.active).node; + var cold_count: usize = 0; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + cold_count += 1; + } + + if (cold_count >= count) return; + _ = try self.grow(); + } +} + +/// Fill the current tail page to capacity without allocating a successor. +/// Capturing the tail before the loop makes this stop at the allocation +/// boundary needed by bounded-pruning tests. +fn fillLastPageForTest(self: *PageList) !void { + const last = self.pages.last.?; + while (last.rows() < last.capacity().rows) _ = try self.grow(); +} + +/// Verify every live page belongs to the current validity epoch, has an +/// allocated generation below the next serial, and validates through the same +/// pointer-plus-generation lookup used by external references. +fn expectLivePageSerialsValidForTest(self: *const PageList) !void { + const testing = std.testing; + var node = self.pages.first; + while (node) |live| : (node = live.next) { + try testing.expect(live.serial >= self.page_serial_epoch); + try testing.expect(live.serial < self.page_serial); + try testing.expect(self.nodeIsValid(live, live.serial)); + } +} + +test "PageList Pin rightWrap exact row multiple" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .x = 5, .y = 0 } }).?; + const wrapped = start.rightWrap(14).?; + _ = wrapped.rowAndCell(); + + try testing.expectEqual( + point.Point{ .active = .{ .x = 9, .y = 1 } }, + s.pointFromPin(.active, wrapped), + ); +} + +test "PageList Pin leftWrap exact row multiple" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .x = 5, .y = 2 } }).?; + const wrapped = start.leftWrap(15).?; + _ = wrapped.rowAndCell(); + + try testing.expectEqual( + point.Point{ .active = .{ .x = 0, .y = 1 } }, + s.pointFromPin(.active, wrapped), + ); +} + +test "PageList Pin rightWrap maximum distance" { + const testing = std.testing; + + var s = try init(testing.allocator, 1, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .y = 0 } }).?; + try testing.expectEqual(null, start.rightWrap(std.math.maxInt(usize))); +} + +test "PageList Pin leftWrap maximum distance" { + const testing = std.testing; + + var s = try init(testing.allocator, 1, 3, null); + defer s.deinit(); + + const start = s.pin(.{ .active = .{ .y = 2 } }).?; + try testing.expectEqual(null, start.leftWrap(std.math.maxInt(usize))); +} + +test "PageList incremental compression skips visible history" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + const initial_activity = s.page_compression.activity_serial; + try testing.expect(initial_activity > 0); + + s.scroll(.top); + const top_activity = s.page_compression.activity_serial; + try testing.expect(top_activity != initial_activity); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.drain), + ); + + const first = s.pages.first.?; + const second = first.next.?; + try testing.expectEqual(first, s.getTopLeft(.viewport).node); + try testing.expectEqual(first, s.getBottomRight(.viewport).?.node); + try testing.expect(!first.isCompressed()); + + var eligible: CompressionIterator = .init(&s); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); + + // Move the viewport to the start of the second page. Rendering it restores + // that page, while the first page which just left view becomes eligible. + s.scroll(.{ .row = first.rows() }); + try testing.expectEqual(second, s.getTopLeft(.viewport).node); + _ = second.page(); + try testing.expect(!second.isCompressed()); + _ = s.compress(.drain); + try testing.expect(first.isCompressed()); + try testing.expect(!second.isCompressed()); + + // Returning to the active area makes every complete historical page + // eligible again, including the page which was just visible. + s.scroll(.active); + _ = s.compress(.drain); + try testing.expect(second.isCompressed()); + try testing.expect(!s.page_compression.flags.did_compress); + try testing.expect(!s.page_compression.flags.verifying); + try testing.expectEqual(@as(?u64, null), s.page_compression.last_serial); + try testing.expectEqual(@as(u64, 0), s.page_compression.next_serial); +} + +test "PageList owns incremental compression state" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const state: IncrementalCompressionState = .{ + .flags = .{ + .did_compress = true, + .verifying = true, + }, + .activity_serial = 42, + .last_serial = 42, + .next_serial = 43, + }; + + s.page_compression = state; + s.scroll(.top); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + // Every scroll restarts traversal, even if clamping leaves the viewport in + // the same place. Missing an eligible page is worse than a no-op pass. + s.page_compression = state; + s.scroll(.top); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + s.scroll(.active); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + try s.resize(.{ .cols = 80, .rows = 24 }); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + s.reset(); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); + + s.page_compression = state; + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.full), + ); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); + + s.page_compression = state; + var cloned = try s.clone(testing.allocator, .{ + .top = .{ .active = .{} }, + }); + defer cloned.deinit(); + try testing.expectEqual( + IncrementalCompressionState{}, + cloned.page_compression, + ); +} + +test "PageList replacements preserve compression continuation and mark activity" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const state: IncrementalCompressionState = .{ + .flags = .{ .verifying = true }, + .activity_serial = 42, + .last_serial = 7, + .next_serial = 8, + }; + const expected: IncrementalCompressionState = .{ + .flags = .{ .verifying = true }, + .activity_serial = 43, + .last_serial = 7, + .next_serial = 8, + }; + + s.page_compression = state; + const replacement = try s.increaseCapacity(s.pages.first.?, null); + try testing.expectEqual(expected, s.page_compression); + + s.page_compression = state; + _ = (try s.compact(replacement)).?; + try testing.expectEqual(expected, s.page_compression); +} + +test "PageList incremental compression bounds inspected pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + + // Precompress every candidate so the incremental pass exercises its + // metadata-only skip budget without stopping at a resident attempt. + _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markActivity(); + try testing.expectEqual( + incremental_compression_max_inspected + 1, + s.memoryStats().compressed_pages, + ); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + const first = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); + + const second = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, second); + try testing.expect(s.page_compression.flags.verifying); + try testing.expect(s.page_compression.last_serial == null); + + // The verification pass is bounded independently, too. + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); +} + +test "PageList incremental compression advances after failure" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x494E_4352_5041_5353); + prng.random().bytes(first.page().memory); + + const failed = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // The unsuccessful first page does not stall the pass. The next step + // continues at the following serial and compresses that page. + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after allocation failure" { + const testing = std.testing; + + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + const first = s.pages.first.?; + const second = first.next.?; + + // Pool preheating supplies compression scratch. Failing the allocator's + // next request therefore rejects the exact encoded allocation while the + // source page and pass remain valid. + failing.fail_index = failing.alloc_index; + const failed = s.compress(.incremental); + try testing.expect(failing.has_induced_failure); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // Allow allocations again. The pass must continue with the following page + // rather than retrying the failed candidate. + failing.fail_index = std.math.maxInt(usize); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after decommit failure" { + const testing = std.testing; + const tw = compressPage_tw; + defer tw.end(.reset) catch unreachable; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + tw.errorAlways(.decommit, error.DecommitFailed); + const failed = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!s.pages.first.?.isCompressed()); + try tw.end(.reset); + + // The failed candidate remains resident and the pass continues at the + // next serial once reclamation is available again. + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.pages.first.?.next.?.isCompressed()); +} + +test "PageList incremental compression restarts after replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const old = s.pages.first.?; + const old_serial = old.serial; + var replacement = old; + while (replacement.page().memory.len <= std_size) { + replacement = try s.increaseCapacity( + replacement, + .grapheme_bytes, + ); + } + try testing.expect(replacement.serial != old_serial); + try testing.expect(replacement.page().memory.len > std_size); + try testing.expect(!replacement.isCompressed()); + + // The exact continuation serial disappeared with the old node. The pass + // restarts at the first page and considers the oversized replacement. + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression restarts after reset" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + // Reset replaces every page and clears the PageList-owned traversal. + s.reset(); + try s.growColdPagesForTest(1); + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); + try testing.expect(s.pages.first.?.isCompressed()); +} + +test "PageList incremental compression restarts after active boundary resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const first = s.pages.first.?; + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + try testing.expectEqual(first, s.getTopLeft(.active).node); + + // Restore the page while it is active. Resize reset the traversal, and + // active contents remain ineligible. + _ = first.page(); + const active = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, active); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); + + // Shrinking the active area makes the page fully historical again. The + // resize reset the PageList-owned cursor, so the next step can reclaim it. + try s.resize(.{ .rows = 24 }); + try s.growColdPagesForTest(1); + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expect(first.isCompressed()); +} + +test "PageList incremental compression restarts after prune reuse" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const reused = s.pages.first.?; + const old_serial = reused.serial; + while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { + _ = try s.grow(); + } + try testing.expectEqual(reused, (try s.grow()).?); + try testing.expect(reused.serial != old_serial); + + // Make the remaining old page fully historical. The continuation serial + // disappeared when its node was recycled, so the pass safely restarts. + try s.growColdPagesForTest(1); + _ = s.compress(.incremental); + try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages); +} + +test "PageList bounded pruning after partial erase preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 2) _ = try s.grow(); + const first = s.pages.first.?; + const old_serial = first.serial; + const old_rows = first.rows(); + + s.eraseHistory(.{ .history = .{ .y = 0 } }); + try testing.expectEqual(first, s.pages.first.?); + try testing.expectEqual(old_rows - 1, first.rows()); + try testing.expect(!s.nodeIsValid(first, old_serial)); + + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + +test "PageList partial erase restarts compression before continuation" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + _ = s.compress(.full); + + const first = s.pages.first.?; + try testing.expect(first.isCompressed()); + + var marker = first; + for (1..incremental_compression_max_inspected) |_| marker = marker.next.?; + s.page_compression = .{ + .flags = .{ .verifying = true }, + .last_serial = marker.serial, + .next_serial = s.page_serial, + }; + + const activity = s.page_compression.activity_serial; + s.eraseHistory(.{ .history = .{ .y = 0 } }); + try testing.expect(!first.isCompressed()); + try testing.expect(activity != s.page_compression.activity_serial); + + // The changed generation is before the saved marker, so continuation must + // restart and recompress it instead of reporting verification complete. + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expect(first.isCompressed()); +} + +test "PageList bounded pruning after split invalidation preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 2) _ = try s.grow(); + const first = s.pages.first.?; + const old_serial = first.serial; + const activity = s.page_compression.activity_serial; + + try s.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + try testing.expect(!s.nodeIsValid(first, old_serial)); + try testing.expect(activity != s.page_compression.activity_serial); + + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + +test "PageList repeated bounded pruning after split preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 3 * PagePool.item_size, + ); + defer s.deinit(); + + const epoch = s.page_serial_epoch; + while (s.totalPages() < 3) _ = try s.grow(); + const first = s.pages.first.?; + try s.split(.{ + .node = first, + .y = first.rows() / 2, + .x = 0, + }); + + // The split target has a fresh serial but precedes older successor pages. + // Prune both the old source and then that target while verifying ordinary + // list mutation does not advance the whole-list validity epoch. + for (0..2) |_| { + while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { + _ = try s.grow(); + } + _ = try s.grow(); + + // Ordinary pruning invalidates one generation at a time through live + // list validation; only reset may begin a new whole-list epoch. + try testing.expectEqual(epoch, s.page_serial_epoch); + try s.expectLivePageSerialsValidForTest(); + } +} + +test "PageList bounded pruning after front replacement preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 2) _ = try s.grow(); + const old = s.pages.first.?; + const old_serial = old.serial; + const replacement = try s.increaseCapacity(old, null); + try testing.expect(replacement != old); + try testing.expect(!s.nodeIsValid(old, old_serial)); + + try s.fillLastPageForTest(); + _ = try s.grow(); + + try s.expectLivePageSerialsValidForTest(); +} + +test "PageList bounded pruning after middle replacement preserves live serials" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 3 * PagePool.item_size, + ); + defer s.deinit(); + + while (s.totalPages() < 3) _ = try s.grow(); + const old = s.pages.first.?.next.?; + const old_serial = old.serial; + const replacement = try s.increaseCapacity(old, null); + try testing.expect(replacement != old); + try testing.expect(!s.nodeIsValid(old, old_serial)); + + // Prune the original first page and then the fresh middle replacement. + for (0..2) |_| { + try s.fillLastPageForTest(); + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); + } +} + +test "PageList incremental compression restarts after earlier replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + _ = s.compress(.incremental); + _ = s.compress(.incremental); + try testing.expect(s.pages.first.?.isCompressed()); + try testing.expect(s.pages.first.?.next.?.isCompressed()); + + // Replace a page before the still-valid continuation marker. The list's + // allocation serial changes even though the marker itself remains, so the + // next step must restart and inspect the replacement. + const old_first = s.pages.first.?; + const old_serial = old_first.serial; + const replacement = try s.increaseCapacity( + old_first, + .grapheme_bytes, + ); + try testing.expect(replacement.serial != old_serial); + try testing.expect(!replacement.isCompressed()); + + _ = s.compress(.incremental); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression keeps progress after tail growth" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markActivity(); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + const first = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); + + // Allocate a new page at the active tail between steps. It is after the + // continuation marker and must not restart progress through cold history. + const next_serial = s.page_serial; + while (s.page_serial == next_serial) _ = try s.grow(); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.page_compression.flags.verifying); +} + +test "PageList memory stats do not restore compressed pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const before = s.memoryStats(); + try testing.expectEqual(s.totalPages(), before.resident_pages); + try testing.expectEqual(@as(usize, 0), before.compressed_pages); + try testing.expectEqual(s.page_size, before.raw_bytes); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(@as(usize, 0), before.decommitted_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(@as(usize, 0), before.encoded_bytes); + try testing.expectEqual( + before.resident_backing_bytes, + before.estimatedResidentBytes(), + ); + try testing.expectEqual(@as(usize, 0), before.estimatedSavings()); + + _ = s.compress(.full); + const first = s.pages.first.?; + try testing.expectEqual(Node.Storage.compressed, first.storage()); + try testing.expect(first.pageIfResident() == null); + + const after = s.memoryStats(); + try testing.expect(first.isCompressed()); + try testing.expectEqual(s.totalPages(), after.resident_pages + after.compressed_pages); + try testing.expectEqual(@as(usize, 2), after.compressed_pages); + try testing.expectEqual(s.page_size, after.raw_bytes); + try testing.expectEqual( + after.raw_bytes, + after.resident_raw_bytes + after.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + after.encoded_bytes, + after.estimatedResidentBytes(), + ); + try testing.expectEqual( + after.decommitted_raw_bytes - after.encoded_bytes, + after.estimatedSavings(), + ); + + const first_raw_len = first.metadata().memory.len; + const first_encoded_len = first.data.compressed.encoded.len; + _ = first.page(); + try testing.expectEqual(Node.Storage.resident, first.storage()); + try testing.expect(first.pageIfResident() != null); + + const restored = s.memoryStats(); + try testing.expectEqual(after.resident_pages + 1, restored.resident_pages); + try testing.expectEqual(after.compressed_pages - 1, restored.compressed_pages); + try testing.expectEqual(after.raw_bytes, restored.raw_bytes); + try testing.expectEqual( + after.resident_raw_bytes + first_raw_len, + restored.resident_raw_bytes, + ); + try testing.expectEqual( + after.decommitted_raw_bytes - first_raw_len, + restored.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + first_raw_len, + restored.resident_backing_bytes, + ); + try testing.expectEqual( + after.encoded_bytes - first_encoded_len, + restored.encoded_bytes, + ); +} + +test "PageList preserved page keeps compressed storage" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const resident = node.page(); + resident.dirty = true; + resident.getRowAndCell(3, 2).cell.* = .init('X'); + + // Resident nodes can be borrowed without allocating an unnecessary copy. + { + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + var preserved = try node.pagePreservingState(failing.allocator()); + defer preserved.deinit(); + switch (preserved) { + .borrowed => |page_| try testing.expectEqual(resident, page_), + .owned => try testing.expect(false), + } + try testing.expect(!failing.has_induced_failure); + } + + const expected = try alloc.dupe(u8, resident.memory); + defer alloc.free(expected); + const retained_ptr = resident.memory.ptr; + + try testing.expect(s.compressPage(node)); + const stats = s.memoryStats(); + const expected_encoded = try alloc.dupe(u8, node.data.compressed.encoded); + defer alloc.free(expected_encoded); + + // Test decommit simulates physical reclamation by clearing the retained + // mapping. A preserved page must decode elsewhere rather than restoring + // it. + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + + // Preserved-page allocation is opportunistic for callers. Failure leaves + // the node and its compressed representation untouched. + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + try testing.expectError( + error.OutOfMemory, + node.pagePreservingState(failing.allocator()), + ); + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + + var preserved = try node.pagePreservingState(alloc); + defer preserved.deinit(); + switch (preserved) { + .borrowed => try testing.expect(false), + .owned => {}, + } + const page_ = preserved.page(); + + try testing.expect(page_.memory.ptr != retained_ptr); + try testing.expectEqualSlices(u8, expected, page_.memory); + try testing.expect(page_.dirty); + try testing.expectEqual( + @as(u21, 'X'), + page_.getRowAndCell(3, 2).cell.content.codepoint, + ); + + // The node still owns the same compressed representation, and neither its + // storage accounting nor its discarded raw mapping changed while cloning. + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + try testing.expectEqual(retained_ptr, node.metadata().memory.ptr); + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + try testing.expectEqualSlices( + u8, + expected_encoded, + node.data.compressed.encoded, + ); +} + +test "PageList memory stats include unused pool backing" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // Pool allocation ownership is based on the requested layout fitting in a + // standard item. The Page itself exposes only the initialized prefix. + const node = try s.createPage(.{ .cap = .{ .cols = 1, .rows = 1 } }); + try testing.expectEqual(Node.Owned.pool, node.owned); + try testing.expect(node.page().memory.len < PagePool.item_size); + node.page().size.rows = 1; + s.pages.append(node); + s.total_rows += 1; + + const raw_len = node.metadata().memory.len; + const before = s.memoryStats(); + try testing.expect(before.raw_bytes < s.page_size); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(s.page_size, before.estimatedResidentBytes()); + + try testing.expect(s.compressPage(node)); + const encoded_len = node.data.compressed.encoded.len; + const compressed = s.memoryStats(); + try testing.expectEqual(before.raw_bytes, compressed.raw_bytes); + try testing.expectEqual( + before.resident_raw_bytes - raw_len, + compressed.resident_raw_bytes, + ); + try testing.expectEqual(raw_len, compressed.decommitted_raw_bytes); + try testing.expectEqual( + before.resident_backing_bytes - raw_len, + compressed.resident_backing_bytes, + ); + try testing.expectEqual(encoded_len, compressed.encoded_bytes); + try testing.expectEqual( + before.estimatedResidentBytes() - raw_len + encoded_len, + compressed.estimatedResidentBytes(), + ); +} + +test "PageList does not compress the mixed history and active page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // One additional row creates history, but the history and all active rows + // still share the first page. The active boundary therefore has a + // historical prefix and must remain resident as one indivisible mapping. + _ = try s.grow(); + const active = s.getTopLeft(.active); + try testing.expectEqual(s.pages.first.?, active.node); + try testing.expect(active.y > 0); + + _ = s.compress(.full); + try testing.expect(!s.pages.first.?.isCompressed()); +} + +test "PageList compresses only complete cold history pages" { + const testing = std.testing; + const alloc = testing.allocator; + + // More active rows than one page at these dimensions can hold ensures the + // active area spans multiple nodes when the pass chooses its boundary. + const active_rows = initialCapacity(80).rows + 1; + var s = try init(alloc, 80, active_rows, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + // Move the active top into the boundary page so it has both a historical + // prefix and active rows while the active area still spans later pages. + _ = try s.grow(); + + const active = s.getTopLeft(.active); + const active_node = active.node; + try testing.expect(active.y > 0); + try testing.expect(active_node != s.pages.last.?); + + var expected_compressed: usize = 0; + var expected_raw_bytes: usize = 0; + var current = s.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + expected_compressed += 1; + expected_raw_bytes += node.metadata().memory.len; + } + try testing.expectEqual(@as(usize, 2), expected_compressed); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const expected = try alloc.dupe(u8, first.page().memory); + defer alloc.free(expected); + const first_memory = first.page().memory.ptr; + const page_size = s.page_size; + + _ = s.compress(.full); + const memory = s.memoryStats(); + try testing.expectEqual(expected_compressed, memory.compressed_pages); + try testing.expectEqual(expected_raw_bytes, memory.decommitted_raw_bytes); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + current = s.pages.first; + var actual_encoded_bytes: usize = 0; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + try testing.expect(node.isCompressed()); + actual_encoded_bytes += node.data.compressed.encoded.len; + } + try testing.expectEqual(actual_encoded_bytes, memory.encoded_bytes); + current = active_node; + while (current) |node| : (current = node.next) { + try testing.expect(!node.isCompressed()); + } + + // Restoring the oldest page preserves both the mapping identity and all + // of its bytes even though the pass discarded its physical pages. + try testing.expectEqual(first_memory, first.metadata().memory.ptr); + try testing.expectEqualSlices(u8, expected, first.page().memory); + try testing.expectEqual(first_memory, first.page().memory.ptr); +} + +test "PageList lazily restores compressed history made active by resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const memory_ptr = first.page().memory.ptr; + const memory_len = first.page().memory.len; + const page_size = s.page_size; + + _ = s.compress(.full); + try testing.expect(first.isCompressed()); + + // Pull all scrollback into the active area by making the viewport as tall + // as the complete screen. A row-only resize needs only page metadata, so + // the newly active page can remain compressed until its contents are used. + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + const active = s.getTopLeft(.active); + try testing.expectEqual(first, active.node); + try testing.expectEqual(@as(size.CellCountInt, 0), active.y); + try testing.expect(first.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + + // The compression pass must not reconsider the node now that it is active. + // Content access follows the normal page boundary, which recommits and + // restores the retained mapping before returning the cell. + _ = s.compress(.full); + try testing.expect(first.isCompressed()); + const cell = s.getCell(.{ .active = .{} }).?; + try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(memory_ptr, first.page().memory.ptr); + try testing.expectEqual(memory_len, first.page().memory.len); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList full and incremental compression skip a spanning viewport" { + const testing = std.testing; + + var full = try init(testing.allocator, 80, 24, null); + defer full.deinit(); + try full.growColdPagesForTest(3); + + var incremental = try init(testing.allocator, 80, 24, null); + defer incremental.deinit(); + try incremental.growColdPagesForTest(3); + + // Start near the end of the first page so the viewport intersects both + // the first and second historical page mappings. + const first = full.pages.first.?; + const overlap_rows: usize = full.rows / 2; + const viewport_row: usize = first.rows() - overlap_rows; + full.scroll(.{ .row = viewport_row }); + incremental.scroll(.{ .row = viewport_row }); + try testing.expect( + full.getTopLeft(.viewport).node != + full.getBottomRight(.viewport).?.node, + ); + const second = first.next.?; + try testing.expectEqual(first, full.getTopLeft(.viewport).node); + try testing.expectEqual(second, full.getBottomRight(.viewport).?.node); + + _ = full.compress(.full); + _ = incremental.compress(.drain); + try testing.expectEqual(full.memoryStats(), incremental.memoryStats()); + try testing.expect(!first.isCompressed()); + try testing.expect(!second.isCompressed()); + + const full_active = full.getTopLeft(.active).node; + const incremental_active = incremental.getTopLeft(.active).node; + var full_node = full.pages.first.?; + var incremental_node = incremental.pages.first.?; + while (full_node != full_active) { + try testing.expectEqual( + full_node.isCompressed(), + incremental_node.isCompressed(), + ); + + full_node = full_node.next.?; + incremental_node = incremental_node.next.?; + } + try testing.expectEqual(incremental_active, incremental_node); + + var eligible: CompressionIterator = .init(&full); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); +} + +test "PageList cold compression continues after an incompressible page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x434F_4C44_5041_4745); + prng.random().bytes(first.page().memory); + + const page_size = s.page_size; + _ = s.compress(.full); + const memory = s.memoryStats(); + try testing.expectEqual(@as(usize, 1), memory.compressed_pages); + try testing.expect(!first.isCompressed()); + try testing.expect(second.isCompressed()); + try testing.expectEqual( + second.metadata().memory.len, + memory.decommitted_raw_bytes, + ); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + // Failed resident candidates are deliberately retried on later passes, + // while the successful page remains compressed and is skipped. + _ = s.compress(.full); + try testing.expectEqual(memory, s.memoryStats()); +} + +test "PageList compression restores through page access" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const page = node.page(); + page.dirty = true; + page.getRowAndCell(3, 2).cell.* = .init('X'); + + const expected = try alloc.dupe(u8, page.memory); + defer alloc.free(expected); + const memory_ptr = page.memory.ptr; + const memory_len = page.memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 24), node.rows()); + try testing.expectEqual(@as(size.CellCountInt, 80), node.cols()); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + try testing.expectEqual(page_size, s.page_size); + + // Pin access restores the page without changing its retained mapping. + const page_pin: Pin = .{ .node = node, .x = 3, .y = 2 }; + try testing.expectEqual( + @as(u21, 'X'), + page_pin.rowAndCell().cell.content.codepoint, + ); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(node.page().dirty); + + // Recompressing exercises reuse of the page-pool scratch item. Page + // iterator chunks also restore before exposing row memory. + try testing.expect(s.compressPage(node)); + var page_it = (Pin{ .node = node }).pageIterator(.right_down, null); + const chunk = page_it.next().?; + try testing.expectEqual(node.rows(), chunk.rows().len); + try testing.expect(!node.isCompressed()); + try testing.expectEqualSlices(u8, expected, node.page().memory); + + // Read-only PageList operations restore through the same boundary. + try testing.expect(s.compressPage(node)); + var cloned = try s.clone(alloc, .{ + .top = .{ .screen = .{} }, + }); + defer cloned.deinit(); + try testing.expect(!node.isCompressed()); + try testing.expectEqual( + @as(u21, 'X'), + cloned.pages.first.?.page().getRowAndCell(3, 2).cell.content.codepoint, + ); +} + +test "PageList compression uses temporary scratch for oversized pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + var node = s.pages.first.?; + while (node.page().memory.len <= std_size) { + node = try s.increaseCapacity(node, .grapheme_bytes); + } + + const expected = try alloc.dupe(u8, node.page().memory); + defer alloc.free(expected); + const memory_ptr = node.page().memory.ptr; + const memory_len = node.page().memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); +} + +test "PageList compression leaves incompressible pages resident" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const original = try alloc.dupe(u8, node.page().memory); + defer alloc.free(original); + defer @memcpy(node.page().memory, original); + + var prng = std.Random.DefaultPrng.init(0x5041_4745_4C49_5354); + prng.random().bytes(node.page().memory); + const page_size = s.page_size; + + try testing.expect(!s.compressPage(node)); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList reset discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.reset(); + try testing.expect(!s.pages.first.?.isCompressed()); + try testing.expectEqual(@as(usize, 1), s.totalPages()); +} + +test "PageList deinit discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.deinit(); +} + +test "PageList prune reuses malformed compressed page memory" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + // Allocate the second page so the first one can be pruned and reused. + while (s.pages.first == s.pages.last) _ = try s.grow(); + const first = s.pages.first.?; + try testing.expect(s.compressPage(first)); + @memset(first.data.compressed.encoded, 0xFF); + + var reused = false; + const growth_limit = @as(usize, s.pages.last.?.capacity().rows) + 1; + for (0..growth_limit) |_| { + if (try s.grow()) |new_node| { + if (new_node == first) { + reused = true; + break; + } + } + } + + try testing.expect(reused); + try testing.expectEqual(first, s.pages.last.?); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 1), first.rows()); + first.page().assertIntegrity(); +} + test "PageList" { const testing = std.testing; const alloc = testing.allocator; @@ -5710,7 +8046,7 @@ test "PageList init more than max cols" { // We expect a single, non-standard page try testing.expect(s.pages.first != null); - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // Initial total rows should be our row count try testing.expectEqual(s.rows, s.total_rows); @@ -5794,17 +8130,17 @@ test "PageList pointFromPin active from prior page" { var s = try init(alloc, 80, 24, null); defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); { try testing.expectEqual(point.Point{ @@ -5837,17 +8173,17 @@ test "PageList pointFromPin traverse pages" { defer s.deinit(); // Grow so we take up at least 2 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 2) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); { const pages = s.totalPages(); @@ -5875,6 +8211,44 @@ test "PageList pointFromPin traverse pages" { }) == null); } } + +test "PageList pointFromPin rejects overflowing screen coordinate" { + const testing = std.testing; + + // Use maximum-height metadata-only pages to model a valid scrollback just + // beyond the u32 coordinate range without allocating their backing cells. + const page_count = 65_539; + const rows_per_page = std.math.maxInt(size.CellCountInt); + const nodes = try testing.allocator.alloc(Node, page_count); + defer testing.allocator.free(nodes); + + for (nodes, 0..) |*node, i| { + node.* = .{ + .prev = if (i > 0) &nodes[i - 1] else null, + .next = if (i + 1 < nodes.len) &nodes[i + 1] else null, + .data = .{ .resident = undefined }, + .serial = @intCast(i), + .owned = .heap, + }; + node.data.resident.size = .{ + .cols = 1, + .rows = rows_per_page, + }; + } + + var s: PageList = undefined; + s.pages = .{ + .first = &nodes[0], + .last = &nodes[nodes.len - 1], + }; + + try testing.expect(s.pointFromPin(.screen, .{ + .node = &nodes[nodes.len - 1], + .y = 0, + .x = 0, + }) == null); +} + test "PageList active after grow" { const testing = std.testing; const alloc = testing.allocator; @@ -5937,8 +8311,8 @@ test "PageList grow allows exceeding max size for active area" { { var it = s.pages.first; while (it) |page| : (it = page.next) { - page.data.size.rows = 1; - page.data.capacity.rows = 1; + page.page().size.rows = 1; + page.page().capacity.rows = 1; } // Avoid integrity check failures @@ -5967,7 +8341,7 @@ test "PageList grow prune required with a single page" { // This is important because it triggers a scenario where our calculated // minSize() which is supposed to accommodate 2 pages is no longer true. while (true) { - const layout = Page.layout(s.pages.first.?.data.capacity); + const layout = Page.layout(s.pages.first.?.capacity()); if (layout.total_size > std_size) break; _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); } @@ -5980,7 +8354,7 @@ test "PageList grow prune required with a single page" { // page. const rem = rem: { const page = s.pages.first.?; - break :rem page.data.capacity.rows - page.data.size.rows; + break :rem page.capacity().rows - page.rows(); }; for (0..rem) |_| try testing.expect(try s.grow() == null); @@ -6205,6 +8579,20 @@ test "PageList scroll delta row back overflow" { }, s.scrollbar()); } +test "PageList scroll minimum row delta" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + // Create one row of history so scrolling all the way back has an + // observable result. + try s.growRows(1); + s.scroll(.{ .delta_row = std.math.minInt(isize) }); + + try testing.expectEqual(Viewport.top, s.viewport); +} + test "PageList scroll delta row forward" { const testing = std.testing; const alloc = testing.allocator; @@ -6870,7 +9258,7 @@ test "PageList: jump zero prompts" { defer s.deinit(); try s.growRows(3); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -6890,6 +9278,16 @@ test "PageList: jump zero prompts" { }, s.scrollbar()); } +test "PageList: jump minimum prompt delta" { + const testing = std.testing; + + var s = try init(testing.allocator, 10, 3, null); + defer s.deinit(); + + s.scroll(.{ .delta_prompt = std.math.minInt(isize) }); + try testing.expectEqual(Viewport.active, s.viewport); +} + test "Screen: jump back one prompt" { const testing = std.testing; const alloc = testing.allocator; @@ -6898,7 +9296,7 @@ test "Screen: jump back one prompt" { defer s.deinit(); try s.growRows(3); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -7016,7 +9414,7 @@ test "PageList grow fit in capacity" { defer s.deinit(); // So we know we're using capacity to grow - const last = &s.pages.last.?.data; + const last = s.pages.last.?.page(); try testing.expect(last.size.rows < last.capacity.rows); // Grow @@ -7039,7 +9437,7 @@ test "PageList grow allocate" { // Grow to capacity const last_node = s.pages.last.?; - const last = &s.pages.last.?.data; + const last = s.pages.last.?.page(); for (0..last.capacity.rows - last.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7058,6 +9456,47 @@ test "PageList grow allocate" { } } +test "PageList Cell screenPoint supports long scrollback" { + const testing = std.testing; + + // A modest number of full-size page nodes is enough to exceed the u16 + // row range without allocating any page backing memory. screenPoint only + // reads the linked metadata while calculating the absolute coordinate. + const page_count = 307; + const rows_per_page = std_capacity.rows; + const nodes = try testing.allocator.alloc(Node, page_count); + defer testing.allocator.free(nodes); + + for (nodes, 0..) |*node, i| { + node.* = .{ + .prev = if (i > 0) &nodes[i - 1] else null, + .next = if (i + 1 < nodes.len) &nodes[i + 1] else null, + .data = .{ .resident = undefined }, + .serial = @intCast(i), + .owned = .heap, + }; + node.data.resident.size = .{ + .cols = 1, + .rows = rows_per_page, + }; + } + + const expected_y: u32 = (page_count - 1) * @as(u32, rows_per_page); + try testing.expect(expected_y > std.math.maxInt(size.CellCountInt)); + + const cell: Cell = .{ + .node = &nodes[nodes.len - 1], + .row = undefined, + .cell = undefined, + .row_idx = 0, + .col_idx = 0, + }; + try testing.expectEqual(point.Point{ .screen = .{ + .x = 0, + .y = expected_y, + } }, cell.screenPoint()); +} + test "PageList grow prune scrollback" { const testing = std.testing; const alloc = testing.allocator; @@ -7068,14 +9507,14 @@ test "PageList grow prune scrollback" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } // Grow and allocate one more page. Then fill that page up. const page2_node = (try s.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7137,14 +9576,14 @@ test "PageList grow prune scrollback with viewport pin not in pruned page" { // Grow to capacity of first page const page1_node = s.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } // Grow and allocate second page, then fill it up const page2_node = (try s.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7195,7 +9634,7 @@ test "PageList eraseRows invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7235,7 +9674,7 @@ test "PageList eraseRow invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7274,7 +9713,7 @@ test "PageList eraseRowBounded invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7306,6 +9745,53 @@ test "PageList eraseRowBounded invalidates viewport offset cache" { }, s.scrollbar()); } +test "PageList row erasure renews affected page generations" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + while (s.totalPages() < 2) _ = try s.grow(); + + const first = s.pages.first.?; + const second = first.next.?; + var first_serial = first.serial; + var second_serial = second.serial; + var activity = s.page_compression.activity_serial; + + try s.eraseRow(.{ .history = .{ .y = 0 } }); + try testing.expect(!s.nodeIsValid(first, first_serial)); + try testing.expect(!s.nodeIsValid(second, second_serial)); + try testing.expect(activity != s.page_compression.activity_serial); + + first_serial = first.serial; + second_serial = second.serial; + activity = s.page_compression.activity_serial; + try s.eraseRowBounded( + .{ .history = .{ .y = 0 } }, + first.rows() + 1, + ); + try testing.expect(!s.nodeIsValid(first, first_serial)); + try testing.expect(!s.nodeIsValid(second, second_serial)); + try testing.expect(activity != s.page_compression.activity_serial); +} + +test "PageList trailing row truncation renews page generation" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const node = s.pages.last.?; + const old_serial = node.serial; + const trimmed = s.trimTrailingBlankRows(1); + s.total_rows -= trimmed; + try testing.expectEqual(@as(size.CellCountInt, 1), trimmed); + try testing.expect(!s.nodeIsValid(node, old_serial)); + + _ = try s.grow(); + try s.expectLivePageSerialsValidForTest(); +} + test "PageList eraseRowBounded multi-page invalidates viewport offset cache" { const testing = std.testing; const alloc = testing.allocator; @@ -7314,7 +9800,7 @@ test "PageList eraseRowBounded multi-page invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7355,7 +9841,7 @@ test "PageList eraseRowBounded full page shift invalidates viewport offset cache defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 4) |_| { @@ -7398,7 +9884,7 @@ test "PageList eraseRowBounded exhausts pages invalidates viewport offset cache" defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7442,11 +9928,11 @@ test "PageList increaseCapacity to increase styles" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_styles_cap = s.pages.first.?.data.capacity.styles; + const original_styles_cap = s.pages.first.?.capacity().styles; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write all our data so we can assert its the same after for (0..s.rows) |y| { @@ -7465,7 +9951,7 @@ test "PageList increaseCapacity to increase styles" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Verify capacity doubled try testing.expectEqual( @@ -7493,11 +9979,11 @@ test "PageList increaseCapacity to increase graphemes" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.grapheme_bytes; + const original_cap = s.pages.first.?.capacity().grapheme_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7514,7 +10000,7 @@ test "PageList increaseCapacity to increase graphemes" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.grapheme_bytes); @@ -7537,11 +10023,11 @@ test "PageList increaseCapacity to increase hyperlinks" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.hyperlink_bytes; + const original_cap = s.pages.first.?.capacity().hyperlink_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7558,7 +10044,7 @@ test "PageList increaseCapacity to increase hyperlinks" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.hyperlink_bytes); @@ -7581,11 +10067,11 @@ test "PageList increaseCapacity to increase string_bytes" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.string_bytes; + const original_cap = s.pages.first.?.capacity().string_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7602,7 +10088,7 @@ test "PageList increaseCapacity to increase string_bytes" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.string_bytes); @@ -7657,7 +10143,7 @@ test "PageList increaseCapacity returns OutOfSpace at max capacity" { ) catch |err| { // Before OutOfSpace, we should have reached maxInt try testing.expectEqual(error.OutOfSpace, err); - try testing.expectEqual(max_styles, s.pages.first.?.data.capacity.styles); + try testing.expectEqual(max_styles, s.pages.first.?.capacity().styles); break; }; } @@ -7675,7 +10161,7 @@ test "PageList increaseCapacity after col shrink" { try testing.expectEqual(5, s.cols); { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(5, page.size.cols); try testing.expect(page.capacity.cols >= 10); } @@ -7684,7 +10170,7 @@ test "PageList increaseCapacity after col shrink" { _ = try s.increaseCapacity(s.pages.first.?, .styles); { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // size.cols should still be 5, not reverted to capacity.cols try testing.expectEqual(5, page.size.cols); try testing.expectEqual(5, s.cols); @@ -7700,19 +10186,19 @@ test "PageList increaseCapacity multi-page" { // Grow to create a second page const page1_node = s.pages.last.?; - page1_node.data.pauseIntegrityChecks(true); - for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + page1_node.page().pauseIntegrityChecks(true); + for (0..page1_node.capacity().rows - page1_node.rows()) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Now we have two pages try testing.expect(s.pages.first != s.pages.last); const page2_node = s.pages.last.?; - const page1_styles_cap = s.pages.first.?.data.capacity.styles; - const page2_styles_cap = page2_node.data.capacity.styles; + const page1_styles_cap = s.pages.first.?.capacity().styles; + const page2_styles_cap = page2_node.capacity().styles; // Increase capacity on the first page only _ = try s.increaseCapacity(s.pages.first.?, .styles); @@ -7720,13 +10206,13 @@ test "PageList increaseCapacity multi-page" { // First page capacity should be doubled try testing.expectEqual( page1_styles_cap * 2, - s.pages.first.?.data.capacity.styles, + s.pages.first.?.capacity().styles, ); // Second page should be unchanged try testing.expectEqual( page2_styles_cap, - s.pages.last.?.data.capacity.styles, + s.pages.last.?.capacity().styles, ); } @@ -7738,7 +10224,7 @@ test "PageList increaseCapacity preserves dirty flag" { defer s.deinit(); // Set page dirty flag and mark some rows as dirty - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.dirty = true; const rows = page.rows.ptr(page.memory); @@ -7751,10 +10237,10 @@ test "PageList increaseCapacity preserves dirty flag" { const new_node = try s.increaseCapacity(s.pages.first.?, .styles); // The page dirty flag should be preserved - try testing.expect(new_node.data.dirty); + try testing.expect(new_node.page().dirty); // Row dirty flags should be preserved - const new_rows = new_node.data.rows.ptr(new_node.data.memory); + const new_rows = new_node.page().rows.ptr(new_node.page().memory); try testing.expect(new_rows[0].dirty); try testing.expect(!new_rows[1].dirty); try testing.expect(new_rows[2].dirty); @@ -7793,12 +10279,12 @@ test "PageList pageIterator two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7806,9 +10292,9 @@ test "PageList pageIterator two pages" { { const chunk = it.next().?; try testing.expect(chunk.node == s.pages.first.?); - const start = chunk.node.data.size.rows - s.rows + 1; + const start = chunk.node.rows() - s.rows + 1; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(chunk.node.data.size.rows, chunk.end); + try testing.expectEqual(chunk.node.rows(), chunk.end); } { const chunk = it.next().?; @@ -7829,12 +10315,12 @@ test "PageList pageIterator history two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7882,12 +10368,12 @@ test "PageList pageIterator reverse two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7904,9 +10390,9 @@ test "PageList pageIterator reverse two pages" { { const chunk = it.next().?; try testing.expect(chunk.node == s.pages.first.?); - const start = chunk.node.data.size.rows - s.rows + 1; + const start = chunk.node.rows() - s.rows + 1; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(chunk.node.data.size.rows, chunk.end); + try testing.expectEqual(chunk.node.rows(), chunk.end); count += chunk.end - chunk.start; } try testing.expect(it.next() == null); @@ -7922,12 +10408,12 @@ test "PageList pageIterator reverse history two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7943,6 +10429,76 @@ test "PageList pageIterator reverse history two pages" { try testing.expect(it.next() == null); } +test "PageList PageIterator reverse count includes row zero" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 2, 2, null); + defer s.deinit(); + + var it: PageIterator = .{ + .row = s.getTopLeft(.screen), + .limit = .{ .count = 1 }, + .direction = .left_up, + }; + const chunk = it.next().?; + try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start); + try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end); + try testing.expect(it.next() == null); +} + +test "PageList PageIterator count crosses page boundaries" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const first = s.pages.first.?; + first.page().pauseIntegrityChecks(true); + while (first.rows() < first.capacity().rows) _ = try s.grow(); + first.page().pauseIntegrityChecks(false); + const second = (try s.grow()).?; + + var down: PageIterator = .{ + .row = .{ .node = first, .y = first.rows() - 1 }, + .limit = .{ .count = 2 }, + .direction = .right_down, + }; + { + const chunk = down.next().?; + try testing.expectEqual(first, chunk.node); + try testing.expectEqual(first.rows() - 1, chunk.start); + try testing.expectEqual(first.rows(), chunk.end); + } + { + const chunk = down.next().?; + try testing.expectEqual(second, chunk.node); + try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start); + try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end); + } + try testing.expect(down.next() == null); + + var up: PageIterator = .{ + .row = .{ .node = second }, + .limit = .{ .count = 2 }, + .direction = .left_up, + }; + { + const chunk = up.next().?; + try testing.expectEqual(second, chunk.node); + try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start); + try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end); + } + { + const chunk = up.next().?; + try testing.expectEqual(first, chunk.node); + try testing.expectEqual(first.rows() - 1, chunk.start); + try testing.expectEqual(first.rows(), chunk.end); + } + try testing.expect(up.next() == null); +} + test "PageList cellIterator" { const testing = std.testing; const alloc = testing.allocator; @@ -7950,7 +10506,7 @@ test "PageList cellIterator" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -8000,7 +10556,7 @@ test "PageList cellIterator reverse" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -8050,7 +10606,7 @@ test "PageList promptIterator left_up" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Normal prompt { const rac = page.getRowAndCell(0, 3); @@ -8107,7 +10663,7 @@ test "PageList promptIterator right_down" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Normal prompt { const rac = page.getRowAndCell(0, 3); @@ -8164,7 +10720,7 @@ test "PageList promptIterator right_down continuation at start" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback) { @@ -8207,7 +10763,7 @@ test "PageList promptIterator right_down with prompt before continuation" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 2, continuation on rows 3-4 // Starting iteration from row 3 should still find the prompt at row 2 @@ -8246,7 +10802,7 @@ test "PageList promptIterator right_down limit inclusive" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8278,7 +10834,7 @@ test "PageList promptIterator left_up limit inclusive" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8311,7 +10867,7 @@ test "PageList highlightSemanticContent prompt" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8365,7 +10921,7 @@ test "PageList highlightSemanticContent prompt with output" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8430,7 +10986,7 @@ test "PageList highlightSemanticContent prompt multiline" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt starts on row 5 { @@ -8486,7 +11042,7 @@ test "PageList highlightSemanticContent prompt only" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt content (no input) { @@ -8530,7 +11086,7 @@ test "PageList highlightSemanticContent prompt to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -8578,7 +11134,7 @@ test "PageList highlightSemanticContent input basic" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8633,7 +11189,7 @@ test "PageList highlightSemanticContent input with output" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8698,7 +11254,7 @@ test "PageList highlightSemanticContent input multiline with continuation" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8775,7 +11331,7 @@ test "PageList highlightSemanticContent input no input returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt, then immediately output { @@ -8823,7 +11379,7 @@ test "PageList highlightSemanticContent input to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -8871,7 +11427,7 @@ test "PageList highlightSemanticContent input prompt only returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt content, no input or output { @@ -8918,7 +11474,7 @@ test "PageList highlightSemanticContent output basic" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8989,7 +11545,7 @@ test "PageList highlightSemanticContent output multiline" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -9080,7 +11636,7 @@ test "PageList highlightSemanticContent output stops at next prompt" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -9165,7 +11721,7 @@ test "PageList highlightSemanticContent output to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -9237,7 +11793,7 @@ test "PageList highlightSemanticContent output no output returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt and input, no output { @@ -9297,7 +11853,7 @@ test "PageList highlightSemanticContent output skips empty cells" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output { @@ -9376,17 +11932,17 @@ test "PageList erase" { try testing.expectEqual(@as(usize, 1), s.totalPages()); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); try testing.expectEqual(@as(usize, 6), s.totalPages()); // Our total rows should be large @@ -9410,17 +11966,17 @@ test "PageList erase reaccounts page size" { const start_size = s.page_size; // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); try testing.expect(s.page_size > start_size); // Erase the entire history, we should be back to just our active set. @@ -9436,17 +11992,17 @@ test "PageList erase row with tracked pin resets to top-left" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Our total rows should be large try testing.expect(s.total_rows > s.rows); @@ -9515,17 +12071,17 @@ test "PageList erase resets viewport to active if moves within active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); @@ -9544,17 +12100,17 @@ test "PageList erase resets viewport if inside erased page but not active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); @@ -9573,17 +12129,17 @@ test "PageList erase resets viewport to active if top is inside active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .top = {} }); @@ -9613,7 +12169,7 @@ test "PageList erase a one-row active" { try testing.expectEqual(@as(usize, 1), s.totalPages()); // Write our letter - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { const rac = page.getRowAndCell(0, y); rac.cell.* = .{ @@ -9736,13 +12292,13 @@ test "PageList eraseRowBounded full rows two pages" { // Grow to two pages so our active area straddles { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); page.pauseIntegrityChecks(true); for (0..page.capacity.rows - page.size.rows) |_| _ = try s.grow(); page.pauseIntegrityChecks(false); try s.growRows(5); try testing.expectEqual(@as(usize, 2), s.totalPages()); - try testing.expectEqual(@as(usize, 5), s.pages.last.?.data.size.rows); + try testing.expectEqual(@as(usize, 5), s.pages.last.?.rows()); } // Pins @@ -9757,11 +12313,11 @@ test "PageList eraseRowBounded full rows two pages" { { try testing.expectEqual(s.pages.last.?.prev.?, p_first.node); - try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 1), p_first.y); + try testing.expectEqual(@as(usize, p_first.node.rows() - 1), p_first.y); try testing.expectEqual(@as(usize, 0), p_first.x); try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node); - try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y); + try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y); try testing.expectEqual(@as(usize, 0), p_first_out.x); try testing.expectEqual(s.pages.last.?, p_in.node); @@ -9784,12 +12340,12 @@ test "PageList eraseRowBounded full rows two pages" { // In page in first page is shifted try testing.expectEqual(s.pages.last.?.prev.?, p_first.node); - try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 2), p_first.y); + try testing.expectEqual(@as(usize, p_first.node.rows() - 2), p_first.y); try testing.expectEqual(@as(usize, 0), p_first.x); // Out page in first page should not be shifted try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node); - try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y); + try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y); try testing.expectEqual(@as(usize, 0), p_first_out.x); // In page is shifted @@ -9863,7 +12419,7 @@ test "PageList clone partial trimmed left reclaims styles" { // Style the rows we're trimming { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; const style_id = try page.styles.add(page.memory, style); @@ -9895,7 +12451,7 @@ test "PageList clone partial trimmed left reclaims styles" { { try testing.expect(s2.pages.first == s2.pages.last); - const page = &s2.pages.first.?.data; + const page = s2.pages.first.?.page(); try testing.expectEqual(0, page.styles.count()); } } @@ -10093,7 +12649,7 @@ test "PageList resize (no reflow) less rows" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10127,7 +12683,7 @@ test "PageList resize (no reflow) one rows" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10161,7 +12717,7 @@ test "PageList resize (no reflow) less rows cursor on bottom" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10213,7 +12769,7 @@ test "PageList resize (no reflow) less rows cursor in scrollback" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10264,7 +12820,7 @@ test "PageList resize (no reflow) less rows trims blank lines" { var s = try init(alloc, 10, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10323,7 +12879,7 @@ test "PageList resize (no reflow) less rows trims blank lines cursor in blank li var s = try init(alloc, 10, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10366,7 +12922,7 @@ test "PageList resize (no reflow) less rows trims blank lines erases pages" { var s = try init(alloc, 100, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Resize to take up two pages { @@ -10399,7 +12955,7 @@ test "PageList resize (no reflow) more rows extends blank lines" { var s = try init(alloc, 10, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10479,7 +13035,7 @@ test "PageList resize (no reflow) less cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10503,7 +13059,7 @@ test "PageList resize (no reflow) less cols pin in trimmed cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } @@ -10521,7 +13077,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { defer s.deinit(); // Add a grapheme. - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(9, 0); rac.cell.* = .{ @@ -10539,7 +13095,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - try testing.expectEqual(@as(usize, 0), chunk.node.data.graphemeCount()); + try testing.expectEqual(@as(usize, 0), chunk.node.page().graphemeCount()); } } @@ -10558,7 +13114,7 @@ test "PageList resize (no reflow) more cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } } @@ -10571,7 +13127,7 @@ test "PageList resize (no reflow) more cols with spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -10614,7 +13170,7 @@ test "PageList resize (no reflow) more cols with spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -10654,7 +13210,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" { // Place a spacer_head at the last column (col 4) on two rows // to simulate a wide character that didn't fit at the right edge. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Row 0: 'x' at col 0..3, spacer_head at col 4, wrap = true { @@ -10693,7 +13249,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" { // Verify the old spacer_head positions are now narrow. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(4, 0); try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); @@ -10749,14 +13305,14 @@ test "PageList resize (no reflow) more cols forces less rows per page" { var it = s.pages.first; while (it) |page| : (it = page.next) { if (page == s.pages.last.?) break; - try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + try testing.expectEqual(page.capacity().rows, page.rows()); } } // Now we need to resize again to a col size that further shrinks // our last capacity. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expect(page.size.rows == page.capacity.rows); const new_cols = new_cols: { var new_cols = page.size.cols + 50; @@ -10779,7 +13335,7 @@ test "PageList resize (no reflow) more cols forces less rows per page" { var it = s.pages.first; while (it) |page| : (it = page.next) { if (page == s.pages.last.?) break; - try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + try testing.expectEqual(page.capacity().rows, page.rows()); } } } @@ -10803,7 +13359,7 @@ test "PageList resize (no reflow) less cols then more cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10823,7 +13379,7 @@ test "PageList resize (no reflow) less rows and cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10841,8 +13397,8 @@ test "PageList resize less rows and cols cursor at bottom" { } }).?); defer s.untrackPin(cursor_pin); - // Shrink both axes so the original cursor.y is strictly past the new row - // count, after resizeWithoutReflow has already reduced self.rows. + // Shrink both axes such that the original cursor.y is strictly past the + // new row count, so resizeWithoutReflow leaves self.rows < c.y + 1. try s.resize(.{ .cols = 79, .rows = 20, @@ -10859,6 +13415,58 @@ test "PageList resize less rows and cols cursor at bottom" { } }, s.pointFromPin(.active, cursor_pin.*).?); } +test "PageList resize less rows and cols cursor near top pushed to scrollback" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Fill every active row with non-blank content so that shrinking rows + // can't trim trailing blank lines and instead pushes the top rows into + // scrollback. + { + var it = s.rowIterator(.right_down, .{ .active = .{} }, null); + while (it.next()) |p| { + const rac = p.rowAndCell(); + const cells = p.node.page().getCells(rac.row); + for (cells, 0..) |*cell, x| cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast('A' + (x % 26)) }, + }; + } + } + + // Cursor near the top of the active area. After we shrink rows the active + // area top moves down past this pin, so it ends up in scrollback. + const cursor_pin = try s.trackPin(s.pin(.{ .active = .{ + .x = 0, + .y = 0, + } }).?); + defer s.untrackPin(cursor_pin); + + // Shrink both axes with reflow. resizeWithoutReflow shrinks self.rows + // first, leaving the cursor pin above the new active area, then resizeCols + // walks .left_up from the cursor pin toward the active-area top. + try s.resize(.{ + .cols = 79, + .rows = 20, + .reflow = true, + .cursor = .{ .x = 0, .y = 0, .pin = cursor_pin }, + }); + try testing.expectEqual(@as(usize, 79), s.cols); + try testing.expectEqual(@as(usize, 20), s.rows); + + // The active area is anchored to the bottom, so shrinking rows pushed the + // top-of-screen cursor into scrollback: it no longer resolves to an + // active-area coordinate, but it remains a valid screen pin. + try testing.expect(s.pointFromPin(.active, cursor_pin.*) == null); + try testing.expect(s.pointFromPin(.screen, cursor_pin.*) != null); + + // Integrity must hold after the resize. + s.assertIntegrity(); +} + test "PageList resize (no reflow) more rows and less cols" { const testing = std.testing; const alloc = testing.allocator; @@ -10875,7 +13483,7 @@ test "PageList resize (no reflow) more rows and less cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10915,7 +13523,7 @@ test "PageList resize (no reflow) empty screen" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } } @@ -10934,7 +13542,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { var s = try init(alloc, cap.cols, cap.rows, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -10954,7 +13562,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, cap2.cols), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } @@ -10970,7 +13578,7 @@ test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" // Grow to 5 total rows, simulating 3 active + 2 scrollback try s.growRows(2); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.totalRows()) |y| { const rac = page.getRowAndCell(0, y); rac.cell.* = .{ @@ -11040,7 +13648,7 @@ test "PageList resize reflow more cols no wrapped rows" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -11059,7 +13667,7 @@ test "PageList resize reflow more cols no wrapped rows" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } @@ -11072,7 +13680,7 @@ test "PageList resize reflow more cols wrapped rows" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11110,7 +13718,7 @@ test "PageList resize reflow more cols wrapped rows" { // First row should be unwrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 4), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -11126,7 +13734,7 @@ test "PageList resize reflow invalidates viewport offset cache" { defer s.deinit(); try s.growRows(20); - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11188,7 +13796,7 @@ test "PageList resize reflow more cols creates multiple pages" { // Wrap every other row so every line is wrapped for reflow { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11220,8 +13828,8 @@ test "PageList resize reflow more cols creates multiple pages" { count += 1; // All pages should have the new capacity - try testing.expectEqual(newcap.cols, page.data.capacity.cols); - try testing.expectEqual(newcap.rows, page.data.capacity.rows); + try testing.expectEqual(newcap.cols, page.capacity().cols); + try testing.expectEqual(newcap.rows, page.capacity().rows); } // We should have more than one page, meaning we created at least @@ -11241,7 +13849,7 @@ test "PageList resize reflow more cols wrap across page boundary" { // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11255,7 +13863,7 @@ test "PageList resize reflow more cols wrap across page boundary" { // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11270,7 +13878,7 @@ test "PageList resize reflow more cols wrap across page boundary" { } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11372,7 +13980,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11386,7 +13994,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11401,7 +14009,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11458,7 +14066,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11472,7 +14080,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11487,7 +14095,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11634,7 +14242,7 @@ test "PageList resize reflow more cols cursor in wrapped row" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11685,7 +14293,7 @@ test "PageList resize reflow more cols cursor in not wrapped row" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11736,7 +14344,7 @@ test "PageList resize reflow more cols cursor in wrapped row that isn't unwrappe var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11802,7 +14410,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; } @@ -11814,7 +14422,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 1); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -11831,7 +14439,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11865,7 +14473,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Almost hit string alloc cap in bottom right of first page. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const id = try page.insertHyperlink(.{ .id = .{ .implicit = 0 }, .uri = "a" ** (pagepkg.string_bytes_default - 1), @@ -11889,7 +14497,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Almost hit string alloc cap in top left of second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const id = try page.insertHyperlink(.{ .id = .{ .implicit = 1 }, .uri = "a" ** (pagepkg.string_bytes_default - 1), @@ -11925,7 +14533,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11959,7 +14567,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Almost hit grapheme alloc cap in bottom right of first page. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(page.size.cols - 1, page.size.rows - 1); rac.row.wrap = true; rac.cell.* = .{ @@ -11992,7 +14600,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Almost hit grapheme alloc cap in top left of second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.wrap = true; rac.cell.* = .{ @@ -12037,7 +14645,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -12055,7 +14663,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Give each cell in the final row of the first page a unique style. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.cols) |x| { const id = page.styles.add( page.memory, @@ -12082,7 +14690,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Do the same for the first row of the second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); for (0..s.cols) |x| { const id = page.styles.add( page.memory, @@ -12118,7 +14726,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12162,7 +14770,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12191,7 +14799,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows" defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12251,7 +14859,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows" { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12295,7 +14903,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12338,7 +14946,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12376,7 +14984,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -12419,7 +15027,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.semantic_prompt = .prompt; } @@ -12431,7 +15039,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -12445,7 +15053,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.semantic_prompt = .prompt; } @@ -12457,7 +15065,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -12470,7 +15078,7 @@ test "PageList resize reflow less cols no wrapped rows" { var s = try init(alloc, 10, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { const end = 4; assert(end < s.cols); @@ -12494,7 +15102,7 @@ test "PageList resize reflow less cols no wrapped rows" { var offset_copy = offset; offset_copy.x = @intCast(x); const rac = offset_copy.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); try testing.expectEqual(@as(u21, @intCast(x)), cells[x].content.codepoint); } @@ -12508,7 +15116,7 @@ test "PageList resize reflow less cols wrapped rows" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12538,7 +15146,7 @@ test "PageList resize reflow less cols wrapped rows" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12546,7 +15154,7 @@ test "PageList resize reflow less cols wrapped rows" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -12555,7 +15163,7 @@ test "PageList resize reflow less cols wrapped rows" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12563,7 +15171,7 @@ test "PageList resize reflow less cols wrapped rows" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -12578,7 +15186,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12608,13 +15216,13 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { } try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12622,7 +15230,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); @@ -12636,7 +15244,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12644,7 +15252,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); @@ -12663,7 +15271,7 @@ test "PageList resize reflow less cols cursor in wrapped row" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12698,7 +15306,7 @@ test "PageList resize reflow less cols wraps spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12756,7 +15364,7 @@ test "PageList resize reflow less cols wraps spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12793,7 +15401,7 @@ test "PageList resize reflow less cols cursor goes to scrollback" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12824,7 +15432,7 @@ test "PageList resize reflow less cols cursor in unchanged row" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12858,7 +15466,7 @@ test "PageList resize reflow less cols cursor in blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12892,7 +15500,7 @@ test "PageList resize reflow less cols cursor in final blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12926,7 +15534,7 @@ test "PageList resize reflow less cols cursor in wrapped blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -12960,7 +15568,7 @@ test "PageList resize reflow less cols blank lines" { var s = try init(alloc, 4, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..1) |y| { for (0..4) |x| { const rac = page.getRowAndCell(x, y); @@ -12981,7 +15589,7 @@ test "PageList resize reflow less cols blank lines" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12989,7 +15597,7 @@ test "PageList resize reflow less cols blank lines" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -13003,7 +15611,7 @@ test "PageList resize reflow less cols blank lines between" { var s = try init(alloc, 4, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { for (0..4) |x| { const rac = page.getRowAndCell(x, 0); @@ -13037,7 +15645,7 @@ test "PageList resize reflow less cols blank lines between" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -13045,7 +15653,7 @@ test "PageList resize reflow less cols blank lines between" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -13059,7 +15667,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); rac.cell.* = .{ @@ -13084,7 +15692,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -13092,13 +15700,13 @@ test "PageList resize reflow less cols blank lines between no scrollback" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); } { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 'C'), cells[0].content.codepoint); @@ -13112,7 +15720,7 @@ test "PageList resize reflow less cols cursor not on last line preserves locatio var s = try init(alloc, 5, 5, 1); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -13157,7 +15765,7 @@ test "PageList resize reflow less cols copy style" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Create a style const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; @@ -13191,8 +15799,8 @@ test "PageList resize reflow less cols copy style" { const style_id = rac.cell.style_id; try testing.expect(style_id != 0); - const style = offset.node.data.styles.get( - offset.node.data.memory, + const style = offset.node.page().styles.get( + offset.node.page().memory, style_id, ); try testing.expect(style.flags.bold); @@ -13211,7 +15819,7 @@ test "PageList resize reflow less cols to eliminate a wide char" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13238,7 +15846,7 @@ test "PageList resize reflow less cols to eliminate a wide char" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13256,7 +15864,7 @@ test "PageList resize reflow less cols to wrap a wide char" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13290,7 +15898,7 @@ test "PageList resize reflow less cols to wrap a wide char" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13324,7 +15932,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // We want to make the screen look like this: // @@ -13383,7 +15991,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13445,7 +16053,7 @@ test "PageList resize reflow less cols copy kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (0..s.cols - 1) |x| { @@ -13486,7 +16094,7 @@ test "PageList resize reflow more cols clears kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (0..s.cols - 1) |x| { @@ -13529,7 +16137,7 @@ test "PageList resize reflow wrap moves kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (2..s.cols - 1) |x| { @@ -13586,20 +16194,44 @@ test "PageList reset invalidates stale untracked refs even if node memory is reu var s = try init(alloc, 80, 24, null); defer s.deinit(); - const old_serial = s.pages.first.?.serial; - try testing.expect(old_serial >= s.page_serial_min); - try testing.expect(old_serial < s.page_serial); - - s.reset(); + var stale_nodes: [page_preheat * 4]*List.Node = undefined; + var stale_serials: [stale_nodes.len]u64 = undefined; + var stale_len: usize = 0; + var reused: ?struct { *List.Node, u64 } = null; + + while (stale_len < stale_nodes.len and reused == null) { + const old_node = s.pages.first.?; + const old_serial = old_node.serial; + try testing.expect(old_serial >= s.page_serial_epoch); + try testing.expect(old_serial < s.page_serial); + stale_nodes[stale_len] = old_node; + stale_serials[stale_len] = old_serial; + stale_len += 1; + + s.reset(); + + const new_node = s.pages.first.?; + for (stale_nodes[0..stale_len], stale_serials[0..stale_len]) |node, serial| { + if (node == new_node) { + reused = .{ node, serial }; + break; + } + } + } - // The important safety property is that stale serials are rejected before - // the node pointer is inspected. Reset rebuilds the page list from the - // pools, so old untracked refs may contain node pointers that are no - // longer safe to dereference. - try testing.expect(old_serial < s.page_serial_min); + try testing.expect(reused != null); + const old_node, const old_serial = reused.?; + const new_node = s.pages.first.?; + const new_serial = new_node.serial; - const new_serial = s.pages.first.?.serial; - try testing.expect(new_serial >= s.page_serial_min); + // Reset advances the epoch before rebuilding from the node pool. Reject + // the stale generation before inspecting its pointer, even when that exact + // address now belongs to a new live generation. + try testing.expectEqual(old_node, new_node); + try testing.expect(old_serial < s.page_serial_epoch); + try testing.expect(!s.nodeIsValid(old_node, old_serial)); + try testing.expect(s.nodeIsValid(new_node, new_serial)); + try testing.expect(new_serial >= s.page_serial_epoch); try testing.expect(new_serial < s.page_serial); } @@ -13685,7 +16317,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // during reflow. Since each source page can only hold this many graphemes, // we create two source pages with graphemes that will merge into one // destination page. - const grapheme_capacity = s.pages.first.?.data.graphemeCapacity(); + const grapheme_capacity = s.pages.first.?.page().graphemeCapacity(); // Use slightly more than half the capacity per page, so combined they // exceed the capacity of a single destination page. const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4; @@ -13693,7 +16325,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Grow to the capacity of the first page and add more rows // so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -13715,7 +16347,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Add graphemes to the end of the first page (last rows) { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const start_row = page.size.rows - graphemes_per_page; for (0..graphemes_per_page) |i| { const y = start_row + i; @@ -13730,7 +16362,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Add graphemes to the beginning of the second page { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const count = @min(graphemes_per_page, page.size.rows); for (0..count) |y| { const rac = page.getRowAndCell(0, y); @@ -13773,7 +16405,7 @@ test "PageList resize grow cols with unwrap fixes viewport pin" { // Fill all rows with wrapped content (pairs that unwrap when cols increase) var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); for (chunk.start..chunk.end) |y| { const rac = page.getRowAndCell(0, y); if (y % 2 == 0) { @@ -13817,16 +16449,16 @@ test "PageList grow reuses non-standard page without leak" { defer s.deinit(); // Increase the first page capacity to make it non-standard (larger than std_size). - while (s.pages.first.?.data.memory.len <= std_size) { + while (s.pages.first.?.page().memory.len <= std_size) { _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); } // The first page should now have non-standard memory size. - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // First, fill up the first page's capacity const first_page = s.pages.first.?; - while (first_page.data.size.rows < first_page.data.capacity.rows) { + while (first_page.rows() < first_page.capacity().rows) { _ = try s.grow(); } @@ -13836,23 +16468,23 @@ test "PageList grow reuses non-standard page without leak" { // Continue growing until we exceed max_size AND the last page is full while (s.page_size + PagePool.item_size <= s.maxSize() or - s.pages.last.?.data.size.rows < s.pages.last.?.data.capacity.rows) + s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { _ = try s.grow(); } // The first page should still be non-standard - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // Verify we have enough rows for active area (so prune path isn't skipped) try testing.expect(s.totalRows() >= s.rows); // Verify last page is full (so grow will need to allocate/reuse) - try testing.expect(s.pages.last.?.data.size.rows == s.pages.last.?.data.capacity.rows); + try testing.expect(s.pages.last.?.page().size.rows == s.pages.last.?.capacity().rows); // Remember the first page memory pointer before the reuse attempt const first_page_ptr = s.pages.first.?; - const first_page_mem_ptr = s.pages.first.?.data.memory.ptr; + const first_page_mem_ptr = s.pages.first.?.page().memory.ptr; // Create a tracked pin pointing to the non-standard first page const tracked_pin = try s.trackPin(.{ .node = first_page_ptr, .x = 0, .y = 0 }); @@ -13871,7 +16503,7 @@ test "PageList grow reuses non-standard page without leak" { // If the non-standard page was properly destroyed and not reused, // the last page should not have the same memory pointer - try testing.expect(s.pages.last.?.data.memory.ptr != first_page_mem_ptr); + try testing.expect(s.pages.last.?.page().memory.ptr != first_page_mem_ptr); // The tracked pin should have been moved to the new first page and marked as garbage try testing.expectEqual(s.pages.first.?, tracked_pin.node); @@ -13900,19 +16532,19 @@ test "PageList grow non-standard page prune protection" { defer s.deinit(); // Make the first page non-standard - while (s.pages.first.?.data.memory.len <= std_size) { + while (s.pages.first.?.page().memory.len <= std_size) { _ = try s.increaseCapacity( s.pages.first.?, .grapheme_bytes, ); } - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); const first_page_node = s.pages.first.?; - const first_page_cap = first_page_node.data.capacity.rows; + const first_page_cap = first_page_node.capacity().rows; // Fill first page to capacity - while (first_page_node.data.size.rows < first_page_cap) _ = try s.grow(); + while (first_page_node.rows() < first_page_cap) _ = try s.grow(); // Grow until we have a second page (first page fills up first) var second_node: ?*List.Node = null; @@ -13921,8 +16553,8 @@ test "PageList grow non-standard page prune protection" { // Fill the second page to capacity so that the next grow() triggers prune const last_node = s.pages.last.?; - const second_cap = last_node.data.capacity.rows; - while (last_node.data.size.rows < second_cap) _ = try s.grow(); + const second_cap = last_node.capacity().rows; + while (last_node.rows() < second_cap) _ = try s.grow(); // Now the last page is full. The next grow must either: // 1. Prune the first page and reuse it, OR @@ -13939,7 +16571,7 @@ test "PageList grow non-standard page prune protection" { try testing.expect(s.totalRows() >= s.rows); // Verify last page is at capacity (so grow must prune or allocate new) - try testing.expectEqual(second_cap, last_node.data.size.rows); + try testing.expectEqual(second_cap, last_node.rows()); // The next grow should trigger prune consideration. // Without the fix, this would destroy the non-standard first page, @@ -13973,12 +16605,12 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { // Trim a history row so the first page has spare capacity. // This triggers the backfill path in resizeWithoutReflowGrowCols. s.eraseHistory(.{ .history = .{ .y = 0 } }); - try testing.expect(first_page.data.size.rows < first_page.data.capacity.rows); + try testing.expect(first_page.rows() < first_page.capacity().rows); // Ensure the resize takes the slow path (new capacity > current capacity). const new_cols: size.CellCountInt = cols + 1; - const adjusted = try second_page.data.capacity.adjust(.{ .cols = new_cols }); - try testing.expect(second_page.data.capacity.cols < adjusted.cols); + const adjusted = try second_page.capacity().adjust(.{ .cols = new_cols }); + try testing.expect(second_page.capacity().cols < adjusted.cols); // Track a pin in row 0 of the second page. This row will be copied // to the first page during backfill and the pin must be remapped. @@ -14005,7 +16637,7 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { } } try testing.expect(found); - try testing.expect(tracked.y < tracked.node.data.size.rows); + try testing.expect(tracked.y < tracked.node.rows()); // Verify the pin still points to the cell with our marker content. const cell = tracked.rowAndCell().cell; @@ -14013,23 +16645,108 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { try testing.expectEqual(marker, cell.content.codepoint); } -test "PageList compact std_size page returns null" { +test "PageList compact pool page produces exact-size heap page" { const testing = std.testing; const alloc = testing.allocator; var s = try init(alloc, 80, 24, 0); defer s.deinit(); - // A freshly created page should be at std_size + // A freshly created page is pool-owned at std_size. const node = s.pages.first.?; - try testing.expect(node.data.memory.len <= std_size); + try testing.expectEqual(.pool, node.owned); + try testing.expect(node.page().memory.len <= std_size); + const original_size = node.page().size; + + // Compacting it should produce a much smaller exact-size heap page. + const new_node = (try s.compact(node)).?; + try testing.expectEqual(.heap, new_node.owned); + try testing.expect(new_node.page().memory.len < std_size); + try testing.expectEqual(original_size.rows, new_node.rows()); + try testing.expectEqual(original_size.cols, new_node.cols()); + try testing.expectEqual(new_node, s.pages.first.?); + + // Our page size accounting should exactly match the compacted + // page since it is the only page in the list. + try testing.expectEqual(new_node.page().memory.len, s.page_size); + + // Compacting again should be a no-op since it is already exact. + try testing.expectEqual(null, try s.compact(new_node)); +} + +test "PageList compact then grow allocates new page" { + const testing = std.testing; + const alloc = testing.allocator; - // compact should return null since there's nothing to compact - const result = try s.compact(node); - try testing.expectEqual(null, result); + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Compact the only page. It now has no spare row capacity. + const node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(node.rows(), node.capacity().rows); + + // Growing must allocate a fresh standard page from the pool, + // exercising that a compacted page remains a valid live page. + _ = try s.grow(); + try testing.expect(s.pages.first != s.pages.last); + try testing.expectEqual(.pool, s.pages.last.?.owned); + try testing.expectEqual(@as(usize, 25), s.totalRows()); +} + +test "PageList compact then reset frees heap pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Compact the only page so the list contains a sub-std_size + // heap-owned page. + const node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(.heap, node.owned); + try testing.expect(node.page().memory.len < std_size); + + // Reset must free the heap page (testing allocator catches leaks + // and invalid frees) and rebuild from the pool. + s.reset(); + try testing.expectEqual(.pool, s.pages.first.?.owned); + try testing.expectEqual(@as(usize, s.rows), s.totalRows()); +} + +test "PageList compact then clone" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Write a marker so we can verify contents survive. + { + const node = s.pages.first.?; + const rac = node.page().getRowAndCell(1, 2); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 'X' }, + }; + } - // Page should still be the same - try testing.expectEqual(node, s.pages.first.?); + // Compact so the source list contains a sub-std_size heap page. + const node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(.heap, node.owned); + try testing.expect(node.page().memory.len < std_size); + + var s2 = try s.clone(alloc, .{ + .top = .{ .screen = .{} }, + }); + defer s2.deinit(); + try testing.expectEqual(@as(usize, s.rows), s2.totalRows()); + + // Verify the marker survived the clone. + { + const node2 = s2.pages.first.?; + const rac = node2.page().getRowAndCell(1, 2); + try testing.expectEqual(@as(u21, 'X'), rac.cell.content.codepoint); + } } test "PageList compact oversized page" { @@ -14041,11 +16758,11 @@ test "PageList compact oversized page" { // Grow until we have multiple pages const page1_node = s.pages.first.?; - page1_node.data.pauseIntegrityChecks(true); - for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + page1_node.page().pauseIntegrityChecks(true); + for (0..page1_node.capacity().rows - page1_node.rows()) |_| { _ = try s.grow(); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); _ = try s.grow(); try testing.expect(s.pages.first != s.pages.last); @@ -14053,7 +16770,7 @@ test "PageList compact oversized page" { // Write content to verify it's preserved { - const page = &node.data; + const page = node.page(); for (0..page.size.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -14070,30 +16787,30 @@ test "PageList compact oversized page" { defer s.untrackPin(tracked); // Make the page oversized - while (node.data.memory.len <= std_size) { + while (node.page().memory.len <= std_size) { node = try s.increaseCapacity(node, .grapheme_bytes); } - try testing.expect(node.data.memory.len > std_size); - const oversized_len = node.data.memory.len; - const original_size = node.data.size; + try testing.expect(node.page().memory.len > std_size); + const oversized_len = node.page().memory.len; + const original_size = node.page().size; const second_node = node.next.?; // Set dirty flag after increaseCapacity - node.data.dirty = true; + node.page().dirty = true; // Compact the page const new_node = try s.compact(node); try testing.expect(new_node != null); // Verify memory is smaller - try testing.expect(new_node.?.data.memory.len < oversized_len); + try testing.expect(new_node.?.page().memory.len < oversized_len); // Verify size preserved - try testing.expectEqual(original_size.rows, new_node.?.data.size.rows); - try testing.expectEqual(original_size.cols, new_node.?.data.size.cols); + try testing.expectEqual(original_size.rows, new_node.?.rows()); + try testing.expectEqual(original_size.cols, new_node.?.cols()); // Verify dirty flag preserved - try testing.expect(new_node.?.data.dirty); + try testing.expect(new_node.?.page().dirty); // Verify linked list integrity try testing.expectEqual(new_node.?, s.pages.first.?); @@ -14107,7 +16824,7 @@ test "PageList compact oversized page" { try testing.expectEqual(@as(size.CellCountInt, 10), tracked.y); // Verify content preserved - const page = &new_node.?.data; + const page = new_node.?.page(); for (0..page.size.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -14119,7 +16836,73 @@ test "PageList compact oversized page" { } } -test "PageList compact insufficient savings returns null" { +test "PageList destroyed pool page reuse is zeroed" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + // Create a page and scribble over its entire backing memory, + // then destroy it so the buffer returns to the pool free list. + const node = try s.createPage(.{ .cap = initialCapacity(80) }); + node.page().size.rows = 1; + const mem_ptr = node.page().memory.ptr; + @memset(node.page().memory, 0xAA); + s.destroyNode(node); + + // Reusing the buffer must produce a fully valid, zeroed page. + const node2 = try s.createPage(.{ .cap = initialCapacity(80) }); + try testing.expectEqual(mem_ptr, node2.page().memory.ptr); + node2.page().size.rows = node2.capacity().rows; + + const cells_len = @as(usize, node2.capacity().cols) * + @as(usize, node2.capacity().rows); + const cells = node2.page().cells.ptr(node2.page().memory)[0..cells_len]; + try testing.expect(std.mem.allEqual( + u64, + @as([]const u64, @ptrCast(cells)), + 0, + )); + node2.page().assertIntegrity(); + s.destroyNode(node2); +} + +test "PageList increaseCapacity from zero-capacity dimensions" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, 0); + defer s.deinit(); + + // Compact the only page. A plain page has no styled, grapheme, + // or hyperlink content so the exact capacity is zero in every + // managed dimension. + var node = (try s.compact(s.pages.first.?)).?; + try testing.expectEqual(0, node.capacity().styles); + try testing.expectEqual(0, node.capacity().grapheme_bytes); + try testing.expectEqual(0, node.capacity().string_bytes); + try testing.expectEqual(0, node.capacity().hyperlink_bytes); + + // Increasing each dimension from zero must actually grow it. + // Regression: 0 * 2 == 0 used to "succeed" without growing, + // which turned caller retry loops into infinite loops. + node = try s.increaseCapacity(node, .styles); + try testing.expect(node.capacity().styles > 0); + node = try s.increaseCapacity(node, .grapheme_bytes); + try testing.expect(node.capacity().grapheme_bytes > 0); + node = try s.increaseCapacity(node, .string_bytes); + try testing.expect(node.capacity().string_bytes > 0); + node = try s.increaseCapacity(node, .hyperlink_bytes); + try testing.expect(node.capacity().hyperlink_bytes > 0); + + // Increasing a non-zero dimension still doubles. + const styles = node.capacity().styles; + node = try s.increaseCapacity(node, .styles); + try testing.expectEqual(styles * 2, node.capacity().styles); +} + +test "PageList compact after increaseCapacity" { const testing = std.testing; const alloc = testing.allocator; @@ -14128,23 +16911,15 @@ test "PageList compact insufficient savings returns null" { var node = s.pages.first.?; - // Make the page slightly oversized (just one increase) - // This might not provide enough savings to justify compaction + // Grow the page capacity. The content is unchanged, so compaction + // should always shrink it back down to an exact-size heap page. node = try s.increaseCapacity(node, .grapheme_bytes); + const grown_len = node.page().memory.len; - // If the page is still at or below std_size, compact returns null - if (node.data.memory.len <= std_size) { - const result = try s.compact(node); - try testing.expectEqual(null, result); - } else { - // If it did grow beyond std_size, verify that compaction - // works or returns null based on savings calculation - const result = try s.compact(node); - // Either it compacted or determined insufficient savings - if (result) |new_node| { - try testing.expect(new_node.data.memory.len < node.data.memory.len); - } - } + const new_node = (try s.compact(node)).?; + try testing.expectEqual(.heap, new_node.owned); + try testing.expect(new_node.page().memory.len < grown_len); + try testing.expect(new_node.page().memory.len < std_size); } test "PageList split at middle row" { @@ -14154,7 +16929,7 @@ test "PageList split at middle row" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to rows: row 0 gets codepoint 0, row 1 gets 1, etc. for (0..page.size.rows) |y| { @@ -14173,8 +16948,8 @@ test "PageList split at middle row" { try testing.expect(s.pages.first != null); try testing.expect(s.pages.first.?.next != null); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have rows 0-4 (5 rows) try testing.expectEqual(@as(usize, 5), first_page.size.rows); @@ -14201,7 +16976,7 @@ test "PageList split at row 0 is no-op" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to all rows for (0..page.size.rows) |y| { @@ -14235,7 +17010,7 @@ test "PageList split at last row" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to all rows for (0..page.size.rows) |y| { @@ -14254,8 +17029,8 @@ test "PageList split at last row" { try testing.expect(s.pages.first != null); try testing.expect(s.pages.first.?.next != null); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have 9 rows try testing.expectEqual(@as(usize, 9), first_page.size.rows); @@ -14441,8 +17216,8 @@ test "PageList split middle page preserves linked list order" { // Now we have 2 pages const page1 = s.pages.first.?; const page2 = s.pages.first.?.next.?; - try testing.expectEqual(@as(usize, 4), page1.data.size.rows); - try testing.expectEqual(@as(usize, 8), page2.data.size.rows); + try testing.expectEqual(@as(usize, 4), page1.rows()); + try testing.expectEqual(@as(usize, 8), page2.rows()); // Split page2 at row 4 to create: page1 -> page2 (rows 0-3) -> page3 (rows 4-7) const split_pin2: Pin = .{ .node = page2, .y = 4, .x = 0 }; @@ -14469,9 +17244,9 @@ test "PageList split middle page preserves linked list order" { try testing.expect(last.next == null); // Verify row counts - try testing.expectEqual(@as(usize, 4), first.data.size.rows); - try testing.expectEqual(@as(usize, 4), middle.data.size.rows); - try testing.expectEqual(@as(usize, 4), last.data.size.rows); + try testing.expectEqual(@as(usize, 4), first.rows()); + try testing.expectEqual(@as(usize, 4), middle.rows()); + try testing.expectEqual(@as(usize, 4), last.rows()); } test "PageList split last page makes new page the last" { @@ -14489,7 +17264,7 @@ test "PageList split last page makes new page the last" { // Now split the last page const last_before_split = s.pages.last.?; - try testing.expectEqual(@as(usize, 5), last_before_split.data.size.rows); + try testing.expectEqual(@as(usize, 5), last_before_split.rows()); const split_pin2: Pin = .{ .node = last_before_split, .y = 2, .x = 0 }; try s.split(split_pin2); @@ -14501,8 +17276,8 @@ test "PageList split last page makes new page the last" { try testing.expect(new_last.next == null); // Verify row counts: original last has 2 rows, new last has 3 rows - try testing.expectEqual(@as(usize, 2), last_before_split.data.size.rows); - try testing.expectEqual(@as(usize, 3), new_last.data.size.rows); + try testing.expectEqual(@as(usize, 2), last_before_split.rows()); + try testing.expectEqual(@as(usize, 3), new_last.rows()); } test "PageList split first page keeps original as first" { @@ -14534,9 +17309,9 @@ test "PageList split first page keeps original as first" { try testing.expectEqual(second_page, inserted.next.?); // Verify row counts: first has 2, inserted has 3, second has 5 - try testing.expectEqual(@as(usize, 2), s.pages.first.?.data.size.rows); - try testing.expectEqual(@as(usize, 3), inserted.data.size.rows); - try testing.expectEqual(@as(usize, 5), second_page.data.size.rows); + try testing.expectEqual(@as(usize, 2), s.pages.first.?.rows()); + try testing.expectEqual(@as(usize, 3), inserted.rows()); + try testing.expectEqual(@as(usize, 5), second_page.rows()); } test "PageList split preserves wrap flags" { @@ -14546,7 +17321,7 @@ test "PageList split preserves wrap flags" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Set wrap flags on rows that will be in the second page after split // Row 5: wrap = true (this is the start of a wrapped line) @@ -14568,7 +17343,7 @@ test "PageList split preserves wrap flags" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const second_page = &s.pages.first.?.next.?.data; + const second_page = s.pages.first.?.next.?.page(); // Verify wrap flags are preserved in new page // Original row 5 is now row 0 in second page @@ -14600,7 +17375,7 @@ test "PageList split preserves styled cells" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Create a style and apply it to cells in rows 5-7 (which will be in the second page) const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; @@ -14623,8 +17398,8 @@ test "PageList split preserves styled cells" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no styles (all styled rows moved to second page) try testing.expectEqual(@as(usize, 0), first_page.styles.count()); @@ -14651,7 +17426,7 @@ test "PageList split preserves grapheme clusters" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Add a grapheme cluster to row 6 (will be row 1 in second page after split at 5) { @@ -14670,8 +17445,8 @@ test "PageList split preserves grapheme clusters" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no graphemes (the grapheme row moved to second page) try testing.expectEqual(@as(usize, 0), first_page.graphemeCount()); @@ -14699,7 +17474,7 @@ test "PageList split preserves hyperlinks" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Add a hyperlink to row 7 (will be row 2 in second page after split at 5) const hyperlink_id = try page.insertHyperlink(.{ @@ -14719,8 +17494,8 @@ test "PageList split preserves hyperlinks" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no hyperlinks (the hyperlink row moved to second page) try testing.expectEqual(@as(usize, 0), first_page.hyperlink_set.count()); |
