aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/PageList.zig
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal/PageList.zig
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (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.zig4215
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());