aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal/PageList.zig
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-04-04 19:22:04 +0300
committerCan Celik <ogulcancelik@gmail.com>2026-04-05 01:23:06 +0300
commit41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch)
tree3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/PageList.zig
parent55c138263e190a1e79d4b997bfb9397306c0ec1f (diff)
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/PageList.zig')
-rw-r--r--vendor/libghostty-vt/src/terminal/PageList.zig14645
1 files changed, 14645 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig
new file mode 100644
index 00000000..39873374
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/PageList.zig
@@ -0,0 +1,14645 @@
+//! Maintains a linked list of pages to make up a terminal screen
+//! and provides higher level operations on top of those pages to
+//! make it slightly easier to work with.
+const PageList = @This();
+
+const std = @import("std");
+const builtin = @import("builtin");
+const build_options = @import("terminal_options");
+const Allocator = std.mem.Allocator;
+const assert = @import("../quirks.zig").inlineAssert;
+const fastmem = @import("../fastmem.zig");
+const tripwire = @import("../tripwire.zig");
+const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList;
+const color = @import("color.zig");
+const highlight = @import("highlight.zig");
+const kitty = @import("kitty.zig");
+const point = @import("point.zig");
+const pagepkg = @import("page.zig");
+const stylepkg = @import("style.zig");
+const size = @import("size.zig");
+const OffsetBuf = size.OffsetBuf;
+const Capacity = pagepkg.Capacity;
+const Page = pagepkg.Page;
+const Row = pagepkg.Row;
+
+const log = std.log.scoped(.page_list);
+
+/// The number of PageList.Nodes we preheat the pool with. A node is
+/// a very small struct so we can afford to preheat many, but the exact
+/// number is uncertain. Any number too large is wasting memory, any number
+/// too small will cause the pool to have to allocate more memory later.
+/// This should be set to some reasonable minimum that we expect a terminal
+/// window to scroll into quickly.
+const page_preheat = 4;
+
+/// The list of pages in the screen. These are expected to be in order
+/// where the first page is the topmost page (scrollback) and the last is
+/// the bottommost page (the current active page).
+pub const List = DoublyLinkedList(Node);
+
+/// A single node within the PageList linked list.
+///
+/// This isn't pub because you can access the type via List.Node.
+const Node = struct {
+ prev: ?*Node = null,
+ next: ?*Node = null,
+ data: Page,
+ serial: u64,
+};
+
+/// The memory pool we get page nodes from.
+const NodePool = std.heap.MemoryPool(List.Node);
+
+/// The standard page capacity that we use as a starting point for
+/// all pages. This is chosen as a sane default that fits most terminal
+/// usage to support using our pool.
+const std_capacity = pagepkg.std_capacity;
+
+/// The byte size required for a standard page.
+const std_size = Page.layout(std_capacity).total_size;
+
+/// The memory pool we use for page memory buffers. We use a separate pool
+/// so we can allocate these with a page allocator. We have to use a page
+/// allocator because we need memory that is zero-initialized and page-aligned.
+const PagePool = std.heap.MemoryPoolAligned(
+ [std_size]u8,
+ .fromByteUnits(std.heap.page_size_min),
+);
+
+/// List of pins, known as "tracked" pins. These are pins that are kept
+/// up to date automatically through page-modifying operations.
+const PinSet = std.AutoArrayHashMapUnmanaged(*Pin, void);
+const PinPool = std.heap.MemoryPool(Pin);
+
+/// The pool of memory used for a pagelist. This can be shared between
+/// multiple pagelists but it is not threadsafe.
+pub const MemoryPool = struct {
+ alloc: Allocator,
+ nodes: NodePool,
+ pages: PagePool,
+ pins: PinPool,
+
+ pub const ResetMode = std.heap.ArenaAllocator.ResetMode;
+
+ pub fn init(
+ gen_alloc: Allocator,
+ page_alloc: Allocator,
+ preheat: usize,
+ ) Allocator.Error!MemoryPool {
+ var node_pool = try NodePool.initPreheated(gen_alloc, preheat);
+ errdefer node_pool.deinit();
+ var page_pool = try PagePool.initPreheated(page_alloc, preheat);
+ errdefer page_pool.deinit();
+ var pin_pool = try PinPool.initPreheated(gen_alloc, 8);
+ errdefer pin_pool.deinit();
+ return .{
+ .alloc = gen_alloc,
+ .nodes = node_pool,
+ .pages = page_pool,
+ .pins = pin_pool,
+ };
+ }
+
+ pub fn deinit(self: *MemoryPool) void {
+ self.pages.deinit();
+ self.nodes.deinit();
+ self.pins.deinit();
+ }
+
+ pub fn reset(self: *MemoryPool, mode: ResetMode) void {
+ _ = self.pages.reset(mode);
+ _ = self.nodes.reset(mode);
+ _ = self.pins.reset(mode);
+ }
+};
+
+/// The memory pool we get page nodes, pages from.
+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 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.
+///
+/// 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.
+page_size: usize,
+
+/// 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.
+explicit_max_size: usize,
+
+/// This is the minimum max size that we will respect due to the rows/cols
+/// of the PageList. We must always be able to fit at least the active area
+/// and at least two pages for our algorithms.
+min_max_size: usize,
+
+/// The total number of rows represented by this PageList. This is used
+/// specifically for scrollbar information so we can have the total size.
+total_rows: usize,
+
+/// The list of tracked pins. These are kept up to date automatically.
+tracked_pins: PinSet,
+
+/// The top-left of certain parts of the screen that are frequently
+/// accessed so we don't have to traverse the linked list to find them.
+///
+/// For other tags, don't need this:
+/// - screen: pages.first
+/// - history: active row minus one
+///
+viewport: Viewport,
+
+/// The pin used for when the viewport scrolls. This is always pre-allocated
+/// so that scrolling doesn't have a failable memory allocation. This should
+/// never be access directly; use `viewport`.
+viewport_pin: *Pin,
+
+/// The row offset from the top that the viewport pin is at. We
+/// store the offset from the top because it doesn't change while more
+/// data is printed to the terminal.
+///
+/// This is null when it isn't calculated. It is calculated on demand
+/// when the viewportRowOffset function is called, because it is only
+/// required for certain operations such as rendering the scrollbar.
+///
+/// In order to make this more efficient, in many places where the value
+/// would be invalidated, we update it in-place instead. This is key to
+/// keeping our performance decent in normal cases since recalculating
+/// this from scratch, depending on the size of the scrollback and position
+/// of the pin, can be very expensive.
+///
+/// This is only valid if viewport is `pin`. Every other offset is
+/// self-evident or quick to calculate.
+viewport_pin_row_offset: ?usize,
+
+/// The current desired screen dimensions. I say "desired" because individual
+/// pages may still be a different size and not yet reflowed since we lazily
+/// reflow text.
+cols: size.CellCountInt,
+rows: size.CellCountInt,
+
+/// If this is true then verifyIntegrity will do nothing. This is
+/// only present with runtime safety enabled.
+pause_integrity_checks: if (build_options.slow_runtime_safety) usize else void =
+ if (build_options.slow_runtime_safety) 0 else {},
+
+/// The viewport location.
+pub const Viewport = union(enum) {
+ /// The viewport is pinned to the active area. By using a specific marker
+ /// for this instead of tracking the row offset, we eliminate a number of
+ /// memory writes making scrolling faster.
+ active,
+
+ /// The viewport is pinned to the top of the screen, or the farthest
+ /// back in the scrollback history.
+ top,
+
+ /// The viewport is pinned to a tracked pin. The tracked pin is ALWAYS
+ /// s.viewport_pin hence this has no value. We force that value to prevent
+ /// allocations.
+ pin,
+};
+
+/// Returns the minimum valid "max size" for a given number of rows and cols
+/// such that we can fit the active area AND at least two pages. Note we
+/// need the two pages for algorithms to work properly (such as grow) but
+/// we don't need to fit double the active area.
+///
+/// This min size may not be totally correct in the case that a large
+/// number of other dimensions makes our row size in a page very small.
+/// But this gives us a nice fast heuristic for determining min/max size.
+/// Therefore, if the page size is violated you should always also verify
+/// that we have enough space for the active area.
+fn minMaxSize(cols: size.CellCountInt, rows: size.CellCountInt) usize {
+ // Invariant required to ensure our divCeil below cannot overflow.
+ comptime {
+ const max_rows = std.math.maxInt(size.CellCountInt);
+ _ = std.math.divCeil(usize, max_rows, 1) catch unreachable;
+ }
+
+ // Get our capacity to fit our rows. If the cols are too big, it may
+ // force less rows than we want meaning we need more than one page to
+ // represent a viewport.
+ const cap = initialCapacity(cols);
+
+ // Calculate the number of standard sized pages we need to represent
+ // an active area.
+ const pages_exact = if (cap.rows >= rows) 1 else std.math.divCeil(
+ usize,
+ rows,
+ cap.rows,
+ ) catch {
+ // Not possible:
+ // - initialCapacity guarantees at least 1 row
+ // - numerator/denominator can't overflow because of comptime check above
+ unreachable;
+ };
+
+ // We always need at least one page extra so that we
+ // can fit partial pages to spread our active area across two pages.
+ // Even for caps that can't fit all rows in a single page, we add one
+ // because the most extra space we need at any given time is only
+ // the partial amount of one page.
+ const pages = pages_exact + 1;
+ assert(pages >= 2);
+
+ // log.debug("minMaxSize cols={} rows={} cap={} pages={}", .{
+ // cols,
+ // rows,
+ // cap,
+ // pages,
+ // });
+
+ return PagePool.item_size * pages;
+}
+
+/// Calculates the initial capacity for a new page for a given column
+/// count. This will attempt to fit within std_size at all times so we
+/// can use our memory pool, but if cols is too big, this will return a
+/// larger capacity.
+///
+/// The returned capacity is always guaranteed to layout properly (not
+/// overflow). We are able to support capacities up to the maximum int
+/// value of cols, so this will never overflow.
+fn initialCapacity(cols: size.CellCountInt) Capacity {
+ // This is an important invariant that ensures that this function
+ // can never return an error. We verify here that our standard capacity
+ // when increased to maximum possible columns can always support at
+ // least one row in memory.
+ //
+ // IF THIS EVER FAILS: We probably need to modify our logic below
+ // to reduce other elements of the capacity (styles, graphemes, etc.).
+ // But, instead, I recommend taking a step back and re-evaluating
+ // life choices.
+ comptime {
+ var cap = std_capacity;
+ cap.cols = std.math.maxInt(size.CellCountInt);
+ const layout = Page.layout(cap);
+ assert(layout.total_size <= size.max_page_size);
+ }
+
+ if (std_capacity.adjust(
+ .{ .cols = cols },
+ )) |cap| {
+ // If we can adjust our standard capacity, we fit within the
+ // standard size and we're good!
+ return cap;
+ } else |err| {
+ // Ensure our error set doesn't change.
+ comptime assert(@TypeOf(err) == error{OutOfMemory});
+ }
+
+ // This code path means that our standard capacity can't even
+ // accommodate our column count! The only solution is to increase
+ // our capacity and go non-standard.
+ var cap: Capacity = std_capacity;
+ cap.cols = cols;
+ return cap;
+}
+
+/// This is the page allocator we'll use for all our underlying
+/// VM page allocations.
+inline fn pageAllocator() Allocator {
+ // In tests we use our testing allocator so we can detect leaks.
+ if (builtin.is_test) return std.testing.allocator;
+
+ // On non-macOS we use our standard Zig page allocator.
+ if (!builtin.target.os.tag.isDarwin()) return std.heap.page_allocator;
+
+ // On macOS we want to tag our memory so we can assign it to our
+ // core terminal usage.
+ const mach = @import("../os/mach.zig");
+ return mach.taggedPageAllocator(.application_specific_1);
+}
+
+const init_tw = tripwire.module(enum {
+ init_memory_pool,
+ init_pages,
+ viewport_pin,
+ viewport_pin_track,
+}, init);
+
+/// Initialize the page. The top of the first page in the list is always the
+/// top of the active area of the screen (important knowledge for quickly
+/// setting up cursors in Screen).
+///
+/// max_size is the maximum number of bytes that will be allocated for
+/// pages. If this is smaller than the bytes required to show the viewport
+/// then max_size will be ignored and the viewport will be shown, but no
+/// scrollback will be created. max_size is always rounded down to the nearest
+/// terminal page size (not virtual memory page), otherwise we would always
+/// slightly exceed max_size in the limits.
+///
+/// If max_size is null then there is no defined limit and the screen will
+/// grow forever. In reality, the limit is set to the byte limit that your
+/// computer can address in memory. If you somehow require more than that
+/// (due to disk paging) then please contribute that yourself and perhaps
+/// search deep within yourself to find out why you need that.
+pub fn init(
+ alloc: Allocator,
+ cols: size.CellCountInt,
+ rows: size.CellCountInt,
+ max_size: ?usize,
+) Allocator.Error!PageList {
+ const tw = init_tw;
+
+ // The screen starts with a single page that is the entire viewport,
+ // and we'll split it thereafter if it gets too large and add more as
+ // necessary.
+ try tw.check(.init_memory_pool);
+ var pool = try MemoryPool.init(
+ alloc,
+ pageAllocator(),
+ page_preheat,
+ );
+ errdefer pool.deinit();
+
+ try tw.check(.init_pages);
+ var page_serial: u64 = 0;
+ const page_list, const page_size = try initPages(
+ &pool,
+ &page_serial,
+ cols,
+ rows,
+ );
+
+ // Get our minimum max size, see doc comments for more details.
+ const min_max_size = minMaxSize(cols, rows);
+
+ // We always track our viewport pin to ensure this is never an allocation
+ try tw.check(.viewport_pin);
+ const viewport_pin = try pool.pins.create();
+ viewport_pin.* = .{ .node = page_list.first.? };
+ var tracked_pins: PinSet = .{};
+ errdefer tracked_pins.deinit(pool.alloc);
+
+ try tw.check(.viewport_pin_track);
+ try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {});
+
+ errdefer comptime unreachable;
+ const result: PageList = .{
+ .cols = cols,
+ .rows = rows,
+ .pool = pool,
+ .pages = page_list,
+ .page_serial = page_serial,
+ .page_serial_min = 0,
+ .page_size = page_size,
+ .explicit_max_size = max_size orelse std.math.maxInt(usize),
+ .min_max_size = min_max_size,
+ .total_rows = rows,
+ .tracked_pins = tracked_pins,
+ .viewport = .{ .active = {} },
+ .viewport_pin = viewport_pin,
+ .viewport_pin_row_offset = null,
+ };
+ result.assertIntegrity();
+ return result;
+}
+
+const initPages_tw = tripwire.module(enum {
+ page_node,
+ page_buf_std,
+ page_buf_non_std,
+}, initPages);
+
+fn initPages(
+ pool: *MemoryPool,
+ serial: *u64,
+ cols: size.CellCountInt,
+ rows: size.CellCountInt,
+) Allocator.Error!struct { List, usize } {
+ const tw = initPages_tw;
+
+ var page_list: List = .{};
+ var page_size: usize = 0;
+
+ // Add pages as needed to create our initial viewport.
+ const cap = initialCapacity(cols);
+ const layout = Page.layout(cap);
+ const pooled = layout.total_size <= std_size;
+ const page_alloc = pool.pages.arena.child_allocator;
+
+ // Guaranteed by comptime checks in initialCapacity but
+ // 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
+ // 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);
+ }
+ }
+ }
+
+ var rem = rows;
+ while (rem > 0) {
+ try tw.check(.page_node);
+ const node = try pool.nodes.create();
+ errdefer pool.nodes.destroy(node);
+
+ const page_buf = if (pooled) buf: {
+ try tw.check(.page_buf_std);
+ break :buf try pool.pages.create();
+ } else buf: {
+ try tw.check(.page_buf_non_std);
+ break :buf try page_alloc.alignedAlloc(
+ u8,
+ .fromByteUnits(std.heap.page_size_min),
+ layout.total_size,
+ );
+ };
+ errdefer if (pooled)
+ pool.pages.destroy(page_buf)
+ else
+ page_alloc.free(page_buf);
+
+ // In runtime safety modes we have to memset because the Zig allocator
+ // interface will always memset to 0xAA for undefined. On freestanding
+ // (WASM), the WasmAllocator reuses freed slots without zeroing since
+ // only fresh memory.grow pages are guaranteed zero by the WASM spec.
+ // On native, the OS page allocator (mmap) returns zeroed pages.
+ if (comptime std.debug.runtime_safety or builtin.os.tag == .freestanding)
+ @memset(page_buf, 0);
+
+ // 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),
+ .serial = serial.*,
+ };
+ node.data.size.rows = @min(rem, node.data.capacity.rows);
+ rem -= node.data.size.rows;
+
+ // Add the page to the list
+ page_list.append(node);
+ page_size += page_buf.len;
+ errdefer comptime unreachable;
+
+ // Increment our serial
+ serial.* += 1;
+ }
+
+ assert(page_list.first != null);
+
+ return .{ page_list, page_size };
+}
+
+/// Assert that the PageList is in a valid state. This is a no-op in
+/// release builds.
+pub inline fn assertIntegrity(self: *const PageList) void {
+ if (comptime !build_options.slow_runtime_safety) return;
+
+ self.verifyIntegrity() catch |err| {
+ log.err("PageList integrity check failed: {}", .{err});
+ @panic("PageList integrity check failed");
+ };
+}
+
+/// Pause or resume integrity checks. This is useful when you're doing
+/// a multi-step operation that temporarily leaves the PageList in an
+/// inconsistent state.
+pub inline fn pauseIntegrityChecks(self: *PageList, pause: bool) void {
+ if (comptime !build_options.slow_runtime_safety) return;
+ if (pause) {
+ self.pause_integrity_checks += 1;
+ } else {
+ self.pause_integrity_checks -= 1;
+ }
+}
+
+const IntegrityError = error{
+ PageSerialInvalid,
+ TotalRowsMismatch,
+ TrackedPinInvalid,
+ ViewportPinOffsetMismatch,
+ ViewportPinInsufficientRows,
+};
+
+/// Verify the integrity of the PageList. This is expensive and should
+/// only be called in debug/test builds.
+fn verifyIntegrity(self: *const PageList) IntegrityError!void {
+ if (comptime !build_options.slow_runtime_safety) return;
+ if (self.pause_integrity_checks > 0) return;
+
+ // Our viewport pin should never be garbage
+ assert(!self.viewport_pin.garbage);
+
+ // Grab our total rows
+ var actual_total: usize = 0;
+ {
+ var node_ = self.pages.first;
+ while (node_) |node| {
+ actual_total += node.data.size.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) {
+ log.warn(
+ "PageList integrity violation: page serial too low serial={} min={}",
+ .{ node.serial, self.page_serial_min },
+ );
+ return IntegrityError.PageSerialInvalid;
+ }
+ }
+ }
+
+ // Verify that our cached total_rows matches the actual row count
+ if (actual_total != self.total_rows) {
+ log.warn(
+ "PageList integrity violation: total_rows mismatch cached={} actual={}",
+ .{ self.total_rows, actual_total },
+ );
+ return IntegrityError.TotalRowsMismatch;
+ }
+
+ // Verify that all our tracked pins point to valid pages.
+ for (self.tracked_pins.keys()) |p| {
+ if (!self.pinIsValid(p.*)) return error.TrackedPinInvalid;
+ }
+
+ if (self.viewport == .pin) {
+ // Verify that our viewport pin row offset is correct.
+ const actual_offset: usize = offset: {
+ var offset: usize = 0;
+ var node = self.pages.last;
+ while (node) |n| : (node = n.prev) {
+ offset += n.data.size.rows;
+ if (n == self.viewport_pin.node) {
+ offset -= self.viewport_pin.y;
+ break :offset self.total_rows - offset;
+ }
+ }
+
+ log.warn(
+ "PageList integrity violation: viewport pin not in list",
+ .{},
+ );
+ return error.ViewportPinOffsetMismatch;
+ };
+
+ if (self.viewport_pin_row_offset) |cached_offset| {
+ if (cached_offset != actual_offset) {
+ log.warn(
+ "PageList integrity violation: viewport pin offset mismatch cached={} actual={}",
+ .{ cached_offset, actual_offset },
+ );
+ return error.ViewportPinOffsetMismatch;
+ }
+ }
+
+ // Ensure our viewport has enough rows.
+ const rows = self.total_rows - actual_offset;
+ if (rows < self.rows) {
+ log.warn(
+ "PageList integrity violation: viewport pin rows too small rows={} needed={}",
+ .{ rows, self.rows },
+ );
+ return error.ViewportPinInsufficientRows;
+ }
+ }
+}
+
+/// Deinit the pagelist. If you own the memory pool (used clonePool) then
+/// this will reset the pool and retain capacity.
+pub fn deinit(self: *PageList) void {
+ // Verify integrity before cleanup
+ self.assertIntegrity();
+
+ // Always deallocate our hashmap.
+ self.tracked_pins.deinit(self.pool.alloc);
+
+ // Go through our linked list and deallocate all pages that are
+ // not standard size.
+ 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);
+ }
+ }
+
+ // Deallocate all the pages. We don't need to deallocate the list or
+ // nodes because they all reside in the pool.
+ self.pool.deinit();
+}
+
+/// Reset the PageList back to an empty state. This is similar to
+/// deinit and reinit but it importantly preserves the pointer
+/// stability of tracked pins (they're moved to the top-left since
+/// all contents are cleared).
+///
+/// This can't fail because we always retain at least enough allocated
+/// memory to fit the active area.
+pub fn reset(self: *PageList) void {
+ defer self.assertIntegrity();
+
+ // We need enough pages/nodes to keep our active area. This should
+ // never fail since we by definition have allocated a page already
+ // that fits our size but I'm not confident to make that assertion.
+ const cap = initialCapacity(self.cols);
+ assert(cap.rows > 0);
+
+ // The number of pages we need is the number of rows in the active
+ // area divided by the row capacity of a page.
+ const page_count = std.math.divCeil(
+ usize,
+ self.rows,
+ cap.rows,
+ ) catch unreachable;
+
+ // Before resetting our pools we need to free any pages that
+ // are non-standard size 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);
+ }
+ }
+ }
+
+ // Reset our pools to free as much memory as possible while retaining
+ // the capacity for at least the minimum number of pages we need.
+ // The return value is whether memory was reclaimed or not, but in
+ // either case the pool is left in a valid state.
+ _ = self.pool.pages.reset(.{
+ .retain_with_limit = page_count * PagePool.item_size,
+ });
+ _ = self.pool.nodes.reset(.{
+ .retain_with_limit = page_count * NodePool.item_size,
+ });
+
+ // Our page pool relies on mmap to zero our page memory. Since we're
+ // retaining a certain amount of memory, it won't use mmap and won't
+ // be zeroed. This block zeroes out all the memory in the pool arena.
+ {
+ // Note: we only have to do this for the page pool because the
+ // nodes are always fully overwritten on each allocation.
+ const page_arena = &self.pool.pages.arena;
+ var it = page_arena.state.buffer_list.first;
+ while (it) |node| : (it = node.next) {
+ // WARN: Since HeapAllocator's BufNode is not public API,
+ // we have to hardcode its layout here. We do a comptime assert
+ // on Zig version to verify we check it on every bump.
+ const BufNode = struct {
+ data: usize,
+ node: std.SinglyLinkedList.Node,
+ };
+ const buf_node: *BufNode = @fieldParentPtr("node", node);
+
+ // The fully allocated buffer
+ const alloc_buf = @as([*]u8, @ptrCast(buf_node))[0..buf_node.data];
+ // The buffer minus our header
+ const data_buf = alloc_buf[@sizeOf(BufNode)..];
+ @memset(data_buf, 0);
+ }
+ }
+
+ // Initialize our pages. This should not be able to fail since
+ // we retained the capacity for the minimum number of pages we need.
+ self.pages, self.page_size = initPages(
+ &self.pool,
+ &self.page_serial,
+ self.cols,
+ self.rows,
+ ) catch @panic("initPages failed");
+
+ // Our total rows always goes back to the default
+ self.total_rows = self.rows;
+
+ // Update all our tracked pins to point to our first page top-left
+ // and mark them as garbage, because it got mangled in a way where
+ // semantically it really doesn't make sense.
+ {
+ var it = self.tracked_pins.iterator();
+ while (it.next()) |entry| {
+ const p: *Pin = entry.key_ptr.*;
+ p.node = self.pages.first.?;
+ p.x = 0;
+ p.y = 0;
+ p.garbage = true;
+ }
+
+ // Our viewport pin is never garbage
+ self.viewport_pin.garbage = false;
+ }
+
+ // Move our viewport back to the active area since everything is gone.
+ self.viewport = .active;
+}
+
+pub const Clone = struct {
+ /// The top and bottom (inclusive) points of the region to clone.
+ /// The x coordinate is ignored; the full row is always cloned.
+ top: point.Point,
+ bot: ?point.Point = null,
+
+ // If this is non-null then cloning will attempt to remap the tracked
+ // pins into the new cloned area and will keep track of the old to
+ // new mapping in this map. If this is null, the cloned pagelist will
+ // not retain any previously tracked pins except those required for
+ // internal operations.
+ //
+ // Any pins not present in the map were not remapped.
+ tracked_pins: ?*TrackedPinsRemap = null,
+
+ pub const TrackedPinsRemap = std.AutoHashMap(*Pin, *Pin);
+};
+
+/// Clone this pagelist from the top to bottom (inclusive).
+///
+/// The viewport is always moved to the active area.
+///
+/// The cloned pagelist must contain at least enough rows for the active
+/// area. If the region specified has less rows than the active area then
+/// rows will be added to the bottom of the region to make up the difference.
+pub fn clone(
+ self: *const PageList,
+ alloc: Allocator,
+ opts: Clone,
+) !PageList {
+ var it = self.pageIterator(
+ .right_down,
+ opts.top,
+ opts.bot,
+ );
+
+ // First, count our pages so our preheat is exactly what we need.
+ var it_copy = it;
+ const page_count: usize = page_count: {
+ var count: usize = 0;
+ while (it_copy.next()) |_| count += 1;
+ break :page_count count;
+ };
+
+ // Setup our pool
+ var pool: MemoryPool = try .init(
+ alloc,
+ pageAllocator(),
+ page_count,
+ );
+ errdefer pool.deinit();
+
+ // Create our viewport. In a clone, the viewport always goes
+ // to the top.
+ const viewport_pin = try pool.pins.create();
+ var tracked_pins: PinSet = .{};
+ errdefer tracked_pins.deinit(pool.alloc);
+ try tracked_pins.putNoClobber(pool.alloc, viewport_pin, {});
+
+ // Our list of pages
+ var page_list: List = .{};
+ errdefer {
+ 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);
+ }
+ }
+ }
+
+ // Copy our pages
+ var page_serial: u64 = 0;
+ var total_rows: usize = 0;
+ var page_size: usize = 0;
+ while (it.next()) |chunk| {
+ // Clone the page. We have to use createPageExt here because
+ // we don't know if the source page has a standard size.
+ const node = try createPageExt(
+ &pool,
+ chunk.node.data.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,
+ chunk.start,
+ chunk.end,
+ );
+
+ node.data.dirty = chunk.node.data.dirty;
+
+ page_list.append(node);
+
+ total_rows += node.data.size.rows;
+
+ // Remap our tracked pins by changing the page and
+ // offsetting the Y position based on the chunk start.
+ if (opts.tracked_pins) |remap| {
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ // We're only interested in pins that were within the chunk.
+ if (p.node != chunk.node or
+ p.y < chunk.start or
+ p.y >= chunk.end) continue;
+ const new_p = try pool.pins.create();
+ new_p.* = p.*;
+ new_p.node = node;
+ new_p.y -= chunk.start;
+ try remap.putNoClobber(p, new_p);
+ try tracked_pins.putNoClobber(pool.alloc, new_p, {});
+ }
+ }
+ }
+
+ // Initialize our viewport pin to point to the first cloned page
+ // so it points to valid memory.
+ viewport_pin.* = .{ .node = page_list.first.? };
+
+ var result: PageList = .{
+ .pool = pool,
+ .pages = page_list,
+ .page_serial = page_serial,
+ .page_serial_min = 0,
+ .page_size = page_size,
+ .explicit_max_size = self.explicit_max_size,
+ .min_max_size = self.min_max_size,
+ .cols = self.cols,
+ .rows = self.rows,
+ .total_rows = total_rows,
+ .tracked_pins = tracked_pins,
+ .viewport = .{ .active = {} },
+ .viewport_pin = viewport_pin,
+ .viewport_pin_row_offset = null,
+ };
+
+ // We always need to have enough rows for our viewport because this is
+ // a pagelist invariant that other code relies on.
+ if (total_rows < self.rows) {
+ const len = self.rows - total_rows;
+ for (0..len) |_| {
+ _ = try result.grow();
+
+ // Clear the row. This is not very fast but in reality right
+ // 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);
+ }
+
+ // Update our total rows to be our row size.
+ result.total_rows = result.rows;
+ }
+
+ result.assertIntegrity();
+ return result;
+}
+
+/// Resize options
+pub const Resize = struct {
+ /// The new cols/cells of the screen.
+ cols: ?size.CellCountInt = null,
+ rows: ?size.CellCountInt = null,
+
+ /// Whether to reflow the text. If this is false then the text will
+ /// be truncated if the new size is smaller than the old size.
+ reflow: bool = true,
+
+ /// Set this to the current cursor position in the active area. Some
+ /// resize/reflow behavior depends on the cursor position.
+ cursor: ?Cursor = null,
+
+ pub const Cursor = struct {
+ x: size.CellCountInt,
+ y: size.CellCountInt,
+ };
+};
+
+/// Resize
+/// TODO: docs
+pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void {
+ defer self.assertIntegrity();
+
+ if (comptime std.debug.runtime_safety) {
+ // Resize does not work with 0 values, this should be protected
+ // upstream
+ if (opts.cols) |v| assert(v > 0);
+ if (opts.rows) |v| assert(v > 0);
+ }
+
+ // Resizing (especially with reflow) can cause our row offset to
+ // become invalid. Rather than do something fancy like we do other
+ // places and try to update it in place, we just invalidate it because
+ // its too easy to get the logic wrong in here.
+ self.viewport_pin_row_offset = null;
+
+ if (!opts.reflow) return try self.resizeWithoutReflow(opts);
+
+ // Recalculate our minimum max size. This allows grow to work properly
+ // when increasing beyond our initial minimum max size or explicit max
+ // size to fit the active area.
+ const old_min_max_size = self.min_max_size;
+ self.min_max_size = minMaxSize(
+ opts.cols orelse self.cols,
+ opts.rows orelse self.rows,
+ );
+ errdefer self.min_max_size = old_min_max_size;
+
+ // On reflow, the main thing that causes reflow is column changes. If
+ // only rows change, reflow is impossible. So we change our behavior based
+ // on the change of columns.
+ const cols = opts.cols orelse self.cols;
+ switch (std.math.order(cols, self.cols)) {
+ .eq => try self.resizeWithoutReflow(opts),
+
+ .gt => {
+ // We grow rows after cols so that we can do our unwrapping/reflow
+ // before we do a no-reflow grow.
+ try self.resizeCols(cols, opts.cursor);
+ try self.resizeWithoutReflow(opts);
+ },
+
+ .lt => {
+ // We first change our row count so that we have the proper amount
+ // we can use when shrinking our cols.
+ try self.resizeWithoutReflow(opts: {
+ var copy = opts;
+ copy.cols = self.cols;
+ break :opts copy;
+ });
+ try self.resizeCols(cols, opts.cursor);
+ },
+ }
+
+ // Various resize operations can change our total row count such
+ // that our viewport pin is now in the active area and has insufficient
+ // space. We need to check for this case and fix it up.
+ switch (self.viewport) {
+ .pin => if (self.pinIsActive(self.viewport_pin.*)) {
+ self.viewport = .active;
+ },
+ .active, .top => {},
+ }
+}
+
+/// Resize the pagelist with reflow by adding or removing columns.
+fn resizeCols(
+ self: *PageList,
+ cols: size.CellCountInt,
+ cursor: ?Resize.Cursor,
+) Allocator.Error!void {
+ assert(cols != self.cols);
+
+ // Update our cols. We have to do this early because grow() that we
+ // may call below relies on this to calculate the proper page size.
+ self.cols = cols;
+
+ // If we have a cursor position (x,y), then we try under any col resizing
+ // to keep the same number remaining active rows beneath it. This is a
+ // very special case if you can imagine clearing the screen (i.e.
+ // scrollClear), having an empty active area, and then resizing to less
+ // cols then we don't want the active area to "jump" to the bottom and
+ // pull down scrollback.
+ const preserved_cursor: ?struct {
+ tracked_pin: *Pin,
+ remaining_rows: usize,
+ wrapped_rows: usize,
+ } = if (cursor) |c| cursor: {
+ const p = self.pin(.{ .active = .{
+ .x = c.x,
+ .y = c.y,
+ } }) orelse break :cursor null;
+
+ const active_pin = self.pin(.{ .active = .{} });
+
+ // We count how many wraps the cursor had before it to begin with
+ // so that we can offset any additional wraps to avoid pushing the
+ // original row contents in to the scrollback.
+ const wrapped = wrapped: {
+ var wrapped: usize = 0;
+
+ var row_it = p.rowIterator(.left_up, active_pin);
+ while (row_it.next()) |next| {
+ const row = next.rowAndCell().row;
+ if (row.wrap_continuation) wrapped += 1;
+ }
+
+ break :wrapped wrapped;
+ };
+
+ break :cursor .{
+ .tracked_pin = try self.trackPin(p),
+ .remaining_rows = self.rows - c.y - 1,
+ .wrapped_rows = wrapped,
+ };
+ } else null;
+ defer if (preserved_cursor) |c| self.untrackPin(c.tracked_pin);
+
+ // Create the first node that contains our reflow.
+ const first_rewritten_node = node: {
+ const page = &self.pages.first.?.data;
+ const cap = page.capacity.adjust(
+ .{ .cols = cols },
+ ) catch |err| err: {
+ comptime assert(@TypeOf(err) == error{OutOfMemory});
+
+ // We verify all maxed out page layouts work.
+ var cap = page.capacity;
+ cap.cols = cols;
+
+ // We're growing columns so we can only get less rows so use
+ // the lesser of our capacity and size so we minimize wasted
+ // rows.
+ cap.rows = @min(page.size.rows, cap.rows);
+ break :err cap;
+ };
+
+ const node = try self.createPage(cap);
+ node.data.size.rows = 1;
+ break :node node;
+ };
+
+ // We need to grab our rowIterator now before we rewrite our
+ // linked list below.
+ var it = self.rowIterator(
+ .right_down,
+ .{ .screen = .{} },
+ null,
+ );
+ errdefer {
+ // If an error occurs, we're in a pretty disastrous broken state,
+ // but we should still try to clean up our leaked memory. Free
+ // any of the remaining orphaned pages from before. If we reflowed
+ // successfully this will be null.
+ var node_: ?*Node = if (it.chunk) |chunk| chunk.node else null;
+ while (node_) |node| {
+ node_ = node.next;
+ self.destroyNode(node);
+ }
+ }
+
+ // Set our new page as the only page. This orphans the existing pages
+ // in the list, but that's fine since we're gonna delete them anyway.
+ self.pages.first = first_rewritten_node;
+ self.pages.last = first_rewritten_node;
+
+ // Reflow all our rows.
+ {
+ var reflow_cursor: ReflowCursor = .init(first_rewritten_node);
+ while (it.next()) |row| {
+ try reflow_cursor.reflowRow(self, row);
+
+ // 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) {
+ self.destroyNode(row.node);
+ }
+ }
+
+ // At the end of the reflow, setup our total row cache
+ // log.warn("total old={} new={}", .{ self.total_rows, reflow_cursor.total_rows });
+ self.total_rows = reflow_cursor.total_rows;
+ }
+
+ // If our total rows is less than our active rows, we need to grow.
+ // This can happen if you're growing columns such that enough active
+ // rows unwrap that we no longer have enough.
+ var node_it = self.pages.first;
+ var total: usize = 0;
+ while (node_it) |node| : (node_it = node.next) {
+ total += node.data.size.rows;
+ if (total >= self.rows) break;
+ } else {
+ for (total..self.rows) |_| _ = try self.grow();
+ }
+
+ // See preserved_cursor setup for why.
+ if (preserved_cursor) |c| cursor: {
+ const active_pt = self.pointFromPin(
+ .active,
+ c.tracked_pin.*,
+ ) orelse break :cursor;
+
+ const active_pin = self.pin(.{ .active = .{} });
+
+ // We need to determine how many rows we wrapped from the original
+ // and subtract that from the remaining rows we expect because if
+ // we wrap down we don't want to push our original row contents into
+ // the scrollback.
+ const wrapped = wrapped: {
+ var wrapped: usize = 0;
+
+ var row_it = c.tracked_pin.rowIterator(.left_up, active_pin);
+ while (row_it.next()) |next| {
+ const row = next.rowAndCell().row;
+ if (row.wrap_continuation) wrapped += 1;
+ }
+
+ break :wrapped wrapped;
+ };
+
+ const current = self.rows - active_pt.active.y - 1;
+
+ var req_rows = c.remaining_rows;
+ req_rows -|= wrapped -| c.wrapped_rows;
+ req_rows -|= current;
+
+ while (req_rows > 0) {
+ _ = try self.grow();
+ req_rows -= 1;
+ }
+ }
+}
+
+// We use a cursor to track where we are in the src/dst. This is very
+// similar to Screen.Cursor, so see that for docs on individual fields.
+// We don't use a Screen because we don't need all the same data and we
+// do our best to optimize having direct access to the page memory.
+const ReflowCursor = struct {
+ x: size.CellCountInt,
+ y: size.CellCountInt,
+ pending_wrap: bool,
+ node: *List.Node,
+ page: *pagepkg.Page,
+ page_row: *pagepkg.Row,
+ page_cell: *pagepkg.Cell,
+ new_rows: usize,
+
+ /// This is the final row count of the reflowed pages.
+ total_rows: usize,
+
+ fn init(node: *List.Node) ReflowCursor {
+ const page = &node.data;
+ const rows = page.rows.ptr(page.memory);
+ return .{
+ .x = 0,
+ .y = 0,
+ .pending_wrap = false,
+ .node = node,
+ .page = page,
+ .page_row = &rows[0],
+ .page_cell = &rows[0].cells.ptr(page.memory)[0],
+ .new_rows = 0,
+
+ // Initially whatever size our input node is.
+ .total_rows = node.data.size.rows,
+ };
+ }
+
+ /// Reflow the provided row in to this cursor.
+ fn reflowRow(
+ self: *ReflowCursor,
+ list: *PageList,
+ row: Pin,
+ ) Allocator.Error!void {
+ const src_page: *Page = &row.node.data;
+ 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];
+
+ // Calculate the columns in this row. First up we trim non-semantic
+ // rightmost blanks.
+ var cols_len = src_page.size.cols;
+ if (!src_row.wrap) {
+ while (cols_len > 0) {
+ if (!cells[cols_len - 1].isEmpty()) break;
+ cols_len -= 1;
+ }
+
+ // If the row has a semantic prompt then the blank row is meaningful
+ // so we just consider pretend the first cell of the row isn't empty.
+ if (cols_len == 0 and src_row.semantic_prompt != .none) cols_len = 1;
+ }
+
+ // Handle tracked pin adjustments.
+ {
+ const pin_keys = list.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (&p.node.data != src_page or
+ p.y != src_y) continue;
+
+ // If this pin is in the blanks on the right and past the end
+ // of the dst col width then we move it to the end of the dst
+ // col width instead.
+ if (p.x >= cols_len) p.x = @min(
+ p.x,
+ self.page.size.cols - 1 - self.x,
+ );
+
+ // We increase our col len to at least include this pin.
+ // This ensures that blank rows with pins are processed,
+ // so that the pins can be properly remapped.
+ cols_len = @max(cols_len, p.x + 1);
+ }
+ }
+
+ // Defer processing of blank rows so that blank rows
+ // at the end of the page list are never written.
+ if (cols_len == 0) {
+ // If this blank row was a wrap continuation somehow
+ // then we won't need to write it since it should be
+ // a part of the previously written row.
+ if (!src_row.wrap_continuation) self.new_rows += 1;
+ return;
+ }
+
+ // Inherit increased styles or grapheme bytes from the src page
+ // we're reflowing from for new pages.
+ const cap = src_page.capacity.adjust(
+ .{ .cols = self.page.size.cols },
+ ) catch |err| err: {
+ comptime assert(@TypeOf(err) == error{OutOfMemory});
+
+ var cap = src_page.capacity;
+ cap.cols = self.page.size.cols;
+ // We're already a non-standard page. We don't want to
+ // inherit a massive set of rows, so cap it at our std size.
+ cap.rows = @min(src_page.size.rows, std_capacity.rows);
+ break :err cap;
+ };
+
+ // Our row isn't blank, write any new rows we deferred.
+ while (self.new_rows > 0) {
+ try self.cursorScrollOrNewPage(list, cap);
+ self.new_rows -= 1;
+ }
+
+ self.copyRowMetadata(src_row);
+
+ var x: usize = 0;
+ while (x < cols_len) {
+ if (self.pending_wrap) {
+ self.page_row.wrap = true;
+ try self.cursorScrollOrNewPage(list, cap);
+ self.copyRowMetadata(src_row);
+ self.page_row.wrap_continuation = true;
+ }
+
+ // Move any tracked pins from the source.
+ {
+ const pin_keys = list.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (&p.node.data != src_page or
+ p.y != src_y or
+ p.x != x) continue;
+
+ p.node = self.node;
+ p.x = self.x;
+ p.y = self.y;
+ }
+ }
+
+ if (self.writeCell(
+ list,
+ &cells[x],
+ src_page,
+ )) |result| switch (result) {
+ // Wrote the cell, move to the next.
+ .success => x += 1,
+
+ // Wrote the cell but request to skip the next so skip it.
+ // This is used for things like spacers.
+ .skip_next => {
+ // Remap any tracked pins at the skipped position (x+1)
+ // 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
+ p.y != src_y or
+ p.x != x + 1) continue;
+
+ p.node = self.node;
+ p.x = self.x;
+ p.y = self.y;
+ }
+
+ x += 2;
+ },
+
+ // Didn't write the cell, repeat writing this same cell.
+ .repeat => {},
+ } else |err| switch (err) {
+ // System out of memory, we can't fix this.
+ error.OutOfMemory => return error.OutOfMemory,
+
+ // We reached the capacity of a single page and can't
+ // add any more of some type of managed memory. When this
+ // happens we split out the current row we're working on
+ // into a new page and continue from there.
+ error.OutOfSpace => if (self.y == 0) {
+ // If we're already on the first-row, we can't split
+ // any further, so we just ignore bad cells and take
+ // corrupted (but valid) cell contents.
+ log.warn("reflowRow OutOfSpace on first row, discarding cell managed memory", .{});
+ x += 1;
+ self.cursorForward();
+ } else {
+ // Move our last row to a new page.
+ try self.moveLastRowToNewPage(list, cap);
+
+ // Do NOT increment x so that we retry writing
+ // the same existing cell.
+ },
+ }
+ }
+
+ // If the source row isn't wrapped then we should scroll afterwards.
+ if (!src_row.wrap) {
+ self.new_rows += 1;
+ }
+ }
+
+ /// Write a cell. On error, this will not unwrite the cell but
+ /// the cell may be incomplete (but valid). For example, if the source
+ /// cell is styled and we failed to allocate space for styles, the
+ /// written cell may not be styled but it is valid.
+ ///
+ /// The key failure to recognize for callers is when we can't increase
+ /// capacity in our destination page. In this case, the caller may want
+ /// to split the page at this row, rewrite the row into a new page
+ /// and continue from there.
+ ///
+ /// But this function guarantees the terminal/page will be in a
+ /// coherent state even on error.
+ fn writeCell(
+ self: *ReflowCursor,
+ list: *PageList,
+ cell: *const pagepkg.Cell,
+ src_page: *const Page,
+ ) IncreaseCapacityError!enum {
+ success,
+ repeat,
+ skip_next,
+ } {
+ // Initialize self.page_cell with basic, unmanaged memory contents.
+ {
+ // This must not fail because we want to make sure we atomically
+ // setup our page cell to be valid.
+ errdefer comptime unreachable;
+
+ // Copy cell contents.
+ switch (cell.content_tag) {
+ .codepoint,
+ .codepoint_grapheme,
+ => switch (cell.wide) {
+ .narrow => self.page_cell.* = cell.*,
+
+ .wide => if (self.page.size.cols > 1) {
+ if (self.x == self.page.size.cols - 1) {
+ // If there's a wide character in the last column of
+ // the reflowed page then we need to insert a spacer
+ // head and wrap before handling it.
+ self.page_cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_head,
+ };
+
+ // Move to the next row (this sets pending wrap
+ // which will cause us to wrap on the next
+ // iteration).
+ self.cursorForward();
+
+ // Decrement the source position so that when we
+ // loop we'll process this source cell again,
+ // since we can't copy it into a spacer head.
+ return .repeat;
+ } else {
+ self.page_cell.* = cell.*;
+ }
+ } else {
+ // Edge case, when resizing to 1 column, wide
+ // characters are just destroyed and replaced
+ // with empty narrow cells.
+ self.page_cell.content.codepoint = 0;
+ self.page_cell.wide = .narrow;
+ self.cursorForward();
+
+ // Skip spacer tail so it doesn't cause a wrap.
+ return .skip_next;
+ },
+
+ .spacer_tail => if (self.page.size.cols > 1) {
+ self.page_cell.* = cell.*;
+ } else {
+ // Edge case, when resizing to 1 column, wide
+ // characters are just destroyed and replaced
+ // with empty narrow cells, so we should just
+ // discard any spacer tails.
+ return .success;
+ },
+
+ .spacer_head => {
+ // Spacer heads should be ignored. If we need a
+ // spacer head in our reflowed page, it is added
+ // when processing the wide cell it belongs to.
+ return .success;
+ },
+ },
+
+ .bg_color_palette,
+ .bg_color_rgb,
+ => {
+ // These are guaranteed to have no style or grapheme
+ // data associated with them so we can fast path them.
+ self.page_cell.* = cell.*;
+ self.cursorForward();
+ return .success;
+ },
+ }
+
+ // These will create issues by trying to clone managed memory that
+ // isn't set if the current dst row needs to be moved to a new page.
+ // They'll be fixed once we do properly copy the relevant memory.
+ self.page_cell.content_tag = .codepoint;
+ self.page_cell.hyperlink = false;
+ self.page_cell.style_id = stylepkg.default_id;
+
+ if (comptime build_options.kitty_graphics) {
+ // Copy Kitty virtual placeholder status
+ if (cell.codepoint() == kitty.graphics.unicode.placeholder) {
+ self.page_row.kitty_virtual_placeholder = true;
+ }
+ }
+ }
+
+ // std.log.warn("\nsrc_y={} src_x={} dst_y={} dst_x={} dst_cols={} cp={X} wide={} page_cell_wide={}", .{
+ // src_y,
+ // x,
+ // self.y,
+ // self.x,
+ // self.page.size.cols,
+ // cell.content.codepoint,
+ // cell.wide,
+ // self.page_cell.wide,
+ // });
+
+ // From this point on we're moving on to failable, managed memory.
+ // If we reach an error, we do the minimal cleanup necessary to
+ // not leave dangling memory but otherwise we gracefully degrade
+ // into some functional but not strictly correct cell.
+
+ // Copy grapheme data.
+ if (cell.content_tag == .codepoint_grapheme) {
+ // Copy the graphemes
+ const cps = src_page.lookupGrapheme(cell).?;
+
+ // If our page can't support an additional cell
+ // with graphemes then we increase capacity.
+ if (self.page.graphemeCount() >= self.page.graphemeCapacity()) {
+ try self.increaseCapacity(
+ list,
+ .grapheme_bytes,
+ );
+ }
+
+ // Attempt to allocate the space that would be required
+ // for these graphemes, and if it's not available, then
+ // increase capacity. Keep trying until we succeed.
+ while (true) {
+ if (self.page.grapheme_alloc.alloc(
+ u21,
+ self.page.memory,
+ cps.len,
+ )) |slice| {
+ self.page.grapheme_alloc.free(
+ self.page.memory,
+ slice,
+ );
+ break;
+ } else |_| {
+ // Grow our capacity until we can fit the extra bytes.
+ try self.increaseCapacity(list, .grapheme_bytes);
+ }
+ }
+
+ self.page.setGraphemes(
+ self.page_row,
+ self.page_cell,
+ cps,
+ ) catch |err| {
+ // This shouldn't fail since we made sure we have space
+ // above. There is no reasonable behavior we can take here
+ // so we have a warn level log. This is ALMOST non-recoverable,
+ // though we choose to recover by corrupting the cell
+ // to a non-grapheme codepoint.
+ log.err("setGraphemes failed after capacity increase err={}", .{err});
+ if (comptime std.debug.runtime_safety) {
+ // Force a crash with safe builds.
+ unreachable;
+ }
+
+ // Unsafe builds we throw away grapheme data!
+ self.page_cell.content_tag = .codepoint;
+ self.page_cell.content = .{ .codepoint = 0xFFFD };
+ };
+ }
+
+ // Copy hyperlink data.
+ if (cell.hyperlink) hyperlink: {
+ const src_id = src_page.lookupHyperlink(cell).?;
+ const src_link = src_page.hyperlink_set.get(src_page.memory, src_id);
+
+ // If our page can't support an additional cell
+ // with a hyperlink then we increase capacity.
+ if (self.page.hyperlinkCount() >= self.page.hyperlinkCapacity()) {
+ try self.increaseCapacity(list, .hyperlink_bytes);
+ }
+
+ // Ensure that the string alloc has sufficient capacity
+ // to dupe the link (and the ID if it's not implicit).
+ const additional_required_string_capacity =
+ src_link.uri.len +
+ switch (src_link.id) {
+ .explicit => |v| v.len,
+ .implicit => 0,
+ };
+ // Keep trying until we have enough capacity.
+ while (true) {
+ if (self.page.string_alloc.alloc(
+ u8,
+ self.page.memory,
+ additional_required_string_capacity,
+ )) |slice| {
+ // We have enough capacity, free the test alloc.
+ self.page.string_alloc.free(
+ self.page.memory,
+ slice,
+ );
+ break;
+ } else |_| {
+ // Grow our capacity until we can fit the extra bytes.
+ try self.increaseCapacity(
+ list,
+ .string_bytes,
+ );
+ }
+ }
+
+ const dst_link = src_link.dupe(
+ src_page,
+ self.page,
+ ) catch |err| {
+ // This shouldn't fail since we did a capacity
+ // check above.
+ log.err("link dupe failed with capacity check err={}", .{err});
+ if (comptime std.debug.runtime_safety) {
+ // Force a crash with safe builds.
+ unreachable;
+ }
+
+ break :hyperlink;
+ };
+
+ const dst_id = self.page.hyperlink_set.addWithIdContext(
+ self.page.memory,
+ dst_link,
+ src_id,
+ .{ .page = self.page },
+ ) catch |err| id: {
+ // Always free our original link in case the increaseCap
+ // call fails so we aren't leaking memory.
+ dst_link.free(self.page);
+
+ // If the add failed then either the set needs to grow
+ // or it needs to be rehashed. Either one of those can
+ // be accomplished by increasing capacity, either with
+ // no actual change or with an increased hyperlink cap.
+ try self.increaseCapacity(list, switch (err) {
+ error.OutOfMemory => .hyperlink_bytes,
+ error.NeedsRehash => null,
+ });
+
+ // We need to recreate the link into the new page.
+ const dst_link2 = src_link.dupe(
+ src_page,
+ self.page,
+ ) catch |err2| {
+ // This shouldn't fail since we did a capacity
+ // check above.
+ log.err("link dupe failed with capacity check err={}", .{err2});
+ if (comptime std.debug.runtime_safety) {
+ // Force a crash with safe builds.
+ unreachable;
+ }
+
+ break :hyperlink;
+ };
+
+ // We assume this one will succeed. We dupe the link
+ // again, and don't have to worry about the other one
+ // because increasing the capacity naturally clears up
+ // any managed memory not associated with a cell yet.
+ break :id self.page.hyperlink_set.addWithIdContext(
+ self.page.memory,
+ dst_link2,
+ src_id,
+ .{ .page = self.page },
+ ) catch |err2| {
+ // This shouldn't happen since we increased capacity
+ // above so we handle it like the other similar
+ // cases and log it, crash in safe builds, and
+ // remove the hyperlink in unsafe builds.
+ log.err(
+ "addWithIdContext failed after capacity increase err={}",
+ .{err2},
+ );
+ if (comptime std.debug.runtime_safety) {
+ // Force a crash with safe builds.
+ unreachable;
+ }
+
+ dst_link2.free(self.page);
+ break :hyperlink;
+ };
+ } orelse src_id;
+
+ // We expect this to succeed due to the hyperlinkCapacity
+ // check we did before. If it doesn't succeed let's
+ // log it, crash (in safe builds), and clear our state.
+ self.page.setHyperlink(
+ self.page_row,
+ self.page_cell,
+ dst_id,
+ ) catch |err| {
+ log.err(
+ "setHyperlink failed after capacity increase err={}",
+ .{err},
+ );
+ if (comptime std.debug.runtime_safety) {
+ // Force a crash with safe builds.
+ unreachable;
+ }
+
+ // Unsafe builds we throw away hyperlink data!
+ self.page.hyperlink_set.release(self.page.memory, dst_id);
+ self.page_cell.hyperlink = false;
+ break :hyperlink;
+ };
+ }
+
+ // Copy style data.
+ if (cell.hasStyling()) style: {
+ const style = src_page.styles.get(
+ src_page.memory,
+ cell.style_id,
+ ).*;
+
+ const id = self.page.styles.addWithId(
+ self.page.memory,
+ style,
+ cell.style_id,
+ ) catch |err| id: {
+ // If the add failed then either the set needs to grow
+ // or it needs to be rehashed. Either one of those can
+ // be accomplished by increasing capacity, either with
+ // no actual change or with an increased style cap.
+ try self.increaseCapacity(list, switch (err) {
+ error.OutOfMemory => .styles,
+ error.NeedsRehash => null,
+ });
+
+ // We assume this one will succeed.
+ break :id self.page.styles.addWithId(
+ self.page.memory,
+ style,
+ cell.style_id,
+ ) catch |err2| {
+ // Should not fail since we just modified capacity
+ // above. Log it, crash in safe builds, clear style
+ // in unsafe builds.
+ log.err(
+ "addWithId failed after capacity increase err={}",
+ .{err2},
+ );
+ if (comptime std.debug.runtime_safety) {
+ // Force a crash with safe builds.
+ unreachable;
+ }
+
+ self.page_cell.style_id = stylepkg.default_id;
+ break :style;
+ };
+ } orelse cell.style_id;
+
+ self.page_row.styled = true;
+ self.page_cell.style_id = id;
+ }
+
+ self.cursorForward();
+ return .success;
+ }
+
+ /// Create a new page in the provided list with the provided
+ /// capacity then clone the row currently being worked on to
+ /// it and delete it from the old page. Places cursor in the
+ /// same position it was in in the old row in the new one.
+ ///
+ /// Asserts that the cursor is on the final row of the page.
+ ///
+ /// Expects that the provided capacity is sufficient to copy
+ /// the row.
+ ///
+ /// If this is the only row in the page, the page is removed
+ /// from the list after cloning the row.
+ fn moveLastRowToNewPage(
+ self: *ReflowCursor,
+ list: *PageList,
+ cap: Capacity,
+ ) Allocator.Error!void {
+ assert(self.y == self.page.size.rows - 1);
+ assert(!self.pending_wrap);
+
+ const old_node = self.node;
+ const old_page = self.page;
+ const old_row = self.page_row;
+ const old_x = self.x;
+
+ // Our total row count never changes, because we're removing one
+ // row from the last page and moving it into a new page.
+ const old_total_rows = self.total_rows;
+ defer self.total_rows = old_total_rows;
+
+ try self.cursorNewPage(list, cap);
+ assert(self.node != old_node);
+ assert(self.y == 0);
+
+ // We have no cleanup for our old state from here on out. No failures!
+ errdefer comptime unreachable;
+
+ // Restore the x position of the cursor.
+ self.cursorAbsolute(old_x, 0);
+
+ // Copy our old data. This should NOT fail because we have the
+ // capacity of the old page which already fits the data we requested.
+ self.page.cloneRowFrom(
+ old_page,
+ self.page_row,
+ old_row,
+ ) catch |err| {
+ log.err(
+ "error cloning single row for moveLastRowToNewPage err={}",
+ .{err},
+ );
+ @panic("unexpected copy row failure");
+ };
+
+ // Move any tracked pins from that last row into this new node.
+ {
+ const pin_keys = list.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (&p.node.data != old_page or
+ p.y != old_page.size.rows - 1) continue;
+
+ p.node = self.node;
+ p.y = self.y;
+ // p.x remains the same since we're copying the row as-is
+ }
+ }
+
+ // Clear the row from the old page and truncate it.
+ old_page.clearCells(old_row, 0, old_page.size.cols);
+ old_page.size.rows -= 1;
+
+ // If that was the last row in that page
+ // then we should remove it from the list.
+ if (old_page.size.rows == 0) {
+ list.pages.remove(old_node);
+ list.destroyNode(old_node);
+ }
+ }
+
+ /// Increase the capacity of the current page.
+ fn increaseCapacity(
+ self: *ReflowCursor,
+ list: *PageList,
+ adjustment: ?IncreaseCapacity,
+ ) IncreaseCapacityError!void {
+ const old_x = self.x;
+ const old_y = self.y;
+ const old_total_rows = self.total_rows;
+
+ const node = node: {
+ // Pause integrity checks because the total row count won't
+ // be correct during a reflow.
+ list.pauseIntegrityChecks(true);
+ defer list.pauseIntegrityChecks(false);
+ break :node try list.increaseCapacity(
+ self.node,
+ adjustment,
+ );
+ };
+ // We must not fail after this, we've modified our self.node
+ // and we need to fix it up.
+ errdefer comptime unreachable;
+
+ self.* = .init(node);
+ self.cursorAbsolute(old_x, old_y);
+ self.total_rows = old_total_rows;
+ }
+
+ /// True if this cursor is at the bottom of the page by capacity,
+ /// i.e. we can't scroll anymore.
+ fn bottom(self: *const ReflowCursor) bool {
+ return self.y == self.page.capacity.rows - 1;
+ }
+
+ fn cursorForward(self: *ReflowCursor) void {
+ if (self.x == self.page.size.cols - 1) {
+ self.pending_wrap = true;
+ } else {
+ const cell: [*]pagepkg.Cell = @ptrCast(self.page_cell);
+ self.page_cell = @ptrCast(cell + 1);
+ self.x += 1;
+ }
+ }
+
+ /// Create a new row and move the cursor down.
+ ///
+ /// Asserts that the cursor is on the bottom row of the
+ /// page and that there is capacity to add a new one.
+ fn cursorScroll(self: *ReflowCursor) void {
+ // Scrolling requires that we're on the bottom of our page.
+ // We also assert that we have capacity because reflow always
+ // works within the capacity of the page.
+ assert(self.y == self.page.size.rows - 1);
+ assert(self.page.size.rows < self.page.capacity.rows);
+
+ // Increase our page size
+ self.page.size.rows += 1;
+
+ // With the increased page size, safely move down a row.
+ const rows: [*]pagepkg.Row = @ptrCast(self.page_row);
+ const row: *pagepkg.Row = @ptrCast(rows + 1);
+ self.page_row = row;
+ self.page_cell = &row.cells.ptr(self.page.memory)[0];
+ self.pending_wrap = false;
+ self.x = 0;
+ self.y += 1;
+ }
+
+ /// Create a new page in the provided list with the provided
+ /// capacity and one row and move the cursor in to it at 0,0
+ fn cursorNewPage(
+ self: *ReflowCursor,
+ list: *PageList,
+ cap: Capacity,
+ ) Allocator.Error!void {
+ // Remember our new row count so we can restore it
+ // after reinitializing our cursor on the new page.
+ const new_rows = self.new_rows;
+
+ const node = try list.createPage(cap);
+ errdefer comptime unreachable;
+ node.data.size.rows = 1;
+ list.pages.insertAfter(self.node, node);
+
+ self.* = .init(node);
+ self.new_rows = new_rows;
+ }
+
+ /// Performs `cursorScroll` or `cursorNewPage` as necessary
+ /// depending on if the cursor is currently at the bottom.
+ fn cursorScrollOrNewPage(
+ self: *ReflowCursor,
+ list: *PageList,
+ cap: Capacity,
+ ) Allocator.Error!void {
+ // The functions below may overwrite self so we need to cache
+ // our total rows. We add one because no matter what when this
+ // returns we'll have one more row added.
+ const new_total_rows: usize = self.total_rows + 1;
+ defer self.total_rows = new_total_rows;
+
+ if (self.bottom()) {
+ try self.cursorNewPage(list, cap);
+ } else {
+ self.cursorScroll();
+ }
+ }
+
+ fn cursorAbsolute(
+ self: *ReflowCursor,
+ x: size.CellCountInt,
+ y: size.CellCountInt,
+ ) void {
+ assert(x < self.page.size.cols);
+ assert(y < self.page.size.rows);
+
+ const rows: [*]pagepkg.Row = @ptrCast(self.page_row);
+ const row: *pagepkg.Row = switch (std.math.order(y, self.y)) {
+ .eq => self.page_row,
+ .lt => @ptrCast(rows - (self.y - y)),
+ .gt => @ptrCast(rows + (y - self.y)),
+ };
+ self.page_row = row;
+ self.page_cell = &row.cells.ptr(self.page.memory)[x];
+ self.pending_wrap = false;
+ self.x = x;
+ self.y = y;
+ }
+
+ fn countTrailingEmptyCells(self: *const ReflowCursor) usize {
+ // If the row is wrapped, all empty cells are meaningful.
+ if (self.page_row.wrap) return 0;
+
+ const cells: [*]pagepkg.Cell = @ptrCast(self.page_cell);
+ const len: usize = self.page.size.cols - self.x;
+ for (0..len) |i| {
+ const rev_i = len - i - 1;
+ if (!cells[rev_i].isEmpty()) return i;
+ }
+
+ // If the row has a semantic prompt then the blank row is meaningful
+ // so we always return all but one so that the row is drawn.
+ if (self.page_row.semantic_prompt != .none) return len - 1;
+
+ return len;
+ }
+
+ fn copyRowMetadata(self: *ReflowCursor, other: *const Row) void {
+ self.page_row.semantic_prompt = other.semantic_prompt;
+ }
+};
+
+fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void {
+ // We only set the new min_max_size if we're not reflowing. If we are
+ // reflowing, then resize handles this for us.
+ const old_min_max_size = self.min_max_size;
+ self.min_max_size = if (!opts.reflow) minMaxSize(
+ opts.cols orelse self.cols,
+ opts.rows orelse self.rows,
+ ) else old_min_max_size;
+ errdefer self.min_max_size = old_min_max_size;
+
+ // Important! We have to do cols first because cols may cause us to
+ // destroy pages if we're increasing cols which will free up page_size
+ // so that when we call grow() in the row mods, we won't prune.
+ if (opts.cols) |cols| {
+ // Any column change without reflow should not result in row counts
+ // changing.
+ const old_total_rows = self.total_rows;
+ defer assert(self.total_rows == old_total_rows);
+
+ switch (std.math.order(cols, self.cols)) {
+ .eq => {},
+
+ // Making our columns smaller. We always have space for this
+ // in existing pages so we need to go through the pages,
+ // resize the columns, and clear any cells that are beyond
+ // the new size.
+ .lt => {
+ var it = self.pageIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |chunk| {
+ const page = &chunk.node.data;
+ defer page.assertIntegrity();
+ const rows = page.rows.ptr(page.memory);
+ for (0..page.size.rows) |i| {
+ const row = &rows[i];
+ page.clearCells(row, cols, self.cols);
+ }
+
+ page.size.cols = cols;
+ }
+
+ // Update all our tracked pins. If they have an X
+ // beyond the edge, clamp it.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.x >= cols) p.x = cols - 1;
+ }
+
+ self.cols = cols;
+ },
+
+ // Make our columns larger. This is a bit more complicated because
+ // pages may not have the capacity for this. If they don't have
+ // the capacity we need to allocate a new page and copy the data.
+ .gt => {
+ // See the comment in the while loop when setting self.cols
+ const old_cols = self.cols;
+
+ var it = self.pageIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |chunk| {
+ // We need to restore our old cols after we resize because
+ // we have an assertion on this and we want to be able to
+ // call this method multiple times.
+ self.cols = old_cols;
+ try self.resizeWithoutReflowGrowCols(cols, chunk);
+ }
+
+ self.cols = cols;
+ },
+ }
+ }
+
+ if (opts.rows) |rows| {
+ switch (std.math.order(rows, self.rows)) {
+ .eq => {},
+
+ // Making rows smaller, we simply change our rows value. Changing
+ // the row size doesn't affect anything else since max size and
+ // so on are all byte-based.
+ .lt => {
+ // If our rows are shrinking, we prefer to trim trailing
+ // blank lines from the active area instead of creating
+ // history if we can.
+ //
+ // This matches macOS Terminal.app behavior. I chose to match that
+ // behavior because it seemed fine in an ocean of differing behavior
+ // between terminal apps. I'm completely open to changing it as long
+ // as resize behavior isn't regressed in a user-hostile way.
+ const trimmed = self.trimTrailingBlankRows(self.rows - rows);
+
+ // Account for our trimmed rows in the total row cache
+ self.total_rows -= trimmed;
+
+ // If we didn't trim enough, just modify our row count and this
+ // will create additional history.
+ self.rows = rows;
+ },
+
+ // Making rows larger we adjust our row count, and then grow
+ // to the row count.
+ .gt => gt: {
+ // If our rows increased and our cursor is NOT at the bottom,
+ // we want to try to preserve the y value of the old cursor.
+ // In other words, we don't want to "pull down" scrollback.
+ // This is purely a UX feature.
+ if (opts.cursor) |cursor| cursor: {
+ if (cursor.y >= self.rows - 1) break :cursor;
+
+ // Cursor is not at the bottom, so we just grow our
+ // rows and we're done. Cursor does NOT change for this
+ // since we're not pulling down scrollback.
+ const delta = rows - self.rows;
+ self.rows = rows;
+ for (0..delta) |_| _ = try self.grow();
+ break :gt;
+ }
+
+ // This must be set BEFORE any calls to grow() so that
+ // grow() doesn't prune pages that we need for the active
+ // area.
+ self.rows = rows;
+
+ // Cursor is at the bottom or we don't care about cursors.
+ // In this case, if we have enough rows in our pages, we
+ // just update our rows and we're done. This effectively
+ // "pulls down" scrollback.
+ //
+ // 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;
+ if (count >= rows) break;
+ } else {
+ assert(count < rows);
+ for (count..rows) |_| _ = try self.grow();
+ }
+
+ // Make sure that the viewport pin isn't below the active
+ // area, since that will lead to all sorts of problems.
+ switch (self.viewport) {
+ .pin => if (self.pinIsActive(self.viewport_pin.*)) {
+ self.viewport = .active;
+ },
+ .active, .top => {},
+ }
+ },
+ }
+
+ if (build_options.slow_runtime_safety) {
+ // We never have less rows than our active screen has.
+ assert(self.totalRows() >= self.rows);
+ }
+ }
+}
+
+fn resizeWithoutReflowGrowCols(
+ self: *PageList,
+ cols: size.CellCountInt,
+ chunk: PageIterator.Chunk,
+) Allocator.Error!void {
+ assert(cols > self.cols);
+ const page = &chunk.node.data;
+
+ // Update our col count
+ const old_cols = self.cols;
+ self.cols = cols;
+ errdefer self.cols = old_cols;
+
+ // Unlikely fast path: we have capacity in the page. This
+ // is only true if we resized to less cols earlier.
+ if (page.capacity.cols >= cols) fast: {
+ // If any row has a spacer head at the old last column, it will
+ // be invalid at the new (wider) size. Fall through to the slow
+ // path which handles spacer heads correctly via cloneRowFrom.
+ const rows = page.rows.ptr(page.memory)[0..page.size.rows];
+ for (rows) |*row| {
+ const cells = page.getCells(row);
+ if (cells[old_cols - 1].wide == .spacer_head) break :fast;
+ }
+
+ page.size.cols = cols;
+ return;
+ }
+
+ // Likely slow path: we don't have capacity, so we need
+ // to allocate a page, and copy the old data into it.
+
+ // Try to fit our new column size into our existing page capacity.
+ // If that doesn't work then use a non-standard page with the
+ // given columns.
+ const cap = page.capacity.adjust(
+ .{ .cols = cols },
+ ) catch |err| err: {
+ comptime assert(@TypeOf(err) == error{OutOfMemory});
+
+ // We verify all maxed out page layouts don't overflow,
+ var cap = page.capacity;
+ cap.cols = cols;
+
+ // We're growing columns so we can only get less rows so use
+ // the lesser of our capacity and size so we minimize wasted
+ // rows.
+ cap.rows = @min(page.size.rows, cap.rows);
+ break :err cap;
+ };
+
+ // On error, we need to undo all the pages we've added.
+ const prev = chunk.node.prev;
+ errdefer {
+ var current = chunk.node.prev;
+ while (current) |p| {
+ if (current == prev) break;
+ current = p.prev;
+ self.pages.remove(p);
+ self.destroyNode(p);
+ }
+ }
+
+ // Keeps track of all our copied rows. Assertions at the end is that
+ // we copied exactly our page size.
+ var copied: size.CellCountInt = 0;
+
+ // This function has an unfortunate side effect in that it causes memory
+ // fragmentation on rows if the columns are increasing in a way that
+ // shrinks capacity rows. If we have pages that don't divide evenly then
+ // we end up creating a final page that is not using its full capacity.
+ // If this chunk isn't the last chunk in the page list, then we've created
+ // a page where we'll never reclaim that capacity. This makes our max size
+ // calculation incorrect since we'll throw away data even though we have
+ // excess capacity. To avoid this, we try to fill our previous page
+ // first if it has capacity.
+ //
+ // 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;
+
+ // We only want scenarios where we have excess capacity.
+ if (prev_page.size.rows >= prev_page.capacity.rows) break :prev;
+
+ // We can copy as much as we can to fill the capacity or our
+ // current page size.
+ const len = @min(
+ prev_page.capacity.rows - prev_page.size.rows,
+ page.size.rows,
+ );
+
+ const src_rows = page.rows.ptr(page.memory)[0..len];
+ const dst_rows = prev_page.rows.ptr(prev_page.memory)[prev_page.size.rows..];
+ for (dst_rows, src_rows) |*dst_row, *src_row| {
+ prev_page.size.rows += 1;
+ copied += 1;
+ prev_page.cloneRowFrom(
+ page,
+ dst_row,
+ src_row,
+ ) catch {
+ // If an error happens, we undo our row copy and break out
+ // into creating a new page.
+ prev_page.size.rows -= 1;
+ copied -= 1;
+ break :prev;
+ };
+ }
+
+ assert(copied == len);
+ assert(prev_page.size.rows <= prev_page.capacity.rows);
+
+ // Remap any tracked pins that pointed to rows we just copied to prev.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != chunk.node or p.y >= len) continue;
+ p.node = prev_node;
+ p.y += prev_page.size.rows - len;
+ }
+ }
+
+ // 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_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(
+ row,
+ 0,
+ prev_page.size.cols,
+ );
+ prev_page.size.rows = prev_size;
+ };
+
+ // We delete any of the nodes we added.
+ errdefer {
+ var it = chunk.node.prev;
+ while (it) |node| {
+ if (node == prev) break;
+ it = node.prev;
+ self.pages.remove(node);
+ self.destroyNode(node);
+ }
+ }
+
+ // 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();
+
+ // 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.
+ const len = @min(cap.rows, page.size.rows - copied);
+
+ // 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];
+ for (dst_rows, src_rows) |*dst_row, *src_row| {
+ new_node.data.size.rows += 1;
+ if (new_node.data.cloneRowFrom(
+ page,
+ dst_row,
+ src_row,
+ )) |_| {
+ copied += 1;
+ } else |err| {
+ // I don't THINK this should be possible, because while our
+ // row count may diminish due to the adjustment, our
+ // prior capacity should have been sufficient to hold all the
+ // managed memory.
+ log.warn(
+ "unexpected cloneRowFrom failure during resizeWithoutReflowGrowCols: {}",
+ .{err},
+ );
+
+ // We can actually safely handle this though by exiting
+ // this loop early and cutting our copy short.
+ new_node.data.size.rows -= 1;
+ break;
+ }
+ }
+ const y_end = copied;
+
+ // Insert our new page
+ self.pages.insertBefore(chunk.node, new_node);
+
+ // Update our tracked pins that pointed to this previous page.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != chunk.node or
+ p.y < y_start or
+ p.y >= y_end) continue;
+ p.node = new_node;
+ p.y -= y_start;
+ }
+ }
+ assert(copied == page.size.rows);
+
+ // Our prior errdeferes are invalid after this point so ensure
+ // we don't have any more errors.
+ errdefer comptime unreachable;
+
+ // Remove the old page.
+ // Deallocate the old page.
+ self.pages.remove(chunk.node);
+ self.destroyNode(chunk.node);
+}
+
+/// Returns the number of trailing blank lines, not to exceed max. Max
+/// is used to limit our traversal in the case of large scrollback.
+fn trailingBlankLines(
+ self: *const PageList,
+ max: size.CellCountInt,
+) size.CellCountInt {
+ var count: size.CellCountInt = 0;
+
+ // 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];
+ 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];
+
+ // If the row has any text then we're done.
+ if (pagepkg.Cell.hasTextAny(cells)) return count;
+
+ // Inc count, if we're beyond max then we're done.
+ count += 1;
+ if (count >= max) return count;
+ }
+ }
+
+ return count;
+}
+
+/// Trims up to max trailing blank rows from the pagelist and returns the
+/// number of rows trimmed. A blank row is any row with no text (but may
+/// have styling).
+///
+/// IMPORTANT: This function does NOT update `total_rows`. It returns the
+/// number of rows trimmed, and the caller is responsible for decrementing
+/// `total_rows` by this amount.
+fn trimTrailingBlankRows(
+ self: *PageList,
+ max: size.CellCountInt,
+) size.CellCountInt {
+ var trimmed: size.CellCountInt = 0;
+ const bl_pin = self.getBottomRight(.screen).?;
+ var it = bl_pin.rowIterator(.left_up, null);
+ while (it.next()) |row_pin| {
+ const cells = row_pin.cells(.all);
+
+ // If the row has any text then we're done.
+ if (pagepkg.Cell.hasTextAny(cells)) return trimmed;
+
+ // If our tracked pins are in this row then we cannot trim it
+ // because it implies some sort of importance. If we trimmed this
+ // we'd invalidate this pin, as well.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != row_pin.node or
+ p.y != row_pin.y) continue;
+ return trimmed;
+ }
+
+ // 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) {
+ self.erasePage(row_pin.node);
+ } else {
+ row_pin.node.data.assertIntegrity();
+ }
+
+ trimmed += 1;
+ if (trimmed >= max) return trimmed;
+ }
+
+ return trimmed;
+}
+
+/// Scroll options.
+pub const Scroll = union(enum) {
+ /// Scroll to the active area. This is also sometimes referred to as
+ /// the "bottom" of the screen. This makes it so that the end of the
+ /// screen is fully visible since the active area is the bottom
+ /// rows/cols of the screen.
+ active,
+
+ /// Scroll to the top of the screen, which is the farthest back in
+ /// the scrollback history.
+ top,
+
+ /// Scroll to the given absolute row from the top. A value of zero
+ /// is the top row. This row will be the first visible row in the viewport.
+ /// Scrolling into or below the active area will clamp to the active area.
+ row: usize,
+
+ /// Scroll up (negative) or down (positive) by the given number of
+ /// rows. This is clamped to the "top" and "active" top left.
+ delta_row: isize,
+
+ /// Jump forwards (positive) or backwards (negative) a set number of
+ /// prompts. If the absolute value is greater than the number of prompts
+ /// in either direction, jump to the furthest prompt in that direction.
+ delta_prompt: isize,
+
+ /// Scroll directly to a specific pin in the page. This will be set
+ /// as the top left of the viewport (ignoring the pin x value).
+ pin: Pin,
+};
+
+/// Scroll the viewport. This will never create new scrollback, allocate
+/// pages, etc. This can only be used to move the viewport within the
+/// previously allocated pages.
+pub fn scroll(self: *PageList, behavior: Scroll) void {
+ defer self.assertIntegrity();
+
+ // Special case no-scrollback mode to never allow scrolling.
+ if (self.explicit_max_size == 0) {
+ self.viewport = .active;
+ return;
+ }
+
+ switch (behavior) {
+ .active => self.viewport = .active,
+ .top => self.viewport = .top,
+ .pin => |p| {
+ if (self.pinIsActive(p)) {
+ self.viewport = .active;
+ return;
+ } else if (self.pinIsTop(p)) {
+ self.viewport = .top;
+ return;
+ }
+
+ self.viewport_pin.* = p;
+ self.viewport = .pin;
+ self.viewport_pin_row_offset = null; // invalidate cache
+ },
+ .row => |n| row: {
+ // If we're at the top, pin the top.
+ if (n == 0) {
+ self.viewport = .top;
+ break :row;
+ }
+
+ // If we're below the top of the active area, pin the active area.
+ if (n >= self.total_rows - self.rows) {
+ self.viewport = .active;
+ break :row;
+ }
+
+ // See if there are any other faster paths we can take.
+ switch (self.viewport) {
+ .top, .active => {},
+ .pin => if (self.viewport_pin_row_offset) |*v| {
+ // If we have a pin and we already calculated a row offset,
+ // then we can efficiently calculate the delta and move
+ // that much from that pin.
+ const delta: isize = delta: {
+ const n_isize: isize = @intCast(n);
+ const v_isize: isize = @intCast(v.*);
+ break :delta n_isize - v_isize;
+ };
+ self.scroll(.{ .delta_row = delta });
+ return;
+ },
+ }
+
+ // We have an accurate row offset so store it to prevent
+ // calculating this again.
+ self.viewport_pin_row_offset = n;
+ self.viewport = .pin;
+
+ // Slow path, we've just got to traverse the linked list and
+ // get to our row. As a slight speedup, let's pick the traversal
+ // that's likely faster based on our absolute row and total rows.
+ const midpoint = self.total_rows / 2;
+ 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;
+ };
+ while (node_it) |node| : (node_it = node.next) {
+ if (rem < node.data.size.rows) {
+ self.viewport_pin.* = .{
+ .node = node,
+ .y = rem,
+ };
+ break :row;
+ }
+
+ rem -= node.data.size.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;
+ };
+ while (node_it) |node| : (node_it = node.prev) {
+ if (rem <= node.data.size.rows) {
+ self.viewport_pin.* = .{
+ .node = node,
+ .y = node.data.size.rows - rem,
+ };
+ break :row;
+ }
+
+ rem -= node.data.size.rows;
+ }
+ }
+
+ // If we reached here, then we couldn't find the offset.
+ // This feels impossible? Just clamp to active, screw it lol.
+ self.viewport = .active;
+ },
+ .delta_prompt => |n| self.scrollPrompt(n),
+ .delta_row => |n| delta_row: {
+ 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.
+ .top => if (n <= 0) break :delta_row,
+
+ // If we're at active and we're scrolling forwards, we don't
+ // have to do anything because it'll result in staying in
+ // the active.
+ .active => if (n >= 0) break :delta_row,
+
+ // If we're already a pin type, then we can fast-path our
+ // delta by simply moving the pin. This has the added benefit
+ // that we can update our row offset cache efficiently, too.
+ .pin => switch (std.math.order(n, 0)) {
+ .eq => break :delta_row,
+
+ .lt => switch (self.viewport_pin.upOverflow(@intCast(-n))) {
+ .offset => |new_pin| {
+ self.viewport_pin.* = new_pin;
+ if (self.viewport_pin_row_offset) |*v| {
+ v.* -= @as(usize, @intCast(-n));
+ }
+ break :delta_row;
+ },
+
+ // If we overflow up we're at the top.
+ .overflow => {
+ self.viewport = .top;
+ break :delta_row;
+ },
+ },
+
+ .gt => switch (self.viewport_pin.downOverflow(@intCast(n))) {
+ // If we offset its a valid pin but we still have to
+ // check if we're in the active area.
+ .offset => |new_pin| {
+ if (self.pinIsActive(new_pin)) {
+ self.viewport = .active;
+ } else {
+ self.viewport_pin.* = new_pin;
+ if (self.viewport_pin_row_offset) |*v| {
+ v.* += @intCast(n);
+ }
+ }
+ break :delta_row;
+ },
+
+ // If we overflow down we're at active.
+ .overflow => {
+ self.viewport = .active;
+ break :delta_row;
+ },
+ },
+ },
+ }
+
+ // 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))) {
+ .offset => |v| v,
+ .overflow => |v| v.end,
+ } else switch (top.downOverflow(@intCast(n))) {
+ .offset => |v| v,
+ .overflow => |v| v.end,
+ };
+
+ // If we are still within the active area, then we pin the
+ // viewport to active. This isn't EXACTLY the same behavior as
+ // other scrolling because normally when you scroll the viewport
+ // is pinned to _that row_ even if new scrollback is created.
+ // But in a terminal when you get to the bottom and back into the
+ // active area, you usually expect that the viewport will now
+ // follow the active area.
+ if (self.pinIsActive(p)) {
+ self.viewport = .active;
+ return;
+ }
+
+ // If we're at the top, then just set the top. This is a lot
+ // more efficient everywhere. We must check this after the
+ // active check above because we prefer active if they overlap.
+ if (self.pinIsTop(p)) {
+ self.viewport = .top;
+ return;
+ }
+
+ // Pin is not active so we need to track it.
+ self.viewport_pin.* = p;
+ self.viewport = .pin;
+ self.viewport_pin_row_offset = null; // invalidate cache
+ },
+ }
+}
+
+/// Jump the viewport forwards (positive) or backwards (negative) a set number of
+/// prompts (delta).
+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);
+ var delta_rem: usize = delta_start;
+
+ // We start at the row before or after our viewport depending on the
+ // delta so that we don't land back on our current viewport.
+ const start_pin = start: {
+ const tl = self.getTopLeft(.viewport);
+
+ // If we're moving up we can just move the viewport up because
+ // promptIterator handles jumpting to the start of prompts.
+ if (delta <= 0) break :start tl.up(1) orelse return;
+
+ // If we're moving down and we're presently at some kind of
+ // prompt, we need to skip all the continuation lines because
+ // promptIterator can't know if we're cutoff or continuing.
+ var adjusted: Pin = tl.down(1) orelse return;
+ if (tl.rowAndCell().row.semantic_prompt != .none) skip: {
+ while (adjusted.rowAndCell().row.semantic_prompt == .prompt_continuation) {
+ adjusted = adjusted.down(1) orelse break :skip;
+ }
+ }
+
+ break :start adjusted;
+ };
+
+ // Go through prompts delta times
+ var it = start_pin.promptIterator(
+ if (delta > 0) .right_down else .left_up,
+ null,
+ );
+ var prompt_pin: ?Pin = null;
+ while (it.next()) |next| {
+ prompt_pin = next;
+ delta_rem -= 1;
+ if (delta_rem == 0) break;
+ }
+
+ // If we found a prompt, we move to it. If the prompt is in the active
+ // area we keep our viewport as active because we can't scroll DOWN
+ // into the active area. Otherwise, we scroll up to the pin.
+ if (prompt_pin) |p| {
+ if (self.pinIsActive(p)) {
+ self.viewport = .active;
+ } else {
+ self.viewport_pin.* = p;
+ self.viewport = .pin;
+ self.viewport_pin_row_offset = null; // invalidate cache
+ }
+ }
+}
+
+/// Clear the screen by scrolling written contents up into the scrollback.
+/// This will not update the viewport.
+pub fn scrollClear(self: *PageList) Allocator.Error!void {
+ defer self.assertIntegrity();
+
+ // Go through the active area backwards to find the first non-empty
+ // row. We use this to determine how many rows to scroll up.
+ const non_empty: usize = non_empty: {
+ 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 row = rows[rev_i];
+ const cells = row.cells.ptr(page.data.memory)[0..self.cols];
+ for (cells) |cell| {
+ if (!cell.isEmpty()) break :non_empty self.rows - n;
+ }
+
+ n += 1;
+ if (n > self.rows) break :non_empty 0;
+ }
+
+ page = page.prev orelse break :non_empty 0;
+ }
+ };
+
+ // Scroll
+ for (0..non_empty) |_| _ = try self.grow();
+}
+
+/// 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
+/// 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.
+///
+/// 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;
+
+ // 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;
+ if (new_size >= old_size) return null;
+
+ // Create the new smaller page
+ const new_node = try self.createPage(req_cap);
+ errdefer self.destroyNode(new_node);
+ const new_page: *Page = &new_node.data;
+ new_page.size = page.size;
+ new_page.dirty = page.dirty;
+ new_page.cloneFrom(
+ page,
+ 0,
+ page.size.rows,
+ ) catch |err| {
+ // cloneFrom should not fail when compacting since req_cap is
+ // computed to exactly fit the source content and our expectation
+ // of exactRowCapacity ensures it can fit all the requested
+ // data.
+ log.err("compact clone failed err={}", .{err});
+
+ // In this case, let's gracefully degrade by pretending we
+ // didn't need to compact.
+ self.destroyNode(new_node);
+ return null;
+ };
+
+ // Fix up all tracked pins to point to the new page
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != node) continue;
+ p.node = new_node;
+ }
+
+ // Insert the new page and destroy the old one
+ self.pages.insertBefore(node, new_node);
+ self.pages.remove(node);
+ self.destroyNode(node);
+
+ new_page.assertIntegrity();
+ return new_node;
+}
+
+pub const SplitError = error{
+ // Allocator OOM
+ OutOfMemory,
+ // Page can't be split further because it is already a single row.
+ OutOfSpace,
+};
+
+/// Split the given node in the PageList at the given pin.
+///
+/// The row at the pin and after will be moved into a new page with
+/// the same capacity as the original page. Alternatively, you can "split
+/// above" by splitting the row following the desired split row.
+///
+/// Since the split happens below the pin, the pin remains valid.
+pub fn split(
+ self: *PageList,
+ p: Pin,
+) SplitError!void {
+ if (build_options.slow_runtime_safety) assert(self.pinIsValid(p));
+
+ // Ran into a bug that I can only explain via aliasing. If a tracked
+ // pin is passed in, its possible Zig will alias the memory and then
+ // 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;
+
+ // 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
+ // seems crazy and the rest of our PageList can't handle heterogeneously
+ // sized pages today.
+ if (page.size.rows <= 1) return error.OutOfSpace;
+
+ // Splitting at row 0 is a no-op since there's nothing before the split point.
+ if (p.y == 0) return;
+
+ // At this point we're doing actual modification so make sure
+ // on the return that we're good.
+ defer self.assertIntegrity();
+
+ // Create a new node with the same capacity of managed memory.
+ const target = try self.createPage(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);
+
+ // 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| {
+ log.err(
+ "error cloning rows for split err={}",
+ .{err},
+ );
+
+ // Rather than crash, we return an OutOfSpace to show that
+ // we couldn't split and let our callers gracefully handle it.
+ // Realistically though... this should not happen.
+ return error.OutOfSpace;
+ };
+
+ // From this point forward there is no going back. We have no
+ // error handling. It is possible but we haven't written it.
+ errdefer comptime unreachable;
+
+ // Move any tracked pins from the copied rows
+ for (self.tracked_pins.keys()) |tracked| {
+ if (&tracked.node.data != page or
+ tracked.y < p.y) continue;
+
+ tracked.node = target;
+ tracked.y -= p.y;
+ // p.x remains the same since we're copying the row as-is
+ }
+
+ // Clear our rows
+ for (page.rows.ptr(page.memory)[y_start..y_end]) |*row| {
+ page.clearCells(
+ row,
+ 0,
+ page.size.cols,
+ );
+ }
+ page.size.rows -= y_end - y_start;
+
+ self.pages.insertAfter(original_node, target);
+}
+
+/// This represents the state necessary to render a scrollbar for this
+/// PageList. It has the total size, the offset, and the size of the viewport.
+pub const Scrollbar = struct {
+ /// Total size of the scrollable area.
+ total: usize,
+
+ /// The offset into the total area that the viewport is at. This is
+ /// guaranteed to be less than or equal to total. This includes the
+ /// visible row.
+ offset: usize,
+
+ /// The length of the visible area. This is including the offset row.
+ len: usize,
+
+ /// A zero-sized scrollable region.
+ pub const zero: Scrollbar = .{
+ .total = 0,
+ .offset = 0,
+ .len = 0,
+ };
+
+ // Sync with: ghostty_action_scrollbar_s
+ pub const C = extern struct {
+ total: u64,
+ offset: u64,
+ len: u64,
+ };
+
+ pub fn cval(self: Scrollbar) C {
+ return .{
+ .total = @intCast(self.total),
+ .offset = @intCast(self.offset),
+ .len = @intCast(self.len),
+ };
+ }
+
+ /// Comparison for scrollbars.
+ pub fn eql(self: Scrollbar, other: Scrollbar) bool {
+ return self.total == other.total and
+ self.offset == other.offset and
+ self.len == other.len;
+ }
+};
+
+/// 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.
+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
+ // it always has SOME extra space (due to the way we allocate by page).
+ // So even with no scrollback we have some growth. It is architecturally
+ // much simpler to just hide that for no-scrollback cases.
+ if (self.explicit_max_size == 0) return .{
+ .total = self.rows,
+ .offset = 0,
+ .len = self.rows,
+ };
+
+ return .{
+ .total = self.total_rows,
+ .offset = self.viewportRowOffset(),
+ .len = self.rows, // Length is always rows
+ };
+}
+
+/// Returns the offset of the current viewport from the top of the
+/// screen.
+///
+/// This is potentially expensive to calculate because if the viewport
+/// is a pin and the pin is near the beginning of the scrollback, we
+/// will traverse a lot of linked list nodes.
+///
+/// The result is cached so repeated calls are cheap.
+fn viewportRowOffset(self: *PageList) usize {
+ return switch (self.viewport) {
+ .top => 0,
+ .active => self.total_rows - self.rows,
+ .pin => pin: {
+ // We assert integrity on this code path because it verifies
+ // that the cached value is correct.
+ defer self.assertIntegrity();
+
+ // Return cached value if available
+ if (self.viewport_pin_row_offset) |cached| break :pin cached;
+
+ // Traverse from the end and count rows until we reach the
+ // viewport pin. We count backwards because most of the time
+ // a user is scrolling near the active area.
+ const top_offset: usize = offset: {
+ var offset: usize = 0;
+ var node = self.pages.last;
+ while (node) |n| : (node = n.prev) {
+ offset += n.data.size.rows;
+ if (n == self.viewport_pin.node) {
+ assert(n.data.size.rows > self.viewport_pin.y);
+ offset -= self.viewport_pin.y;
+ break :offset self.total_rows - offset;
+ }
+ }
+
+ // Invalid pins are not possible.
+ unreachable;
+ };
+
+ // The offset is from the bottom and our cached value and this
+ // function returns from the top, so we need to invert it.
+ self.viewport_pin_row_offset = top_offset;
+ break :pin top_offset;
+ },
+ };
+}
+
+/// This fixes up the viewport data when rows are removed from the
+/// PageList. This will update a viewport to `active` if row removal
+/// puts the viewport into the active area, to `top` if the viewport
+/// is now at row 0, and updates any row offset caches as necessary.
+///
+/// This is unit tested transitively through other tests such as
+/// eraseRows.
+fn fixupViewport(
+ self: *PageList,
+ removed: usize,
+) void {
+ switch (self.viewport) {
+ .active => {},
+
+ // For pin, we check if our pin is now in the active area and if so
+ // we move our viewport back to the active area.
+ .pin => if (self.pinIsActive(self.viewport_pin.*)) {
+ self.viewport = .active;
+ } else if (self.viewport_pin_row_offset) |*v| {
+ // If we have a cached row offset, we need to update it
+ // to account for the erased rows.
+ if (v.* < removed) {
+ self.viewport = .top;
+ } else {
+ v.* -= removed;
+ }
+ },
+
+ // For top, we move back to active if our erasing moved our
+ // top page into the active area.
+ .top => if (self.pinIsActive(.{ .node = self.pages.first.? })) {
+ self.viewport = .active;
+ },
+ }
+}
+
+/// Returns the actual max size. This may be greater than the explicit
+/// value if the explicit value is less than the min_max_size.
+///
+/// This value is a HEURISTIC. You cannot assert on this value. We may
+/// exceed this value if required to fit the active area. This may be
+/// required in some cases if the active area has a large number of
+/// graphemes, styles, etc.
+pub fn maxSize(self: *const PageList) usize {
+ return @max(self.explicit_max_size, self.min_max_size);
+}
+
+/// Grow the active area by exactly one row.
+///
+/// This may allocate, but also may not if our current page has more
+/// capacity we can use. This will prune scrollback if necessary to
+/// adhere to max_size.
+///
+/// This returns the newly allocated page node if there is one.
+pub fn grow(self: *PageList) Allocator.Error!?*List.Node {
+ defer self.assertIntegrity();
+
+ const last = self.pages.last.?;
+ if (last.data.capacity.rows > last.data.size.rows) {
+ // Fast path: we have capacity in the last page.
+ last.data.size.rows += 1;
+ last.data.assertIntegrity();
+
+ // Increase our total rows by one
+ self.total_rows += 1;
+
+ return null;
+ }
+
+ // Slower path: we have no space, we need to allocate a new page.
+
+ // Get the layout first so our failable work is done early.
+ // We'll need this for both paths.
+ const cap = initialCapacity(self.cols);
+
+ // If allocation would exceed our max size, we prune the first page.
+ // We don't need to reallocate because we can simply reuse that first
+ // page.
+ //
+ // We only take this path if we have more than one page since pruning
+ // reuses the popped page. It is possible to have a single page and
+ // exceed the max size if that page was adjusted to be larger after
+ // initial allocation.
+ if (self.pages.first != null and
+ self.pages.first != self.pages.last and
+ self.page_size + PagePool.item_size > self.maxSize())
+ prune: {
+ const first = self.pages.popFirst().?;
+ assert(first != last);
+
+ // Decrease our total row count from the pruned page
+ self.total_rows -= first.data.size.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
+ // more row below.
+ if (self.total_rows + 1 < self.rows) {
+ self.pages.prepend(first);
+ assert(self.pages.first == first);
+ self.total_rows += first.data.size.rows;
+ break :prune;
+ }
+
+ // If we have a pin viewport cache then we need to update it.
+ if (self.viewport == .pin) viewport: {
+ 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) {
+ 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;
+ }
+ }
+
+ // Update any tracked pins that point to this page to point to the
+ // new first page to the top-left, and mark them as garbage.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != first) continue;
+ p.node = self.pages.first.?;
+ p.y = 0;
+ p.x = 0;
+ p.garbage = true;
+ }
+ 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;
+ }
+
+ // Reset our memory
+ const buf = first.data.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;
+ 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;
+ 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();
+ return first;
+ }
+
+ // We need to allocate a new memory buffer.
+ const next_node = try self.createPage(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;
+
+ // We should never be more than our max size here because we've
+ // verified the case above.
+ next_node.data.assertIntegrity();
+
+ // Record the increased row count
+ self.total_rows += 1;
+
+ return next_node;
+}
+
+/// Possible dimensions to increase capacity for.
+pub const IncreaseCapacity = enum {
+ styles,
+ grapheme_bytes,
+ hyperlink_bytes,
+ string_bytes,
+};
+
+pub const IncreaseCapacityError = error{
+ // An actual system OOM trying to allocate memory.
+ OutOfMemory,
+
+ // The existing page is already at max capacity for the given
+ // adjustment. The caller must create a new page, remove data from
+ // the old page, etc. (up to the caller).
+ OutOfSpace,
+};
+
+/// Increase the capacity of the given page node in the given direction.
+/// This will always allocate a new node and remove the old node, so the
+/// existing node pointer will be invalid after this call. The newly created
+/// node on success is returned.
+///
+/// The increase amount is at the control of the PageList implementation,
+/// but is guaranteed to always increase by at least one unit in the
+/// given dimension. Practically, we'll always increase by much more
+/// (we currently double every time) but callers shouldn't depend on that.
+/// The only guarantee is some amount of growth.
+///
+/// Adjustment can be null if you want to recreate, reclone the page
+/// with the same capacity. This is a special case used for rehashing since
+/// the logic is otherwise the same. In this case, OutOfMemory is the
+/// only possible error.
+pub fn increaseCapacity(
+ self: *PageList,
+ node: *List.Node,
+ adjustment: ?IncreaseCapacity,
+) IncreaseCapacityError!*List.Node {
+ defer self.assertIntegrity();
+ const page: *Page = &node.data;
+
+ // Apply our adjustment
+ var cap = page.capacity;
+ if (adjustment) |v| switch (v) {
+ inline else => |tag| {
+ const field_name = @tagName(tag);
+ 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;
+ };
+ @field(cap, field_name) = new;
+
+ // If our capacity exceeds the maximum page size, treat it
+ // as an OutOfSpace because things like page splitting will
+ // help.
+ const layout = Page.layout(cap);
+ if (layout.total_size > size.max_page_size) {
+ return error.OutOfSpace;
+ }
+ },
+ };
+
+ log.info("adjusting page capacity={}", .{cap});
+
+ // Create our new page and clone the old page into it.
+ const new_node = try self.createPage(cap);
+ errdefer self.destroyNode(new_node);
+ const new_page: *Page = &new_node.data;
+ assert(new_page.capacity.rows >= page.capacity.rows);
+ assert(new_page.capacity.cols >= page.capacity.cols);
+ new_page.size.rows = page.size.rows;
+ new_page.size.cols = page.size.cols;
+ new_page.cloneFrom(
+ page,
+ 0,
+ page.size.rows,
+ ) catch |err| {
+ // cloneFrom only errors if there isn't capacity for the data
+ // from the source page but we're only increasing capacity so
+ // this should never be possible. If it happens, we should crash
+ // because we're in no man's land and can't safely recover.
+ log.err("increaseCapacity clone failed err={}", .{err});
+ @panic("unexpected clone failure");
+ };
+
+ // Preserve page-level dirty flag (cloneFrom only copies row data)
+ new_page.dirty = page.dirty;
+
+ // Must not fail after this because the operations we do after this
+ // can't be recovered.
+ errdefer comptime unreachable;
+
+ // Fix up all our tracked pins to point to the new page.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != node) continue;
+ p.node = new_node;
+ }
+
+ // Insert this page and destroy the old page
+ self.pages.insertBefore(node, new_node);
+ self.pages.remove(node);
+ self.destroyNode(node);
+
+ new_page.assertIntegrity();
+ return new_node;
+}
+
+/// 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,
+) Allocator.Error!*List.Node {
+ // log.debug("create page cap={}", .{cap});
+ return try createPageExt(
+ &self.pool,
+ cap,
+ &self.page_serial,
+ &self.page_size,
+ );
+}
+
+inline fn createPageExt(
+ pool: *MemoryPool,
+ cap: Capacity,
+ 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 page_alloc = pool.pages.arena.child_allocator;
+
+ // It would be better to encode this into the Zig error handling
+ // system but that is a big undertaking and we only have a few
+ // centralized call sites so it is handled on its own currently.
+ assert(layout.total_size <= size.max_page_size);
+
+ // 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,
+ );
+ errdefer if (pooled)
+ pool.pages.destroy(page_buf)
+ else
+ page_alloc.free(page_buf);
+
+ // In runtime safety modes, allocators fill with 0xAA. On freestanding
+ // (WASM), the WasmAllocator reuses freed slots without zeroing.
+ if (comptime std.debug.runtime_safety or builtin.os.tag == .freestanding)
+ @memset(page_buf, 0);
+
+ page.* = .{
+ .data = .initBuf(.init(page_buf), layout),
+ .serial = serial.*,
+ };
+ page.data.size.rows = 0;
+ serial.* += 1;
+
+ if (total_size) |v| {
+ // Accumulate page size now. We don't assert or check max size
+ // because we may exceed it here temporarily as we are allocating
+ // pages before destroy.
+ v.* += page_buf.len;
+ }
+
+ return page;
+}
+
+/// 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.
+///
+/// IMPORTANT: This function does NOT update `total_rows`. The caller is
+/// responsible for accounting for the removed rows. This function only
+/// updates `page_size` (byte accounting), not row accounting.
+fn destroyNode(self: *PageList, node: *List.Node) void {
+ destroyNodeExt(&self.pool, node, &self.page_size);
+}
+
+fn destroyNodeExt(
+ pool: *MemoryPool,
+ node: *List.Node,
+ total_size: ?*usize,
+) void {
+ const page: *Page = &node.data;
+
+ // Update our accounting for page size
+ if (total_size) |v| v.* -= page.memory.len;
+
+ 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);
+ }
+
+ pool.nodes.destroy(node);
+}
+
+/// Fast-path function to erase exactly 1 row. Erasing means that the row
+/// is completely REMOVED, not just cleared. All rows following the removed
+/// row will be shifted up by 1 to fill the empty space.
+///
+/// Unlike eraseRows, eraseRow does not change the size of any pages. The
+/// caller is responsible for adjusting the row count of the final page if
+/// that behavior is required.
+pub fn eraseRow(
+ self: *PageList,
+ pt: point.Point,
+) !void {
+ defer self.assertIntegrity();
+ const pn = self.pin(pt).?;
+
+ var node = pn.node;
+ var rows = node.data.rows.ptr(node.data.memory.ptr);
+
+ // 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]);
+
+ // We adjust the tracked pins in this page, moving up any that were below
+ // the removed row.
+ {
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node == node and p.y > pn.y) p.y -= 1;
+ }
+ }
+
+ // If we have a pinned viewport, we need to adjust for active area.
+ self.fixupViewport(1);
+
+ // 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;
+
+ // 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);
+
+ // 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
+ // rotated down in the previous page, and accounts for the row in
+ // this page that will be rotated down as well.
+ //
+ // rotate -> clone --> rotate -> result
+ // 0 -. 1 1 1
+ // 1 | 2 2 2
+ // 2 | 3 3 3
+ // 3 <' 0 <. 4 4
+ // --- --- | --- --- <- page boundary
+ // 4 4 -' 4 -. 5
+ // 5 5 5 | 6
+ // 6 6 6 | 7
+ // 7 7 7 <' 4
+ try node.data.cloneRowFrom(
+ &next.data,
+ &rows[node.data.size.rows - 1],
+ &next_rows[0],
+ );
+
+ node = next;
+ rows = next_rows;
+
+ fastmem.rotateOnce(Row, rows[0..node.data.size.rows]);
+
+ // Mark the whole page as dirty.
+ node.data.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
+ // been moved to the previous page. Otherwise, move it up by 1.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != node) continue;
+ if (p.y == 0) {
+ p.node = node.prev.?;
+ p.y = p.node.data.size.rows - 1;
+ continue;
+ }
+ p.y -= 1;
+ }
+ }
+
+ // 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);
+}
+
+/// A variant of eraseRow that shifts only a bounded number of following
+/// rows up, filling the space they leave behind with blank rows.
+///
+/// `limit` is exclusive of the erased row. A limit of 1 will erase the target
+/// row and shift the row below in to its position, leaving a blank row below.
+pub fn eraseRowBounded(
+ self: *PageList,
+ pt: point.Point,
+ limit: usize,
+) !void {
+ defer self.assertIntegrity();
+
+ // This function has a lot of repeated code in it because it is a hot path.
+ //
+ // To get a better idea of what's happening, read eraseRow first for more
+ // in-depth explanatory comments. To avoid repetition, the only comments for
+ // this function are for where it differs from eraseRow.
+
+ const pn = self.pin(pt).?;
+
+ var node: *List.Node = pn.node;
+ var rows = node.data.rows.ptr(node.data.memory.ptr);
+
+ // 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);
+ 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;
+
+ // 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
+ // of in the pin loop below because its unlikely to be true and we
+ // don't want to run the conditional N times.
+ if (self.viewport == .pin) viewport: {
+ if (self.viewport_pin_row_offset) |*v| {
+ const p = self.viewport_pin;
+ if (p.node != node or
+ p.y < pn.y or
+ p.y > pn.y + limit or
+ p.y == 0) break :viewport;
+ v.* -= 1;
+ }
+ }
+
+ // Update pins in the shifted region.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node == node and
+ p.y >= pn.y and
+ p.y <= pn.y + limit)
+ {
+ if (p.y == 0) {
+ p.x = 0;
+ } else {
+ p.y -= 1;
+ }
+ }
+ }
+
+ return;
+ }
+
+ fastmem.rotateOnce(Row, rows[pn.y..node.data.size.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;
+
+ // 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;
+
+ // Update tracked pins.
+ {
+ // See the other places we do something similar in this function
+ // for a detailed explanation.
+ if (self.viewport == .pin) viewport: {
+ if (self.viewport_pin_row_offset) |*v| {
+ const p = self.viewport_pin;
+ if (p.node != node or
+ p.y < pn.y or
+ p.y == 0) break :viewport;
+ v.* -= 1;
+ }
+ }
+
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node == node and p.y >= pn.y) {
+ if (p.y == 0) {
+ p.x = 0;
+ } else {
+ p.y -= 1;
+ }
+ }
+ }
+ }
+
+ while (node.next) |next| {
+ const next_rows = next.data.rows.ptr(next.data.memory.ptr);
+
+ try node.data.cloneRowFrom(
+ &next.data,
+ &rows[node.data.size.rows - 1],
+ &next_rows[0],
+ );
+
+ node = next;
+ rows = next_rows;
+
+ // We check to see if this page contains enough rows to satisfy the
+ // specified limit, accounting for rows we've already shifted in prior
+ // pages.
+ //
+ // 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);
+ 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;
+
+ // See the other places we do something similar in this function
+ // for a detailed explanation.
+ if (self.viewport == .pin) viewport: {
+ if (self.viewport_pin_row_offset) |*v| {
+ const p = self.viewport_pin;
+ if (p.node != node or
+ p.y > shifted_limit) break :viewport;
+ v.* -= 1;
+ }
+ }
+
+ // Update pins in the shifted region.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ 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;
+ continue;
+ }
+ p.y -= 1;
+ }
+
+ return;
+ }
+
+ fastmem.rotateOnce(Row, rows[0..node.data.size.rows]);
+
+ // Mark the whole page as dirty.
+ node.data.dirty = true;
+
+ // Account for the rows shifted in this node.
+ shifted += node.data.size.rows;
+
+ // See the other places we do something similar in this function
+ // for a detailed explanation.
+ if (self.viewport == .pin) viewport: {
+ if (self.viewport_pin_row_offset) |*v| {
+ const p = self.viewport_pin;
+ if (p.node != node) break :viewport;
+ v.* -= 1;
+ }
+ }
+
+ // Update tracked pins.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != node) continue;
+ if (p.y == 0) {
+ p.node = node.prev.?;
+ p.y = p.node.data.size.rows - 1;
+ continue;
+ }
+ p.y -= 1;
+ }
+ }
+
+ // 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);
+}
+
+/// Erase all history rows, optionally up to a bottom-left bound.
+/// This always starts from the beginning of the history area.
+pub fn eraseHistory(
+ self: *PageList,
+ bl_pt: ?point.Point,
+) void {
+ self.eraseRows(.{ .history = .{} }, bl_pt);
+}
+
+/// Erase active area rows, from the top of the active area to the
+/// given row (inclusive).
+pub fn eraseActive(
+ self: *PageList,
+ y: size.CellCountInt,
+) void {
+ assert(y < self.rows);
+ self.eraseRows(.{ .active = .{} }, .{ .active = .{ .y = y } });
+}
+
+/// Erase rows from tl_pt to bl_pt (inclusive), physically removing
+/// them rather than just clearing their contents. If a point falls
+/// in the middle of a page, remaining rows in that page are shifted
+/// 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.
+/// Use the public eraseHistory/eraseActive wrappers which enforce this.
+fn eraseRows(
+ self: *PageList,
+ tl_pt: point.Point,
+ bl_pt: ?point.Point,
+) void {
+ defer self.assertIntegrity();
+
+ // The count of rows that was erased.
+ var erased: usize = 0;
+
+ // A pageIterator iterates one page at a time from the back forward.
+ // "back" here is in terms of scrollback, but actually the front of the
+ // linked list.
+ var it = self.pageIterator(.right_down, tl_pt, bl_pt);
+ while (it.next()) |chunk| {
+ // If the chunk is a full page, deinit thit page and remove it from
+ // the linked list.
+ if (chunk.fullPage()) {
+ // A rare special case is that we're deleting everything
+ // in our linked list. erasePage requires at least one other
+ // 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;
+ erased += page.size.rows;
+ page.reinit();
+ page.size.rows = 0;
+ break;
+ }
+
+ erased += chunk.node.data.size.rows;
+ self.erasePage(chunk.node);
+ continue;
+ }
+
+ // We are modifying our chunk so make sure it is in a good state.
+ defer chunk.node.data.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;
+ for (0..scroll_amount) |i| {
+ const src: *Row = &rows[i + chunk.end];
+ const dst: *Row = &rows[i];
+ const old_dst = dst.*;
+ dst.* = src.*;
+ src.* = old_dst;
+
+ // Mark the moved row as dirty.
+ dst.dirty = true;
+ }
+
+ // 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| {
+ const row: *Row = &rows[i];
+ chunk.node.data.clearCells(
+ row,
+ 0,
+ chunk.node.data.size.cols,
+ );
+ }
+
+ // Update any tracked pins to shift their y. If it was in the erased
+ // row then we move it to the top of this page.
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != chunk.node) continue;
+ if (p.y >= chunk.end) {
+ p.y -= chunk.end;
+ } else {
+ p.y = 0;
+ p.x = 0;
+ }
+ }
+
+ // Our new size is the amount we scrolled
+ chunk.node.data.size.rows = @intCast(scroll_amount);
+ erased += chunk.end;
+ }
+
+ // Update our total row count
+ self.total_rows -= erased;
+
+ // If we deleted active, we need to regrow because one of our invariants
+ // is that we always have full active space.
+ if (tl_pt == .active) {
+ for (0..erased) |_| _ = self.grow() catch |err| {
+ // If this fails its a pretty big issue actually... but I don't
+ // want to turn this function into an error-returning function
+ // because erasing active is so rare and even if it happens failing
+ // is even more rare...
+ log.err("failed to regrow active area after erase err={}", .{err});
+ return;
+ };
+ }
+
+ // If we have a pinned viewport, we need to adjust for active area.
+ self.fixupViewport(erased);
+}
+
+/// Erase a single page, freeing all its resources. The page must be
+/// at the front or back of the linked list (not the middle) and must
+/// NOT be the final page in the entire list (i.e. must not make the
+/// list empty).
+///
+/// IMPORTANT: This function does NOT update `total_rows`. The caller is
+/// responsible for accounting for the removed rows before or after calling
+/// this function.
+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.
+ 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| {
+ if (p.node != node) continue;
+ p.node = node.prev orelse node.next orelse unreachable;
+ p.y = 0;
+ p.x = 0;
+
+ // This doesn't get marked garbage because the tracked pin
+ // movement is sensical.
+ }
+
+ // Remove the page from the linked list
+ self.pages.remove(node);
+ self.destroyNode(node);
+}
+
+/// Returns the pin for the given point. The pin is NOT tracked so it
+/// is only valid as long as the pagelist isn't modified.
+///
+/// This will return null if the point is out of bounds. The caller
+/// should clamp the point to the bounds of the coordinate space if
+/// necessary.
+pub fn pin(self: *const PageList, pt: point.Point) ?Pin {
+ // getTopLeft is much more expensive than checking the cols bounds
+ // so we do this first.
+ const x = pt.coord().x;
+ if (x >= self.cols) return null;
+
+ // Grab the top left and move to the point.
+ var p = self.getTopLeft(pt).down(pt.coord().y) orelse return null;
+ p.x = x;
+ return p;
+}
+
+/// Convert the given pin to a tracked pin. A tracked pin will always be
+/// automatically updated as the pagelist is modified. If the point the
+/// pin points to is removed completely, the tracked pin will be updated
+/// to the top-left of the screen.
+pub fn trackPin(self: *PageList, p: Pin) Allocator.Error!*Pin {
+ if (build_options.slow_runtime_safety) assert(self.pinIsValid(p));
+
+ // Create our tracked pin
+ const tracked = try self.pool.pins.create();
+ errdefer self.pool.pins.destroy(tracked);
+ tracked.* = p;
+
+ // Add it to the tracked list
+ try self.tracked_pins.putNoClobber(self.pool.alloc, tracked, {});
+ errdefer _ = self.tracked_pins.remove(tracked);
+
+ return tracked;
+}
+
+/// Untrack a previously tracked pin. This will deallocate the pin.
+pub fn untrackPin(self: *PageList, p: *Pin) void {
+ assert(p != self.viewport_pin);
+ if (self.tracked_pins.swapRemove(p)) {
+ self.pool.pins.destroy(p);
+ }
+}
+
+pub fn countTrackedPins(self: *const PageList) usize {
+ return self.tracked_pins.count();
+}
+
+/// Returns the tracked pins for this pagelist. The slice is owned by the
+/// pagelist and is only valid until the pagelist is modified.
+pub fn trackedPins(self: *const PageList) []const *Pin {
+ return self.tracked_pins.keys();
+}
+
+/// Checks if a pin is valid for this pagelist. This is a very slow and
+/// expensive operation since we traverse the entire linked list in the
+/// worst case. Only for runtime safety/debug.
+pub fn pinIsValid(self: *const PageList, p: Pin) bool {
+ // This is very slow so we want to ensure we only ever
+ // call this during slow runtime safety builds.
+ comptime assert(build_options.slow_runtime_safety);
+
+ 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 false;
+}
+
+/// Returns the viewport for the given pin, preferring to pin to
+/// "active" if the pin is within the active area.
+fn pinIsActive(self: *const PageList, p: Pin) bool {
+ // If the pin is in the active page, then we can quickly determine
+ // if we're beyond the end.
+ const active = self.getTopLeft(.active);
+ if (p.node == active.node) return p.y >= active.y;
+
+ var node_ = active.node.next;
+ while (node_) |node| {
+ // This loop is pretty fast because the active area is
+ // never that large so this is at most one, two nodes for
+ // reasonable terminals (including very large real world
+ // ones).
+
+ // A node forward in the active area is our node, so we're
+ // definitely in the active area.
+ if (node == p.node) return true;
+ node_ = node.next;
+ }
+
+ return false;
+}
+
+/// Returns true if the pin is at the top of the scrollback area.
+fn pinIsTop(self: *const PageList, p: Pin) bool {
+ return p.y == 0 and p.node == self.pages.first.?;
+}
+
+/// Convert a pin to a point in the given context. If the pin can't fit
+/// within the given tag (i.e. its in the history but you requested active),
+/// then this will return null.
+///
+/// Note that this can be a very expensive operation depending on the tag and
+/// the location of the pin. This works by traversing the linked list of pages
+/// in the tagged region.
+///
+/// Therefore, this is recommended only very rarely.
+pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point {
+ const tl = self.getTopLeft(tag);
+
+ // Count our first page which is special because it may be partial.
+ var coord: point.Coordinate = .{ .x = p.x };
+ if (p.node == tl.node) {
+ // If our top-left is after our y then we're outside the range.
+ if (tl.y > p.y) return null;
+ coord.y = p.y - tl.y;
+ } else {
+ coord.y += tl.node.data.size.rows - tl.y;
+ var node_ = tl.node.next;
+ while (node_) |node| : (node_ = node.next) {
+ if (node == p.node) {
+ coord.y += p.y;
+ break;
+ }
+
+ coord.y += node.data.size.rows;
+ } else {
+ // We never saw our node, meaning we're outside the range.
+ return null;
+ }
+ }
+
+ return switch (tag) {
+ inline else => |comptime_tag| @unionInit(
+ point.Point,
+ @tagName(comptime_tag),
+ coord,
+ ),
+ };
+}
+
+/// Get the cell at the given point, or null if the cell does not
+/// exist or is out of bounds.
+///
+/// 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);
+ return .{
+ .node = pt_pin.node,
+ .row = rac.row,
+ .cell = rac.cell,
+ .row_idx = pt_pin.y,
+ .col_idx = pt_pin.x,
+ };
+}
+
+/// Log a debug diagram of the page list to the provided writer.
+///
+/// EXAMPLE:
+///
+/// +-----+ = PAGE 0
+/// ... | |
+/// 50 | foo |
+/// ... | |
+/// +--------+ ACTIVE
+/// 124 | | | 0
+/// 125 |Text | | 1
+/// : ^ : : = PIN 0
+/// 126 |Wrap… | 2
+/// +-----+ :
+/// +-----+ : = PAGE 1
+/// 0 …ed | | 3
+/// 1 | etc.| | 4
+/// +-----+ :
+/// +--------+
+pub fn diagram(
+ self: *const PageList,
+ writer: *std.Io.Writer,
+) std.Io.Writer.Error!void {
+ const active_pin = self.getTopLeft(.active);
+
+ var active = false;
+ var active_index: usize = 0;
+
+ var page_index: usize = 0;
+ var cols: usize = 0;
+
+ var it = self.pageIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |chunk| : (page_index += 1) {
+ cols = chunk.node.data.size.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| {
+ // Active header
+ if (!active and
+ chunk.node == active_pin.node and
+ active_pin.y == y)
+ {
+ active = true;
+ try writer.writeAll(" +-");
+ try writer.writeByteNTimes('-', cols);
+ try writer.writeAll("--+ ACTIVE");
+ try writer.writeByte('\n');
+ }
+
+ // Page header
+ if (y == 0) {
+ try writer.writeAll(" +");
+ try writer.writeByteNTimes('-', cols);
+ try writer.writeByte('+');
+ if (active) try writer.writeAll(" :");
+ try writer.print(" = PAGE {}", .{page_index});
+ try writer.writeByte('\n');
+ }
+
+ // Row contents
+ {
+ const row = chunk.node.data.getRow(y);
+ const cells = chunk.node.data.getCells(row)[0..cols];
+
+ var row_has_content = false;
+
+ for (cells) |cell| {
+ if (cell.hasText()) {
+ row_has_content = true;
+ break;
+ }
+ }
+
+ // We don't want to print this row's contents
+ // unless it has text or is in the active area.
+ if (!active and !row_has_content) {
+ // If we haven't, draw an ellipsis row.
+ if (!skipped) {
+ try writer.writeAll(" ... :");
+ try writer.writeByteNTimes(' ', cols);
+ try writer.writeByte(':');
+ if (active) try writer.writeAll(" :");
+ try writer.writeByte('\n');
+ }
+ skipped = true;
+ continue;
+ }
+
+ skipped = false;
+
+ // Left pad row number to 5 wide
+ const y_digits = if (y == 0) 0 else std.math.log10_int(y);
+ try writer.writeByteNTimes(' ', 4 - y_digits);
+ try writer.print("{} ", .{y});
+
+ // Left edge or wrap continuation marker
+ try writer.writeAll(if (row.wrap_continuation) "…" else "|");
+
+ // Row text
+ if (row_has_content) {
+ for (cells) |*cell| {
+ // Skip spacer tails, since wide cells are, well, wide.
+ if (cell.wide == .spacer_tail) continue;
+
+ // Write non-printing bytes as base36, for convenience.
+ if (cell.codepoint() < ' ') {
+ try writer.writeByte("0123456789ABCDEFGHIJKLMNOPQRSTUVWXYZ"[cell.codepoint()]);
+ continue;
+ }
+ try writer.print("{u}", .{cell.codepoint()});
+ if (cell.hasGrapheme()) {
+ const grapheme = chunk.node.data.lookupGrapheme(cell).?;
+ for (grapheme) |cp| {
+ try writer.print("{u}", .{cp});
+ }
+ }
+ }
+ } else {
+ try writer.writeByteNTimes(' ', cols);
+ }
+
+ // Right edge or wrap marker
+ try writer.writeAll(if (row.wrap) "…" else "|");
+ if (active) {
+ try writer.print(" | {}", .{active_index});
+ active_index += 1;
+ }
+
+ try writer.writeByte('\n');
+ }
+
+ // Tracked pin marker(s)
+ pins: {
+ // If we have more than 16 tracked pins in a row, oh well,
+ // don't wanna bother making this function allocating.
+ var pin_buf: [16]*Pin = undefined;
+ var pin_count: usize = 0;
+ const pin_keys = self.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != chunk.node) continue;
+ if (p.y != y) continue;
+ pin_buf[pin_count] = p;
+ pin_count += 1;
+ if (pin_count >= pin_buf.len) return error.TooManyTrackedPinsInRow;
+ }
+
+ if (pin_count == 0) break :pins;
+
+ const pins = pin_buf[0..pin_count];
+ std.mem.sort(
+ *Pin,
+ pins,
+ {},
+ struct {
+ fn lt(_: void, a: *Pin, b: *Pin) bool {
+ return a.x < b.x;
+ }
+ }.lt,
+ );
+
+ try writer.writeAll(" :");
+ var x: usize = 0;
+
+ for (pins) |p| {
+ if (x > p.x) continue;
+ try writer.writeByteNTimes(' ', p.x - x);
+ try writer.writeByte('^');
+ x = p.x + 1;
+ }
+
+ try writer.writeByteNTimes(' ', cols - x);
+ try writer.writeByte(':');
+
+ if (active) try writer.writeAll(" :");
+
+ try writer.print(" = PIN{s}", .{if (pin_count > 1) "S" else ""});
+
+ x = pins[0].x;
+ for (pins, 0..) |p, i| {
+ if (p.x != x) try writer.writeByte(',');
+ try writer.print(" {}", .{i});
+ }
+
+ try writer.writeByte('\n');
+ }
+ }
+
+ // Page footer
+ {
+ try writer.writeAll(" +");
+ try writer.writeByteNTimes('-', cols);
+ try writer.writeByte('+');
+ if (active) try writer.writeAll(" :");
+ try writer.writeByte('\n');
+ }
+ }
+
+ // Active footer
+ {
+ try writer.writeAll(" +-");
+ try writer.writeByteNTimes('-', cols);
+ try writer.writeAll("--+");
+ try writer.writeByte('\n');
+ }
+}
+
+/// Returns the boundaries of the given semantic content type for
+/// the prompt at the given pin. The pin row MUST be the first row
+/// of a prompt, otherwise the results may be nonsense.
+///
+/// To get prompt pins, use promptIterator. Warning that if there are
+/// no semantic prompts ever present, promptIterator will iterate the
+/// entire PageList. Downstream callers should keep track of a flag if
+/// they've ever seen semantic prompt operations to prevent this performance
+/// case.
+///
+/// Note that some semantic content type such as "input" is usually
+/// nested within prompt boundaries, so the returned boundaries may include
+/// prompt text.
+pub fn highlightSemanticContent(
+ self: *const PageList,
+ at: Pin,
+ content: pagepkg.Cell.SemanticContent,
+) ?highlight.Untracked {
+ // Performance note: we can do this more efficiently in a single
+ // forward-pass. Semantic content operations aren't usually fast path
+ // but if someone wants to optimize them someday that's great.
+
+ const end: Pin = end: {
+ // Safety assertion, our starting point should be a prompt row.
+ // so the first returned prompt should be ourselves.
+ var it = at.promptIterator(.right_down, null);
+ assert(it.next().?.y == at.y);
+
+ // Our end is the end of the line just before the next prompt
+ // line, which should exist since we verified we have at least
+ // two prompts here.
+ if (it.next()) |next| next: {
+ var prev = next.up(1) orelse break :next;
+ prev.x = prev.node.data.size.cols - 1;
+ break :end prev;
+ }
+
+ // Didn't find any further prompt so the end of our zone is
+ // the end of the screen.
+ break :end self.getBottomRight(.screen).?;
+ };
+
+ switch (content) {
+ // For the prompt, we select all the way up to command output.
+ // We include all the input lines, too.
+ .prompt => {
+ var result: highlight.Untracked = .{
+ .start = at.left(at.x),
+ .end = at,
+ };
+
+ var it = at.cellIterator(.right_down, end);
+ while (it.next()) |p| {
+ switch (p.rowAndCell().cell.semantic_content) {
+ .prompt, .input => result.end = p,
+ .output => break,
+ }
+ }
+
+ return result;
+ },
+
+ // For input, we include the start of the input to the end of
+ // the input, which may include all the prompts in the middle, too.
+ .input => {
+ var result: highlight.Untracked = .{
+ .start = undefined,
+ .end = undefined,
+ };
+
+ // Find the start
+ var it = at.cellIterator(.right_down, end);
+ while (it.next()) |p| {
+ switch (p.rowAndCell().cell.semantic_content) {
+ .prompt => {},
+ .input => {
+ result.start = p;
+ result.end = p;
+ break;
+ },
+ .output => return null,
+ }
+ } else {
+ // No input found
+ return null;
+ }
+
+ // Find the end
+ while (it.next()) |p| {
+ switch (p.rowAndCell().cell.semantic_content) {
+ // Prompts can be nested in our input for continuation
+ .prompt => {},
+
+ // Output means we're done
+ .output => break,
+
+ .input => result.end = p,
+ }
+ }
+
+ return result;
+ },
+
+ .output => {
+ var result: highlight.Untracked = .{
+ .start = undefined,
+ .end = undefined,
+ };
+
+ // Find the start
+ var it = at.cellIterator(.right_down, end);
+ while (it.next()) |p| {
+ const cell = p.rowAndCell().cell;
+ switch (cell.semantic_content) {
+ .prompt, .input => {},
+ .output => {
+ // Skip empty cells - they default to .output but aren't real output
+ if (!cell.hasText()) continue;
+ result.start = p;
+ result.end = p;
+ break;
+ },
+ }
+ } else {
+ // No output found
+ return null;
+ }
+
+ // Find the end
+ while (it.next()) |p| {
+ const cell = p.rowAndCell().cell;
+ switch (cell.semantic_content) {
+ .prompt, .input => break,
+ .output => {
+ // Only extend to cells with actual text
+ if (cell.hasText()) result.end = p;
+ },
+ }
+ }
+
+ return result;
+ },
+ }
+}
+
+/// Direction that iterators can move.
+pub const Direction = enum { left_up, right_down };
+
+pub const PromptIterator = struct {
+ /// The pin that we are currently at. Also the starting pin when
+ /// initializing.
+ current: ?Pin,
+
+ /// The pin to end at or null if we end when we can't traverse
+ /// anymore.
+ limit: ?Pin,
+
+ /// The direction to do the traversal.
+ direction: Direction,
+
+ pub const empty: PromptIterator = .{
+ .current = null,
+ .limit = null,
+ .direction = .left_up,
+ };
+
+ /// Return the next pin that represents the first row in a prompt.
+ /// From here, you can find the prompt input, command output, etc.
+ pub fn next(self: *PromptIterator) ?Pin {
+ switch (self.direction) {
+ .left_up => return self.nextLeftUp(),
+ .right_down => return self.nextRightDown(),
+ }
+ }
+
+ pub fn nextRightDown(self: *PromptIterator) ?Pin {
+ // Start at our current pin. If we have no current it means
+ // we reached the end and we're done.
+ const start: Pin = self.current orelse return null;
+
+ // We need to traverse downwards and look for prompts.
+ var current: ?Pin = start;
+ while (current) |p| : (current = p.down(1)) {
+ // Check our limit.
+ const at_limit = if (self.limit) |limit| limit.eql(p) else false;
+
+ const rac = p.rowAndCell();
+ switch (rac.row.semantic_prompt) {
+ // This row isn't a prompt. Keep looking.
+ .none => if (at_limit) break,
+
+ // This is a prompt line or continuation line. In either
+ // case we consider the first line the prompt, and then
+ // skip over any remaining prompt lines. This handles the
+ // case where scrollback pruned the prompt.
+ .prompt, .prompt_continuation => {
+ // If we're at our limit just return this prompt.
+ if (at_limit) {
+ self.current = null;
+ return p.left(p.x);
+ }
+
+ // Skip over any continuation lines that follow this prompt,
+ // up to our limit.
+ var end_pin = p;
+ while (end_pin.down(1)) |next_pin| : (end_pin = next_pin) {
+ switch (next_pin.rowAndCell().row.semantic_prompt) {
+ .prompt_continuation => if (self.limit) |limit| {
+ if (limit.eql(next_pin)) break;
+ },
+
+ .prompt, .none => {
+ self.current = next_pin;
+ return p.left(p.x);
+ },
+ }
+ }
+
+ self.current = null;
+ return p.left(p.x);
+ },
+ }
+ }
+
+ self.current = null;
+ return null;
+ }
+
+ pub fn nextLeftUp(self: *PromptIterator) ?Pin {
+ // Start at our current pin. If we have no current it means
+ // we reached the end and we're done.
+ const start: Pin = self.current orelse return null;
+
+ // We need to traverse upwards and look for prompts.
+ var current: ?Pin = start;
+ while (current) |p| : (current = p.up(1)) {
+ // Check our limit.
+ const at_limit = if (self.limit) |limit| limit.eql(p) else false;
+
+ const rac = p.rowAndCell();
+ switch (rac.row.semantic_prompt) {
+ // This row isn't a prompt. Keep looking.
+ .none => if (at_limit) break,
+
+ // This is a prompt line.
+ .prompt => {
+ self.current = if (at_limit) null else p.up(1);
+ return p.left(p.x);
+ },
+
+ // If this is a prompt continuation, then we continue
+ // looking for the start of the prompt OR a non-prompt
+ // line, whichever is first. The non-prompt line is to handle
+ // poorly behaved programs or scrollback that's been cut-off.
+ .prompt_continuation => {
+ // If we're at our limit just return this continuation as prompt.
+ if (at_limit) {
+ self.current = null;
+ return p.left(p.x);
+ }
+
+ var end_pin = p;
+ while (end_pin.up(1)) |prior| : (end_pin = prior) {
+ if (self.limit) |limit| {
+ if (limit.eql(prior)) break;
+ }
+
+ switch (prior.rowAndCell().row.semantic_prompt) {
+ // No prompt. That means our last pin is good!
+ .none => {
+ self.current = prior;
+ return end_pin.left(end_pin.x);
+ },
+
+ // Prompt continuation, keep looking.
+ .prompt_continuation => {},
+
+ // Prompt! Found it!
+ .prompt => {
+ self.current = prior.up(1);
+ return prior.left(prior.x);
+ },
+ }
+ }
+
+ // No prior rows, trimmed scrollback probably.
+ self.current = null;
+ return p.left(p.x);
+ },
+ }
+ }
+
+ self.current = null;
+ return null;
+ }
+};
+
+pub fn promptIterator(
+ self: *const PageList,
+ direction: Direction,
+ tl_pt: point.Point,
+ bl_pt: ?point.Point,
+) PromptIterator {
+ const tl_pin = self.pin(tl_pt).?;
+ const bl_pin = if (bl_pt) |pt|
+ self.pin(pt).?
+ else
+ self.getBottomRight(tl_pt) orelse return .empty;
+
+ return switch (direction) {
+ .right_down => tl_pin.promptIterator(.right_down, bl_pin),
+ .left_up => bl_pin.promptIterator(.left_up, tl_pin),
+ };
+}
+
+pub const CellIterator = struct {
+ row_it: RowIterator,
+ cell: ?Pin = null,
+
+ pub fn next(self: *CellIterator) ?Pin {
+ const cell = self.cell orelse return null;
+
+ switch (self.row_it.page_it.direction) {
+ .right_down => {
+ if (cell.x + 1 < cell.node.data.size.cols) {
+ // We still have cells in this row, increase x.
+ var copy = cell;
+ copy.x += 1;
+ self.cell = copy;
+ } else {
+ // We need to move to the next row.
+ self.cell = self.row_it.next();
+ }
+ },
+
+ .left_up => {
+ if (cell.x > 0) {
+ // We still have cells in this row, decrease x.
+ var copy = cell;
+ copy.x -= 1;
+ self.cell = copy;
+ } else {
+ // 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;
+ self.cell = copy;
+ } else {
+ self.cell = null;
+ }
+ }
+ },
+ }
+
+ return cell;
+ }
+};
+
+pub fn cellIterator(
+ self: *const PageList,
+ direction: Direction,
+ tl_pt: point.Point,
+ bl_pt: ?point.Point,
+) CellIterator {
+ const tl_pin = self.pin(tl_pt).?;
+ const bl_pin = if (bl_pt) |pt|
+ self.pin(pt).?
+ else
+ self.getBottomRight(tl_pt) orelse
+ return .{ .row_it = undefined };
+
+ return switch (direction) {
+ .right_down => tl_pin.cellIterator(.right_down, bl_pin),
+ .left_up => bl_pin.cellIterator(.left_up, tl_pin),
+ };
+}
+
+pub const RowIterator = struct {
+ page_it: PageIterator,
+ chunk: ?PageIterator.Chunk = null,
+ offset: size.CellCountInt = 0,
+
+ pub fn next(self: *RowIterator) ?Pin {
+ const chunk = self.chunk orelse return null;
+ const row: Pin = .{ .node = chunk.node, .y = self.offset };
+
+ switch (self.page_it.direction) {
+ .right_down => {
+ // Increase our offset in the chunk
+ self.offset += 1;
+
+ // If we are beyond the chunk end, we need to move to the next chunk.
+ if (self.offset >= chunk.end) {
+ self.chunk = self.page_it.next();
+ if (self.chunk) |c| self.offset = c.start;
+ }
+ },
+
+ .left_up => {
+ // If we are at the start of the chunk, we need to move to the
+ // previous chunk.
+ if (self.offset == 0) {
+ self.chunk = self.page_it.next();
+ if (self.chunk) |c| self.offset = c.end - 1;
+ } else {
+ // If we're at the start of the chunk and its a non-zero
+ // offset then we've reached a limit.
+ if (self.offset == chunk.start) {
+ self.chunk = null;
+ } else {
+ self.offset -= 1;
+ }
+ }
+ },
+ }
+
+ return row;
+ }
+};
+
+/// Create an iterator that can be used to iterate all the rows in
+/// a region of the screen from the given top-left. The tag of the
+/// top-left point will also determine the end of the iteration,
+/// so convert from one reference point to another to change the
+/// iteration bounds.
+pub fn rowIterator(
+ self: *const PageList,
+ direction: Direction,
+ tl_pt: point.Point,
+ bl_pt: ?point.Point,
+) RowIterator {
+ const tl_pin = self.pin(tl_pt).?;
+ const bl_pin = if (bl_pt) |pt|
+ self.pin(pt).?
+ else
+ self.getBottomRight(tl_pt) orelse
+ return .{ .page_it = undefined };
+
+ return switch (direction) {
+ .right_down => tl_pin.rowIterator(.right_down, bl_pin),
+ .left_up => bl_pin.rowIterator(.left_up, tl_pin),
+ };
+}
+
+pub const PageIterator = struct {
+ row: ?Pin = null,
+ limit: Limit = .none,
+ direction: Direction = .right_down,
+
+ const Limit = union(enum) {
+ none,
+ count: usize,
+ row: Pin,
+ };
+
+ pub fn next(self: *PageIterator) ?Chunk {
+ return switch (self.direction) {
+ .left_up => self.nextUp(),
+ .right_down => self.nextDown(),
+ };
+ }
+
+ fn nextDown(self: *PageIterator) ?Chunk {
+ // Get our current row location
+ const row = self.row orelse return null;
+
+ return switch (self.limit) {
+ .none => none: {
+ // If we have no limit, then we consume this entire page. Our
+ // next row is the next page.
+ self.row = next: {
+ const next_page = row.node.next orelse break :next null;
+ break :next .{ .node = next_page };
+ };
+
+ break :none .{
+ .node = row.node,
+ .start = row.y,
+ .end = row.node.data.size.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;
+ }
+
+ break :count .{
+ .node = row.node,
+ .start = row.y,
+ .end = row.y + len,
+ };
+ },
+
+ .row => |limit_row| row: {
+ // If this is not the same page as our limit then we
+ // can consume the entire page.
+ if (limit_row.node != row.node) {
+ self.row = next: {
+ const next_page = row.node.next orelse break :next null;
+ break :next .{ .node = next_page };
+ };
+
+ break :row .{
+ .node = row.node,
+ .start = row.y,
+ .end = row.node.data.size.rows,
+ };
+ }
+
+ // If this is the same page then we only consume up to
+ // the limit row.
+ self.row = null;
+ if (row.y > limit_row.y) return null;
+ break :row .{
+ .node = row.node,
+ .start = row.y,
+ .end = limit_row.y + 1,
+ };
+ },
+ };
+ }
+
+ fn nextUp(self: *PageIterator) ?Chunk {
+ // Get our current row location
+ const row = self.row orelse return null;
+
+ return switch (self.limit) {
+ .none => none: {
+ // If we have no limit, then we consume this entire page. Our
+ // next row is the next page.
+ self.row = next: {
+ const next_page = row.node.prev orelse break :next null;
+ break :next .{
+ .node = next_page,
+ .y = next_page.data.size.rows - 1,
+ };
+ };
+
+ break :none .{
+ .node = row.node,
+ .start = 0,
+ .end = row.y + 1,
+ };
+ },
+
+ .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;
+ }
+
+ break :count .{
+ .node = row.node,
+ .start = row.y - len,
+ .end = row.y - 1,
+ };
+ },
+
+ .row => |limit_row| row: {
+ // If this is not the same page as our limit then we
+ // can consume the entire page.
+ if (limit_row.node != row.node) {
+ self.row = next: {
+ const next_page = row.node.prev orelse break :next null;
+ break :next .{
+ .node = next_page,
+ .y = next_page.data.size.rows - 1,
+ };
+ };
+
+ break :row .{
+ .node = row.node,
+ .start = 0,
+ .end = row.y + 1,
+ };
+ }
+
+ // If this is the same page then we only consume up to
+ // the limit row.
+ self.row = null;
+ if (row.y < limit_row.y) return null;
+ break :row .{
+ .node = row.node,
+ .start = limit_row.y,
+ .end = row.y + 1,
+ };
+ },
+ };
+ }
+
+ pub const Chunk = struct {
+ node: *List.Node,
+
+ /// Start y index (inclusive) of this chunk in the page.
+ start: size.CellCountInt,
+
+ /// End y index (exclusive) of this chunk in the page.
+ end: size.CellCountInt,
+
+ pub fn rows(self: Chunk) []Row {
+ const rows_ptr = self.node.data.rows.ptr(self.node.data.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;
+ }
+
+ /// Returns true if this chunk overlaps with the given other chunk
+ /// in any way.
+ pub fn overlaps(self: Chunk, other: Chunk) bool {
+ if (self.node != other.node) return false;
+ if (self.end <= other.start) return false;
+ if (self.start >= other.end) return false;
+ return true;
+ }
+ };
+};
+
+/// Return an iterator that iterates through the rows in the tagged area
+/// of the point. The iterator returns row "chunks", which are the largest
+/// contiguous set of rows in a single backing page for a given portion of
+/// the point region.
+///
+/// This is a more efficient way to iterate through the data in a region,
+/// since you can do simple pointer math and so on.
+///
+/// If bl_pt is non-null, iteration will stop at the bottom left point
+/// (inclusive). If bl_pt is null, the entire region specified by the point
+/// tag will be iterated over. tl_pt and bl_pt must be the same tag, and
+/// bl_pt must be greater than or equal to tl_pt.
+///
+/// If direction is left_up, iteration will go from bl_pt to tl_pt. If
+/// direction is right_down, iteration will go from tl_pt to bl_pt.
+/// Both inclusive.
+pub fn pageIterator(
+ self: *const PageList,
+ direction: Direction,
+ tl_pt: point.Point,
+ bl_pt: ?point.Point,
+) PageIterator {
+ const tl_pin = self.pin(tl_pt).?;
+ const bl_pin = if (bl_pt) |pt|
+ self.pin(pt).?
+ else
+ self.getBottomRight(tl_pt) orelse return .{ .row = null };
+
+ if (build_options.slow_runtime_safety) {
+ assert(tl_pin.eql(bl_pin) or tl_pin.before(bl_pin));
+ }
+
+ return switch (direction) {
+ .right_down => tl_pin.pageIterator(.right_down, bl_pin),
+ .left_up => bl_pin.pageIterator(.left_up, tl_pin),
+ };
+}
+
+/// Get the top-left of the screen for the given tag.
+pub fn getTopLeft(self: *const PageList, tag: point.Tag) Pin {
+ return switch (tag) {
+ // The full screen or history is always just the first page.
+ .screen, .history => .{ .node = self.pages.first.? },
+
+ .viewport => switch (self.viewport) {
+ .active => self.getTopLeft(.active),
+ .top => self.getTopLeft(.screen),
+ .pin => self.viewport_pin.*,
+ },
+
+ // The active area is calculated backwards from the last page.
+ // This makes getting the active top left slower but makes scrolling
+ // much faster because we don't need to update the top left. Under
+ // heavy load this makes a measurable difference.
+ .active => active: {
+ var rem = self.rows;
+ var it = self.pages.last;
+ while (it) |node| : (it = node.prev) {
+ if (rem <= node.data.size.rows) break :active .{
+ .node = node,
+ .y = node.data.size.rows - rem,
+ };
+
+ rem -= node.data.size.rows;
+ }
+
+ unreachable; // assertion: we always have enough rows for active
+ },
+ };
+}
+
+/// Returns the bottom right of the screen for the given tag. This can
+/// return null because it is possible that a tag is not in the screen
+/// (e.g. history does not yet exist).
+pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin {
+ return switch (tag) {
+ .screen, .active => last: {
+ const node = self.pages.last.?;
+ break :last .{
+ .node = node,
+ .y = node.data.size.rows - 1,
+ .x = node.data.size.cols - 1,
+ };
+ },
+
+ .viewport => viewport: {
+ var br = self.getTopLeft(.viewport);
+ br = br.down(self.rows - 1).?;
+ br.x = br.node.data.size.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;
+ break :active br;
+ },
+ };
+}
+
+/// The total rows in the screen. This is the actual row count currently
+/// and not a capacity or maximum.
+///
+/// This is very slow, it traverses the full list of pages to count the
+/// rows, so it is not pub. This is only used for testing/debugging.
+fn totalRows(self: *const PageList) usize {
+ var rows: usize = 0;
+ var node_ = self.pages.first;
+ while (node_) |node| {
+ rows += node.data.size.rows;
+ node_ = node.next;
+ }
+
+ return rows;
+}
+
+/// The total number of pages in this list. This should only be used
+/// for tests since it is O(N) over the list of pages.
+pub fn totalPages(self: *const PageList) usize {
+ var pages: usize = 0;
+ var node_ = self.pages.first;
+ while (node_) |node| {
+ pages += 1;
+ node_ = node.next;
+ }
+
+ return pages;
+}
+
+/// 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 {
+ for (0..n) |_| _ = try self.grow();
+}
+
+/// Clear all dirty bits on all pages. This is not efficient since it
+/// traverses the entire list of pages. This is used for testing/debugging.
+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| {
+ row.dirty = false;
+ }
+ }
+}
+
+/// Returns true if the point is dirty, used for testing.
+pub fn isDirty(self: *const PageList, pt: point.Point) bool {
+ return self.getCell(pt).?.isDirty();
+}
+
+/// Mark a point as dirty, used for testing.
+fn markDirty(self: *PageList, pt: point.Point) void {
+ self.pin(pt).?.markDirty();
+}
+
+/// Represents an exact x/y coordinate within the screen. This is called
+/// a "pin" because it is a fixed point within the pagelist direct to
+/// a specific page pointer and memory offset. The benefit is that this
+/// point remains valid even through scrolling without any additional work.
+///
+/// A downside is that the pin is only valid until the pagelist is modified
+/// in a way that may invalidate page pointers or shuffle rows, such as resizing,
+/// erasing rows, etc.
+///
+/// A pin can also be "tracked" which means that it will be updated as the
+/// PageList is modified.
+///
+/// The PageList maintains a list of active pin references and keeps them
+/// all up to date as the pagelist is modified. This isn't cheap so callers
+/// should limit the number of active pins as much as possible.
+pub const Pin = struct {
+ node: *List.Node,
+ y: size.CellCountInt = 0,
+ x: size.CellCountInt = 0,
+
+ /// This is flipped to true for tracked pins that were tracking
+ /// a page that got pruned for any reason and where the tracked pin
+ /// couldn't be moved to a sensical location. Users of the tracked
+ /// pin could use this data and make their own determination of
+ /// semantics.
+ garbage: bool = false,
+
+ pub inline fn rowAndCell(self: Pin) struct {
+ row: *pagepkg.Row,
+ cell: *pagepkg.Cell,
+ } {
+ const rac = self.node.data.getRowAndCell(self.x, self.y);
+ return .{ .row = rac.row, .cell = rac.cell };
+ }
+
+ pub const CellSubset = enum { all, left, right };
+
+ /// Returns the cells for the row that this pin is on. The subset determines
+ /// 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);
+ return switch (subset) {
+ .all => all,
+ .left => all[0 .. self.x + 1],
+ .right => all[self.x..],
+ };
+ }
+
+ /// 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);
+ }
+
+ /// 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,
+ 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;
+ }
+
+ /// Mark this pin location as dirty.
+ pub inline fn markDirty(self: Pin) void {
+ self.rowAndCell().row.dirty = true;
+ }
+
+ /// Iterators. These are the same as PageList iterator funcs but operate
+ /// on pins rather than points. This is MUCH more efficient than calling
+ /// pointFromPin and building up the iterator from points.
+ ///
+ /// The limit pin is inclusive.
+ pub inline fn pageIterator(
+ self: Pin,
+ direction: Direction,
+ limit: ?Pin,
+ ) PageIterator {
+ if (build_options.slow_runtime_safety) {
+ if (limit) |l| {
+ // Check the order according to the iteration direction.
+ switch (direction) {
+ .right_down => assert(self.eql(l) or self.before(l)),
+ .left_up => assert(self.eql(l) or l.before(self)),
+ }
+ }
+ }
+
+ return .{
+ .row = self,
+ .limit = if (limit) |p| .{ .row = p } else .{ .none = {} },
+ .direction = direction,
+ };
+ }
+
+ pub inline fn rowIterator(
+ self: Pin,
+ direction: Direction,
+ limit: ?Pin,
+ ) RowIterator {
+ var page_it = self.pageIterator(direction, limit);
+ const chunk = page_it.next() orelse return .{ .page_it = page_it };
+ return .{
+ .page_it = page_it,
+ .chunk = chunk,
+ .offset = switch (direction) {
+ .right_down => chunk.start,
+ .left_up => chunk.end - 1,
+ },
+ };
+ }
+
+ pub inline fn cellIterator(
+ self: Pin,
+ direction: Direction,
+ limit: ?Pin,
+ ) CellIterator {
+ var row_it = self.rowIterator(direction, limit);
+ var cell = row_it.next() orelse return .{ .row_it = row_it };
+ cell.x = self.x;
+ return .{ .row_it = row_it, .cell = cell };
+ }
+
+ pub inline fn promptIterator(
+ self: Pin,
+ direction: Direction,
+ limit: ?Pin,
+ ) PromptIterator {
+ return .{
+ .current = self,
+ .limit = limit,
+ .direction = direction,
+ };
+ }
+
+ /// Returns true if this pin is between the top and bottom, inclusive.
+ //
+ // Note: this is primarily unit tested as part of the Kitty
+ // graphics deletion code.
+ pub fn isBetween(self: Pin, top: Pin, bottom: Pin) bool {
+ if (build_options.slow_runtime_safety) {
+ if (top.node == bottom.node) {
+ // If top is bottom, must be ordered.
+ assert(top.y <= bottom.y);
+ if (top.y == bottom.y) {
+ assert(top.x <= bottom.x);
+ }
+ } else {
+ // If top is not bottom, top must be before bottom.
+ var node_ = top.node.next;
+ while (node_) |node| : (node_ = node.next) {
+ if (node == bottom.node) break;
+ } else assert(false);
+ }
+ }
+
+ if (self.node == top.node) {
+ // If our pin is the top page and our y is less than the top y
+ // then we can't possibly be between the top and bottom.
+ if (self.y < top.y) return false;
+
+ // If our y is after the top y but we're on the same page
+ // then we're between the top and bottom if our y is less
+ // than or equal to the bottom y if its the same page. If the
+ // bottom is another page then it means that the range is
+ // at least the full top page and since we're the same page
+ // we're in the range.
+ if (self.y > top.y) {
+ return if (self.node == bottom.node)
+ self.y <= bottom.y
+ else
+ true;
+ }
+
+ // Otherwise our y is the same as the top y, so we need to
+ // check the x coordinate.
+ assert(self.y == top.y);
+ if (self.x < top.x) return false;
+ }
+ if (self.node == bottom.node) {
+ // Our page is the bottom page so we're between the top and
+ // bottom if our y is less than the bottom y.
+ if (self.y > bottom.y) return false;
+ if (self.y < bottom.y) return true;
+
+ // If our y is the same, then we're between if we're before
+ // or equal to the bottom x.
+ assert(self.y == bottom.y);
+ return self.x <= bottom.x;
+ }
+
+ // Our page isn't the top or bottom so we need to check if
+ // our page is somewhere between the top and bottom.
+
+ // Since our loop starts at top.page.next we need to check that
+ // top != bottom because if they're the same then we can't possibly
+ // be between them.
+ if (top.node == bottom.node) return false;
+ var node_ = top.node.next;
+ while (node_) |node| : (node_ = node.next) {
+ if (node == bottom.node) break;
+ if (node == self.node) return true;
+ }
+
+ return false;
+ }
+
+ /// Returns true if self is before other. This is very expensive since
+ /// it requires traversing the linked list of pages. This should not
+ /// be called in performance critical paths.
+ pub fn before(self: Pin, other: Pin) bool {
+ if (self.node == other.node) {
+ if (self.y < other.y) return true;
+ if (self.y > other.y) return false;
+ return self.x < other.x;
+ }
+
+ var node_ = self.node.next;
+ while (node_) |node| : (node_ = node.next) {
+ if (node == other.node) return true;
+ }
+
+ return false;
+ }
+
+ pub inline fn eql(self: Pin, other: Pin) bool {
+ return self.node == other.node and
+ self.y == other.y and
+ self.x == other.x;
+ }
+
+ /// Move the pin left n columns. n must fit within the size.
+ pub inline fn left(self: Pin, n: usize) Pin {
+ assert(n <= self.x);
+ var result = self;
+ result.x -= std.math.cast(size.CellCountInt, n) orelse result.x;
+ return result;
+ }
+
+ /// 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);
+ var result = self;
+ result.x +|= std.math.cast(size.CellCountInt, n) orelse
+ std.math.maxInt(size.CellCountInt);
+ return result;
+ }
+
+ /// Move the pin left n columns, stopping at the start of the row.
+ pub inline fn leftClamp(self: Pin, n: size.CellCountInt) Pin {
+ var result = self;
+ result.x -|= n;
+ return result;
+ }
+
+ /// 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);
+ return result;
+ }
+
+ /// Move the pin left n cells, wrapping to the previous row as needed.
+ ///
+ /// If the offset goes beyond the top of the screen, returns null.
+ ///
+ /// 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,
+ }
+ }
+
+ /// Move the pin right n cells, wrapping to the next row as needed.
+ ///
+ /// If the offset goes beyond the bottom of the screen, returns null.
+ ///
+ /// 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);
+ return result;
+ },
+ .overflow => return null,
+ }
+ }
+
+ /// Move the pin down a certain number of rows, or return null if
+ /// the pin goes beyond the end of the screen.
+ pub inline fn down(self: Pin, n: usize) ?Pin {
+ return switch (self.downOverflow(n)) {
+ .offset => |v| v,
+ .overflow => null,
+ };
+ }
+
+ /// Move the pin up a certain number of rows, or return null if
+ /// the pin goes beyond the start of the screen.
+ pub inline fn up(self: Pin, n: usize) ?Pin {
+ return switch (self.upOverflow(n)) {
+ .offset => |v| v,
+ .overflow => null,
+ };
+ }
+
+ /// Move the offset down n rows. If the offset goes beyond the
+ /// end of the screen, return the overflow amount.
+ pub fn downOverflow(self: Pin, n: usize) union(enum) {
+ offset: Pin,
+ overflow: struct {
+ end: Pin,
+ remaining: usize,
+ },
+ } {
+ // Index fits within this page
+ const rows = self.node.data.size.rows - (self.y + 1);
+ if (n <= rows) return .{ .offset = .{
+ .node = self.node,
+ .y = std.math.cast(size.CellCountInt, self.y + n) orelse
+ std.math.maxInt(size.CellCountInt),
+ .x = self.x,
+ } };
+
+ // Need to traverse page links to find the page
+ var node: *List.Node = self.node;
+ var n_left: usize = n - rows;
+ while (true) {
+ node = node.next orelse return .{ .overflow = .{
+ .end = .{
+ .node = node,
+ .y = node.data.size.rows - 1,
+ .x = self.x,
+ },
+ .remaining = n_left,
+ } };
+ if (n_left <= node.data.size.rows) return .{ .offset = .{
+ .node = node,
+ .y = std.math.cast(size.CellCountInt, n_left - 1) orelse
+ std.math.maxInt(size.CellCountInt),
+ .x = self.x,
+ } };
+ n_left -= node.data.size.rows;
+ }
+ }
+
+ /// Move the offset up n rows. If the offset goes beyond the
+ /// start of the screen, return the overflow amount.
+ pub fn upOverflow(self: Pin, n: usize) union(enum) {
+ offset: Pin,
+ overflow: struct {
+ end: Pin,
+ remaining: usize,
+ },
+ } {
+ // Index fits within this page
+ if (n <= self.y) return .{ .offset = .{
+ .node = self.node,
+ .y = std.math.cast(size.CellCountInt, self.y - n) orelse
+ std.math.maxInt(size.CellCountInt),
+ .x = self.x,
+ } };
+
+ // Need to traverse page links to find the page
+ var node: *List.Node = self.node;
+ var n_left: usize = n - self.y;
+ while (true) {
+ node = node.prev orelse return .{ .overflow = .{
+ .end = .{ .node = node, .y = 0, .x = self.x },
+ .remaining = n_left,
+ } };
+ if (n_left <= node.data.size.rows) return .{ .offset = .{
+ .node = node,
+ .y = std.math.cast(size.CellCountInt, node.data.size.rows - n_left) orelse
+ std.math.maxInt(size.CellCountInt),
+ .x = self.x,
+ } };
+ n_left -= node.data.size.rows;
+ }
+ }
+};
+
+pub const Cell = struct {
+ node: *List.Node,
+ row: *pagepkg.Row,
+ cell: *pagepkg.Cell,
+ row_idx: size.CellCountInt,
+ col_idx: size.CellCountInt,
+
+ /// Returns true if this cell is marked as dirty.
+ ///
+ /// 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;
+ }
+
+ /// Get the cell style.
+ ///
+ /// 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,
+ self.cell.style_id,
+ ).*;
+ }
+
+ /// Gets the screen point for the given cell.
+ ///
+ /// This is REALLY expensive/slow so it isn't pub. This was built
+ /// for debugging and tests. If you have a need for this outside of
+ /// 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 node_ = self.node;
+ while (node_.prev) |node| {
+ y += node.data.size.rows;
+ node_ = node;
+ }
+
+ return .{ .screen = .{
+ .x = self.col_idx,
+ .y = y,
+ } };
+ }
+};
+
+test "PageList" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try testing.expect(s.viewport == .active);
+ try testing.expect(s.pages.first != null);
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ // Initial total rows should be our row count
+ try testing.expectEqual(s.rows, s.total_rows);
+
+ // Our viewport pin must be defined. It isn't used until the
+ // viewport is a pin but it prevents undefined access on clone.
+ try testing.expect(s.viewport_pin.node == s.pages.first.?);
+
+ // Active area should be the top
+ try testing.expectEqual(Pin{
+ .node = s.pages.first.?,
+ .y = 0,
+ .x = 0,
+ }, s.getTopLeft(.active));
+
+ // Scrollbar should be where we expect it
+ try testing.expectEqual(Scrollbar{
+ .total = s.rows,
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList init error" {
+ // Test every failure point in `init` and ensure that we don't
+ // leak memory (testing.allocator verifies) since we're exiting early.
+ for (std.meta.tags(init_tw.FailPoint)) |tag| {
+ const tw = init_tw;
+ defer tw.end(.reset) catch unreachable;
+ tw.errorAlways(tag, error.OutOfMemory);
+ try std.testing.expectError(
+ error.OutOfMemory,
+ init(
+ std.testing.allocator,
+ 80,
+ 24,
+ null,
+ ),
+ );
+ }
+
+ // init calls initPages transitively, so let's check that if
+ // any failures happen in initPages, we also don't leak memory.
+ for (std.meta.tags(initPages_tw.FailPoint)) |tag| {
+ const tw = initPages_tw;
+ defer tw.end(.reset) catch unreachable;
+ tw.errorAlways(tag, error.OutOfMemory);
+
+ const cols: size.CellCountInt = if (tag == .page_buf_std) 80 else std_capacity.maxCols().? + 1;
+ try std.testing.expectError(
+ error.OutOfMemory,
+ init(
+ std.testing.allocator,
+ cols,
+ 24,
+ null,
+ ),
+ );
+ }
+
+ // Try non-standard pages since they don't go in our pool.
+ for ([_]initPages_tw.FailPoint{
+ .page_buf_non_std,
+ }) |tag| {
+ const tw = initPages_tw;
+ defer tw.end(.reset) catch unreachable;
+ tw.errorAfter(tag, error.OutOfMemory, 1);
+ try std.testing.expectError(
+ error.OutOfMemory,
+ init(
+ std.testing.allocator,
+ std_capacity.maxCols().? + 1,
+ std_capacity.rows + 1,
+ null,
+ ),
+ );
+ }
+}
+
+test "PageList init rows across two pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Find a cap that makes it so that rows don't fit on one page.
+ const rows = 100;
+ const cap = cap: {
+ var cap = try std_capacity.adjust(.{ .cols = 50 });
+ while (cap.rows >= rows) cap = try std_capacity.adjust(.{
+ .cols = cap.cols + 50,
+ });
+
+ break :cap cap;
+ };
+
+ // Init
+ var s = try init(alloc, cap.cols, rows, null);
+ defer s.deinit();
+ try testing.expect(s.viewport == .active);
+ try testing.expect(s.pages.first != null);
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ // Initial total rows should be our row count
+ try testing.expectEqual(s.rows, s.total_rows);
+
+ // Scrollbar should be where we expect it
+ try testing.expectEqual(Scrollbar{
+ .total = s.rows,
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList init more than max cols" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Initialize with more columns than we can fit in our standard
+ // capacity. This is going to force us to go to a non-standard page
+ // immediately.
+ var s = try init(
+ alloc,
+ std_capacity.maxCols().? + 1,
+ 80,
+ null,
+ );
+ defer s.deinit();
+ try testing.expect(s.viewport == .active);
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ // 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);
+
+ // Initial total rows should be our row count
+ try testing.expectEqual(s.rows, s.total_rows);
+
+ // Scrollbar should be where we expect it
+ try testing.expectEqual(Scrollbar{
+ .total = s.rows,
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList pointFromPin active no history" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ {
+ try testing.expectEqual(point.Point{
+ .active = .{
+ .y = 0,
+ .x = 0,
+ },
+ }, s.pointFromPin(.active, .{
+ .node = s.pages.first.?,
+ .y = 0,
+ .x = 0,
+ }).?);
+ }
+ {
+ try testing.expectEqual(point.Point{
+ .active = .{
+ .y = 2,
+ .x = 4,
+ },
+ }, s.pointFromPin(.active, .{
+ .node = s.pages.first.?,
+ .y = 2,
+ .x = 4,
+ }).?);
+ }
+}
+
+test "PageList pointFromPin active with history" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(30);
+
+ {
+ try testing.expectEqual(point.Point{
+ .active = .{
+ .y = 0,
+ .x = 2,
+ },
+ }, s.pointFromPin(.active, .{
+ .node = s.pages.first.?,
+ .y = 30,
+ .x = 2,
+ }).?);
+ }
+
+ // In history, invalid
+ {
+ try testing.expect(s.pointFromPin(.active, .{
+ .node = s.pages.first.?,
+ .y = 21,
+ .x = 2,
+ }) == null);
+ }
+}
+
+test "PageList pointFromPin active from prior page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ var cur_page = s.pages.last.?;
+ cur_page.data.pauseIntegrityChecks(true);
+ for (0..page.capacity.rows * 5) |_| {
+ if (try s.grow()) |new_page| {
+ cur_page.data.pauseIntegrityChecks(false);
+ cur_page = new_page;
+ cur_page.data.pauseIntegrityChecks(true);
+ }
+ }
+ cur_page.data.pauseIntegrityChecks(false);
+
+ {
+ try testing.expectEqual(point.Point{
+ .active = .{
+ .y = 0,
+ .x = 2,
+ },
+ }, s.pointFromPin(.active, .{
+ .node = s.pages.last.?,
+ .y = 0,
+ .x = 2,
+ }).?);
+ }
+
+ // Prior page
+ {
+ try testing.expect(s.pointFromPin(.active, .{
+ .node = s.pages.first.?,
+ .y = 0,
+ .x = 0,
+ }) == null);
+ }
+}
+
+test "PageList pointFromPin traverse pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow so we take up at least 2 pages.
+ const page = &s.pages.last.?.data;
+ var cur_page = s.pages.last.?;
+ cur_page.data.pauseIntegrityChecks(true);
+ for (0..page.capacity.rows * 2) |_| {
+ if (try s.grow()) |new_page| {
+ cur_page.data.pauseIntegrityChecks(false);
+ cur_page = new_page;
+ cur_page.data.pauseIntegrityChecks(true);
+ }
+ }
+ cur_page.data.pauseIntegrityChecks(false);
+
+ {
+ const pages = s.totalPages();
+ const page_cap = page.capacity.rows;
+ const expected_y = page_cap * (pages - 2) + 5;
+
+ try testing.expectEqual(point.Point{
+ .screen = .{
+ .y = @intCast(expected_y),
+ .x = 2,
+ },
+ }, s.pointFromPin(.screen, .{
+ .node = s.pages.last.?.prev.?,
+ .y = 5,
+ .x = 2,
+ }).?);
+ }
+
+ // Prior page
+ {
+ try testing.expect(s.pointFromPin(.active, .{
+ .node = s.pages.first.?,
+ .y = 0,
+ .x = 0,
+ }) == null);
+ }
+}
+test "PageList active after grow" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ try s.growRows(10);
+ try testing.expectEqual(@as(usize, s.rows + 10), s.totalRows());
+
+ // Make sure all points make sense
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+ {
+ const pt = s.getCell(.{ .screen = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+
+ // Scrollbar should be in the active area
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 10,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList grow allows exceeding max size for active area" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Setup our initial page so that we fully take up one page.
+ const cap = try std_capacity.adjust(.{ .cols = 5 });
+ var s = try init(alloc, 5, cap.rows, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ // Grow once because we guarantee at least two pages of
+ // capacity so we want to get to that.
+ _ = try s.grow();
+ const start_pages = s.totalPages();
+ try testing.expect(start_pages >= 2);
+
+ // Surgically modify our pages so that they have a smaller size.
+ {
+ var it = s.pages.first;
+ while (it) |page| : (it = page.next) {
+ page.data.size.rows = 1;
+ page.data.capacity.rows = 1;
+ }
+
+ // Avoid integrity check failures
+ s.total_rows = s.totalRows();
+ }
+
+ // Grow our row and ensure we don't prune pages because we need
+ // enough for the active area.
+ _ = try s.grow();
+ try testing.expectEqual(start_pages + 1, s.totalPages());
+}
+
+test "PageList grow prune required with a single page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Need scrollback > 0 to have a scrollbar to test
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // This block is all test setup. There is nothing required about this
+ // behavior during a refactor. This is setting up a scenario that is
+ // possible to trigger a bug (#2280).
+ {
+ // Increase our capacity until our page is larger than the standard size.
+ // 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);
+ if (layout.total_size > std_size) break;
+ _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes);
+ }
+ try testing.expect(s.pages.first != null);
+ try testing.expect(s.pages.first == s.pages.last);
+ }
+
+ // Figure out the remaining number of rows. This is the amount that
+ // can be added to the current page before we need to allocate a new
+ // page.
+ const rem = rem: {
+ const page = s.pages.first.?;
+ break :rem page.data.capacity.rows - page.data.size.rows;
+ };
+ for (0..rem) |_| try testing.expect(try s.grow() == null);
+
+ // The next one we add will trigger a new page.
+ const new = try s.grow();
+ try testing.expect(new != null);
+ try testing.expect(new != s.pages.first);
+
+ // Scrollbar should be in the active area
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scrollbar with max_size 0 after grow" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, 0);
+ defer s.deinit();
+
+ // Grow some rows (simulates normal terminal output)
+ try s.growRows(10);
+
+ const sb = s.scrollbar();
+
+ // With no scrollback (max_size = 0), total should equal rows
+ try testing.expectEqual(s.rows, sb.total);
+
+ // With no scrollback, offset should be 0 (nowhere to scroll back to)
+ try testing.expectEqual(@as(usize, 0), sb.offset);
+}
+
+test "PageList scroll with max_size 0 no history" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, 0);
+ defer s.deinit();
+
+ try s.growRows(10);
+
+ // Remember initial viewport position
+ const pt_before = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+
+ // Try to scroll backwards into "history" - should be no-op
+ s.scroll(.{ .delta_row = -5 });
+ try testing.expect(s.viewport == .active);
+
+ // Scroll to top - should also be no-op with no scrollback
+ s.scroll(.{ .top = {} });
+ const pt_after = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(pt_before, pt_after);
+}
+
+test "PageList scroll top" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(10);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+
+ s.scroll(.{ .top = {} });
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ try s.growRows(10);
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ s.scroll(.{ .active = {} });
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 20,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll delta row back" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(10);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+
+ s.scroll(.{ .delta_row = -1 });
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = s.total_rows - s.rows - 1,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 9,
+ } }, pt);
+ }
+
+ try s.growRows(10);
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 9,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = s.total_rows - s.rows - 11,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ s.scroll(.{ .delta_row = -1 });
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = s.total_rows - s.rows - 12,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll delta row back overflow" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(10);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+
+ s.scroll(.{ .delta_row = -100 });
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ try s.growRows(10);
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll delta row forward" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(10);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+
+ s.scroll(.{ .top = {} });
+ s.scroll(.{ .delta_row = 2 });
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 2,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, pt);
+ }
+
+ try s.growRows(10);
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 2,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll delta row forward into active" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ s.scroll(.{ .delta_row = 2 });
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll delta row back without space preserves active" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ s.scroll(.{ .delta_row = -1 });
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ try testing.expect(s.viewport == .active);
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll to pin" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(10);
+
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{
+ .y = 4,
+ .x = 2,
+ } }).? });
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 4,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 4,
+ } }, pt);
+ }
+
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{
+ .y = 5,
+ .x = 2,
+ } }).? });
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 5,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, pt);
+ }
+}
+
+test "PageList scroll to pin in active" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(10);
+
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{
+ .y = 30,
+ .x = 2,
+ } }).? });
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+}
+
+test "PageList scroll to pin at top" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(10);
+
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{
+ .y = 0,
+ .x = 2,
+ } }).? });
+
+ try testing.expect(s.viewport == .top);
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.totalRows(),
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+}
+
+test "PageList scroll to row 0" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(10);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+
+ s.scroll(.{ .row = 0 });
+ try testing.expect(s.viewport == .top);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ try s.growRows(10);
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 0,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll to row in scrollback" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(20);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 20,
+ } }, pt);
+ }
+
+ s.scroll(.{ .row = 5 });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 5,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, pt);
+ }
+
+ try s.growRows(10);
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 5,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll to row in middle" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(50);
+
+ const total = s.total_rows;
+ const midpoint = total / 2;
+ s.scroll(.{ .row = midpoint });
+
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = midpoint,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = @as(size.CellCountInt, @intCast(midpoint)),
+ } }, pt);
+ }
+
+ try s.growRows(10);
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = @as(size.CellCountInt, @intCast(midpoint)),
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = midpoint,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll to row at active boundary" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(20);
+
+ const active_start = s.total_rows - s.rows;
+
+ s.scroll(.{ .row = active_start });
+
+ try testing.expect(s.viewport == .active);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = @as(size.CellCountInt, @intCast(active_start)),
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ try s.growRows(10);
+
+ try testing.expect(s.viewport == .active);
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll to row beyond active" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(10);
+
+ s.scroll(.{ .row = 1000 });
+
+ try testing.expect(s.viewport == .active);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll to row without scrollback" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ s.scroll(.{ .row = 5 });
+
+ try testing.expect(s.viewport == .active);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll to row then delta" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(30);
+
+ s.scroll(.{ .row = 10 });
+
+ try testing.expect(s.viewport == .pin);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 10,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ s.scroll(.{ .delta_row = 5 });
+
+ try testing.expect(s.viewport == .pin);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 15,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 15,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ s.scroll(.{ .delta_row = -3 });
+
+ try testing.expect(s.viewport == .pin);
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 12,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 12,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll to row with cache fast path down" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(50);
+
+ s.scroll(.{ .row = 10 });
+
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 10,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, pt);
+ }
+
+ // Verify cache is populated
+ try testing.expect(s.viewport_pin_row_offset != null);
+ try testing.expectEqual(@as(usize, 10), s.viewport_pin_row_offset.?);
+
+ // Now scroll to a different row - this should use the fast path
+ s.scroll(.{ .row = 20 });
+
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 20,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 20,
+ } }, pt);
+ }
+
+ try s.growRows(10);
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 20,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 20,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll to row with cache fast path up" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(50);
+
+ s.scroll(.{ .row = 30 });
+
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 30,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 30,
+ } }, pt);
+ }
+
+ // Verify cache is populated
+ try testing.expect(s.viewport_pin_row_offset != null);
+ try testing.expectEqual(@as(usize, 30), s.viewport_pin_row_offset.?);
+
+ // Now scroll up to a different row - this should use the fast path
+ s.scroll(.{ .row = 15 });
+
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 15,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 15,
+ } }, pt);
+ }
+
+ try s.growRows(10);
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 15,
+ } }, pt);
+ }
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 15,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList scroll clear" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ {
+ const cell = s.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?;
+ cell.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+ {
+ const cell = s.getCell(.{ .active = .{ .x = 0, .y = 1 } }).?;
+ cell.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+
+ try s.scrollClear();
+
+ {
+ const pt = s.getCell(.{ .viewport = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, pt);
+ }
+}
+
+test "PageList: jump zero prompts" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 5, 3, null);
+ defer s.deinit();
+ try s.growRows(3);
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.semantic_prompt = .prompt;
+ }
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ s.scroll(.{ .delta_prompt = 0 });
+ try testing.expect(s.viewport == .active);
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "Screen: jump back one prompt" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 5, 3, null);
+ defer s.deinit();
+ try s.growRows(3);
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.semantic_prompt = .prompt;
+ }
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Jump back
+ {
+ s.scroll(.{ .delta_prompt = -1 });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 1,
+ } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?);
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 1,
+ .len = s.rows,
+ }, s.scrollbar());
+ }
+ {
+ s.scroll(.{ .delta_prompt = -1 });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 1,
+ } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?);
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 1,
+ .len = s.rows,
+ }, s.scrollbar());
+ }
+
+ // Jump forward
+ {
+ s.scroll(.{ .delta_prompt = 1 });
+ try testing.expect(s.viewport == .active);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+ }
+ {
+ s.scroll(.{ .delta_prompt = 1 });
+ try testing.expect(s.viewport == .active);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = s.total_rows - s.rows,
+ .len = s.rows,
+ }, s.scrollbar());
+ }
+}
+
+test "Screen: jump forward prompt skips multiline continuation" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 5, 3, null);
+ defer s.deinit();
+ try s.growRows(7);
+
+ // Multiline prompt on rows 1-3.
+ {
+ const p = s.pin(.{ .screen = .{ .y = 1 } }).?;
+ p.rowAndCell().row.semantic_prompt = .prompt;
+ }
+ {
+ const p = s.pin(.{ .screen = .{ .y = 2 } }).?;
+ p.rowAndCell().row.semantic_prompt = .prompt_continuation;
+ }
+ {
+ const p = s.pin(.{ .screen = .{ .y = 3 } }).?;
+ p.rowAndCell().row.semantic_prompt = .prompt_continuation;
+ }
+
+ // Next prompt after command output.
+ {
+ const p = s.pin(.{ .screen = .{ .y = 6 } }).?;
+ p.rowAndCell().row.semantic_prompt = .prompt;
+ }
+
+ // Starting at the first prompt line should jump to the next prompt,
+ // not to continuation lines.
+ s.scroll(.{ .row = 1 });
+ s.scroll(.{ .delta_prompt = 1 });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 6,
+ } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?);
+
+ // Starting in the middle of continuation lines should also jump to
+ // the next prompt.
+ s.scroll(.{ .row = 2 });
+ s.scroll(.{ .delta_prompt = 1 });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 6,
+ } }, s.pointFromPin(.screen, s.pin(.{ .viewport = .{} }).?).?);
+}
+
+test "PageList grow fit in capacity" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // So we know we're using capacity to grow
+ const last = &s.pages.last.?.data;
+ try testing.expect(last.size.rows < last.capacity.rows);
+
+ // Grow
+ try testing.expect(try s.grow() == null);
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 1,
+ } }, pt);
+ }
+}
+
+test "PageList grow allocate" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow to capacity
+ const last_node = s.pages.last.?;
+ const last = &s.pages.last.?.data;
+ for (0..last.capacity.rows - last.size.rows) |_| {
+ try testing.expect(try s.grow() == null);
+ }
+
+ // Grow, should allocate
+ const new = (try s.grow()).?;
+ try testing.expect(s.pages.last.? == new);
+ try testing.expect(last_node.next.? == new);
+ {
+ const cell = s.getCell(.{ .active = .{ .y = s.rows - 1 } }).?;
+ try testing.expect(cell.node == new);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = last.capacity.rows,
+ } }, cell.screenPoint());
+ }
+}
+
+test "PageList grow prune scrollback" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Use std_size to limit scrollback so pruning is triggered.
+ var s = try init(alloc, 80, 24, std_size);
+ defer s.deinit();
+
+ // Grow to capacity
+ const page1_node = s.pages.last.?;
+ const page1 = page1_node.data;
+ 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;
+ for (0..page2.capacity.rows - page2.size.rows) |_| {
+ try testing.expect(try s.grow() == null);
+ }
+
+ // Get our page size
+ const old_page_size = s.page_size;
+
+ // Create a tracked pin in the first page
+ const p = try s.trackPin(s.pin(.{ .screen = .{} }).?);
+ defer s.untrackPin(p);
+ try testing.expect(p.node == s.pages.first.?);
+
+ // Scroll back to create a pinned viewport (not active)
+ const pin_y = page1.capacity.rows / 2;
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? });
+ try testing.expect(s.viewport == .pin);
+
+ // Get the scrollbar state to populate the cache
+ const scrollbar_before = s.scrollbar();
+ try testing.expectEqual(pin_y, scrollbar_before.offset);
+
+ // Next should create a new page, but it should reuse our first
+ // page since we're at max size.
+ const new = (try s.grow()).?;
+ try testing.expect(s.pages.last.? == new);
+ try testing.expectEqual(s.page_size, old_page_size);
+
+ // Our first should now be page2 and our last should be page1
+ try testing.expectEqual(page2_node, s.pages.first.?);
+ try testing.expectEqual(page1_node, s.pages.last.?);
+
+ // Our tracked pin should point to the top-left of the first page
+ try testing.expect(p.node == s.pages.first.?);
+ try testing.expect(p.x == 0);
+ try testing.expect(p.y == 0);
+ try testing.expect(p.garbage);
+
+ // Verify the viewport offset cache was invalidated. After pruning,
+ // the offset should have changed because we removed rows from
+ // the beginning.
+ {
+ const scrollbar_after = s.scrollbar();
+ const rows_pruned = page1.capacity.rows;
+ const expected_offset = if (pin_y >= rows_pruned)
+ pin_y - rows_pruned
+ else
+ 0;
+ try testing.expectEqual(expected_offset, scrollbar_after.offset);
+ }
+}
+
+test "PageList grow prune scrollback with viewport pin not in pruned page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Use std_size to limit scrollback so pruning is triggered.
+ var s = try init(alloc, 80, 24, std_size);
+ defer s.deinit();
+
+ // Grow to capacity of first page
+ const page1_node = s.pages.last.?;
+ const page1 = page1_node.data;
+ 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;
+ for (0..page2.capacity.rows - page2.size.rows) |_| {
+ try testing.expect(try s.grow() == null);
+ }
+
+ // Get our page size
+ const old_page_size = s.page_size;
+
+ // Scroll back to create a pinned viewport in page2 (NOT page1)
+ // This is the key difference from the previous test - the viewport
+ // pin is NOT in the page that will be pruned.
+ const pin_y = page1.capacity.rows + 5;
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? });
+ try testing.expect(s.viewport == .pin);
+ try testing.expect(s.viewport_pin.node == page2_node);
+
+ // Get the scrollbar state to populate the cache
+ const scrollbar_before = s.scrollbar();
+ try testing.expectEqual(pin_y, scrollbar_before.offset);
+
+ // Next grow will trigger pruning of the first page.
+ // The viewport_pin.node is page2, not page1, so it won't be moved
+ // by the pin update loop, but the cached offset still needs to be
+ // invalidated because rows were removed from the beginning.
+ const new = (try s.grow()).?;
+ try testing.expect(s.pages.last.? == new);
+ try testing.expectEqual(s.page_size, old_page_size);
+
+ // Our first should now be page2 (page1 was pruned)
+ try testing.expectEqual(page2_node, s.pages.first.?);
+
+ // The viewport pin should still be on page2, unchanged
+ try testing.expect(s.viewport_pin.node == page2_node);
+
+ // Verify the viewport offset cache was invalidated/updated.
+ // After pruning, the offset should have decreased by the number
+ // of rows that were pruned.
+ const scrollbar_after = s.scrollbar();
+ const rows_pruned = page1.capacity.rows;
+ const expected_offset = pin_y - rows_pruned;
+ try testing.expectEqual(expected_offset, scrollbar_after.offset);
+}
+
+test "PageList eraseRows invalidates viewport offset cache" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow so we take up several pages worth of history
+ const page = &s.pages.last.?.data;
+ {
+ var cur_page = s.pages.last.?;
+ for (0..page.capacity.rows * 3) |_| {
+ if (try s.grow()) |new_page| cur_page = new_page;
+ }
+ }
+
+ // Scroll back to create a pinned viewport somewhere in the middle
+ // of the scrollback
+ const pin_y = page.capacity.rows;
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ // Erase some history rows BEFORE the viewport pin.
+ // This removes rows from before our pin, which changes its absolute
+ // offset from the top, but the cache is not invalidated.
+ const rows_to_erase = page.capacity.rows / 2;
+ s.eraseHistory(.{ .history = .{ .y = rows_to_erase - 1 } });
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y - rows_to_erase,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList eraseRow invalidates viewport offset cache" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow so we take up several pages worth of history
+ const page = &s.pages.last.?.data;
+ {
+ var cur_page = s.pages.last.?;
+ for (0..page.capacity.rows * 3) |_| {
+ if (try s.grow()) |new_page| cur_page = new_page;
+ }
+ }
+
+ // Scroll back to create a pinned viewport somewhere in the middle
+ // of the scrollback
+ const pin_y = page.capacity.rows;
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ // Erase a single row from the history BEFORE the viewport pin.
+ // This removes one row from before our pin, which changes its absolute
+ // offset from the top by 1, but the cache is not invalidated.
+ try s.eraseRow(.{ .history = .{ .y = 0 } });
+
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y - 1,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList eraseRowBounded invalidates viewport offset cache" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow so we take up several pages worth of history
+ const page = &s.pages.last.?.data;
+ {
+ var cur_page = s.pages.last.?;
+ for (0..page.capacity.rows * 3) |_| {
+ if (try s.grow()) |new_page| cur_page = new_page;
+ }
+ }
+
+ // Scroll back to create a pinned viewport somewhere in the middle
+ // of the scrollback
+ const pin_y: u16 = 4;
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ // Erase a row from the history BEFORE the viewport pin with a bounded
+ // shift. This removes one row from before our pin, which changes its
+ // absolute offset from the top by 1, but the cache is not invalidated.
+ try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, 10);
+
+ // Verify the scrollbar reflects the change (offset decreased by 1)
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y - 1,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList eraseRowBounded multi-page invalidates viewport offset cache" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow so we take up several pages worth of history
+ const page = &s.pages.last.?.data;
+ {
+ var cur_page = s.pages.last.?;
+ for (0..page.capacity.rows * 3) |_| {
+ if (try s.grow()) |new_page| cur_page = new_page;
+ }
+ }
+
+ // Scroll back to create a pinned viewport somewhere in the middle
+ // of the scrollback, after the first page
+ const pin_y = page.capacity.rows + 1;
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ // Erase a row from the beginning of history with a limit that spans
+ // across multiple pages. This ensures we hit the code path where
+ // eraseRowBounded finds the limit boundary in a subsequent page.
+ const limit = page.capacity.rows + 10;
+ try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, limit);
+
+ // Verify the scrollbar reflects the change (offset decreased by 1)
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y - 1,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList eraseRowBounded full page shift invalidates viewport offset cache" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow so we take up several pages worth of history
+ const page = &s.pages.last.?.data;
+ {
+ var cur_page = s.pages.last.?;
+ for (0..page.capacity.rows * 4) |_| {
+ if (try s.grow()) |new_page| cur_page = new_page;
+ }
+ }
+
+ // Scroll back to create a pinned viewport somewhere well beyond
+ // the first two pages
+ const pin_y = 5;
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ // Erase a row from the beginning of history with a limit that is
+ // larger than multiple full pages. This ensures we hit the code path
+ // where eraseRowBounded continues looping through entire pages,
+ // rotating all rows in each page until it reaches the limit or
+ // runs out of pages.
+ const limit = page.capacity.rows * 2 + 10;
+ try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, limit);
+
+ // Verify the scrollbar reflects the change (offset decreased by 1)
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y - 1,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList eraseRowBounded exhausts pages invalidates viewport offset cache" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow so we take up several pages worth of history
+ const page = &s.pages.last.?.data;
+ {
+ var cur_page = s.pages.last.?;
+ for (0..page.capacity.rows * 3) |_| {
+ if (try s.grow()) |new_page| cur_page = new_page;
+ }
+ }
+
+ // Our total rows should include history
+ const total_rows_before = s.totalRows();
+ try testing.expect(total_rows_before > s.rows);
+
+ // Scroll back to create a pinned viewport somewhere in the history,
+ // well after the erase will complete
+ const pin_y = page.capacity.rows * 2 + 10;
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ // Erase a row from the beginning of history with a limit that is
+ // LARGER than all remaining pages combined. This ensures we exhaust
+ // all pages in the while loop and reach the cleanup code after the loop.
+ const limit = total_rows_before * 2;
+ try s.eraseRowBounded(.{ .history = .{ .y = 0 } }, limit);
+
+ // Verify the scrollbar reflects the change (offset decreased by 1)
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y - 1,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList increaseCapacity to increase styles" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 2, 0);
+ defer s.deinit();
+
+ const original_styles_cap = s.pages.first.?.data.capacity.styles;
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Write all our data so we can assert its the same after
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ }
+
+ // Increase our styles
+ _ = try s.increaseCapacity(s.pages.first.?, .styles);
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Verify capacity doubled
+ try testing.expectEqual(
+ original_styles_cap * 2,
+ page.capacity.styles,
+ );
+
+ // Verify data preserved
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ try testing.expectEqual(
+ @as(u21, @intCast(x)),
+ rac.cell.content.codepoint,
+ );
+ }
+ }
+ }
+}
+
+test "PageList increaseCapacity to increase graphemes" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 2, 0);
+ defer s.deinit();
+
+ const original_cap = s.pages.first.?.data.capacity.grapheme_bytes;
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ }
+
+ _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes);
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ try testing.expectEqual(original_cap * 2, page.capacity.grapheme_bytes);
+
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ try testing.expectEqual(
+ @as(u21, @intCast(x)),
+ rac.cell.content.codepoint,
+ );
+ }
+ }
+ }
+}
+
+test "PageList increaseCapacity to increase hyperlinks" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 2, 0);
+ defer s.deinit();
+
+ const original_cap = s.pages.first.?.data.capacity.hyperlink_bytes;
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ }
+
+ _ = try s.increaseCapacity(s.pages.first.?, .hyperlink_bytes);
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ try testing.expectEqual(original_cap * 2, page.capacity.hyperlink_bytes);
+
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ try testing.expectEqual(
+ @as(u21, @intCast(x)),
+ rac.cell.content.codepoint,
+ );
+ }
+ }
+ }
+}
+
+test "PageList increaseCapacity to increase string_bytes" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 2, 0);
+ defer s.deinit();
+
+ const original_cap = s.pages.first.?.data.capacity.string_bytes;
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ }
+
+ _ = try s.increaseCapacity(s.pages.first.?, .string_bytes);
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ try testing.expectEqual(original_cap * 2, page.capacity.string_bytes);
+
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ try testing.expectEqual(
+ @as(u21, @intCast(x)),
+ rac.cell.content.codepoint,
+ );
+ }
+ }
+ }
+}
+
+test "PageList increaseCapacity tracked pins" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 2, 0);
+ defer s.deinit();
+
+ // Create a tracked pin on the first page
+ const tracked = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 1 } }).?);
+ defer s.untrackPin(tracked);
+
+ const old_node = s.pages.first.?;
+ try testing.expectEqual(old_node, tracked.node);
+
+ // Increase capacity
+ const new_node = try s.increaseCapacity(s.pages.first.?, .styles);
+
+ // Pin should now point to the new node
+ try testing.expectEqual(new_node, tracked.node);
+ try testing.expectEqual(@as(size.CellCountInt, 1), tracked.x);
+ try testing.expectEqual(@as(size.CellCountInt, 1), tracked.y);
+}
+
+test "PageList increaseCapacity returns OutOfSpace at max capacity" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 2, 0);
+ defer s.deinit();
+
+ // Keep increasing styles capacity until we get OutOfSpace
+ const max_styles = std.math.maxInt(size.StyleCountInt);
+ while (true) {
+ _ = s.increaseCapacity(
+ s.pages.first.?,
+ .styles,
+ ) 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);
+ break;
+ };
+ }
+}
+
+test "PageList increaseCapacity after col shrink" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 2, 0);
+ defer s.deinit();
+
+ // Shrink columns
+ try s.resize(.{ .cols = 5, .reflow = false });
+ try testing.expectEqual(5, s.cols);
+
+ {
+ const page = &s.pages.first.?.data;
+ try testing.expectEqual(5, page.size.cols);
+ try testing.expect(page.capacity.cols >= 10);
+ }
+
+ // Increase capacity
+ _ = try s.increaseCapacity(s.pages.first.?, .styles);
+
+ {
+ const page = &s.pages.first.?.data;
+ // size.cols should still be 5, not reverted to capacity.cols
+ try testing.expectEqual(5, page.size.cols);
+ try testing.expectEqual(5, s.cols);
+ }
+}
+
+test "PageList increaseCapacity multi-page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // 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) |_| {
+ try testing.expect(try s.grow() == null);
+ }
+ page1_node.data.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;
+
+ // Increase capacity on the first page only
+ _ = try s.increaseCapacity(s.pages.first.?, .styles);
+
+ // First page capacity should be doubled
+ try testing.expectEqual(
+ page1_styles_cap * 2,
+ s.pages.first.?.data.capacity.styles,
+ );
+
+ // Second page should be unchanged
+ try testing.expectEqual(
+ page2_styles_cap,
+ s.pages.last.?.data.capacity.styles,
+ );
+}
+
+test "PageList increaseCapacity preserves dirty flag" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 4, 0);
+ defer s.deinit();
+
+ // Set page dirty flag and mark some rows as dirty
+ const page = &s.pages.first.?.data;
+ page.dirty = true;
+
+ const rows = page.rows.ptr(page.memory);
+ rows[0].dirty = true;
+ rows[1].dirty = false;
+ rows[2].dirty = true;
+ rows[3].dirty = false;
+
+ // Increase capacity
+ const new_node = try s.increaseCapacity(s.pages.first.?, .styles);
+
+ // The page dirty flag should be preserved
+ try testing.expect(new_node.data.dirty);
+
+ // Row dirty flags should be preserved
+ const new_rows = new_node.data.rows.ptr(new_node.data.memory);
+ try testing.expect(new_rows[0].dirty);
+ try testing.expect(!new_rows[1].dirty);
+ try testing.expect(new_rows[2].dirty);
+ try testing.expect(!new_rows[3].dirty);
+}
+
+test "PageList pageIterator single page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // The viewport should be within a single page
+ try testing.expect(s.pages.first.?.next == null);
+
+ // Iterate the active area
+ var it = s.pageIterator(.right_down, .{ .active = .{} }, null);
+ {
+ const chunk = it.next().?;
+ try testing.expect(chunk.node == s.pages.first.?);
+ try testing.expectEqual(@as(usize, 0), chunk.start);
+ try testing.expectEqual(@as(usize, s.rows), chunk.end);
+ }
+
+ // Should only have one chunk
+ try testing.expect(it.next() == null);
+}
+
+test "PageList pageIterator two pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow to capacity
+ const page1_node = s.pages.last.?;
+ const page1 = page1_node.data;
+ page1_node.data.pauseIntegrityChecks(true);
+ for (0..page1.capacity.rows - page1.size.rows) |_| {
+ try testing.expect(try s.grow() == null);
+ }
+ page1_node.data.pauseIntegrityChecks(false);
+ try testing.expect(try s.grow() != null);
+
+ // Iterate the active area
+ var it = s.pageIterator(.right_down, .{ .active = .{} }, null);
+ {
+ const chunk = it.next().?;
+ try testing.expect(chunk.node == s.pages.first.?);
+ const start = chunk.node.data.size.rows - s.rows + 1;
+ try testing.expectEqual(start, chunk.start);
+ try testing.expectEqual(chunk.node.data.size.rows, chunk.end);
+ }
+ {
+ const chunk = it.next().?;
+ try testing.expect(chunk.node == s.pages.last.?);
+ const start: usize = 0;
+ try testing.expectEqual(start, chunk.start);
+ try testing.expectEqual(start + 1, chunk.end);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList pageIterator history two pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow to capacity
+ const page1_node = s.pages.last.?;
+ const page1 = page1_node.data;
+ page1_node.data.pauseIntegrityChecks(true);
+ for (0..page1.capacity.rows - page1.size.rows) |_| {
+ try testing.expect(try s.grow() == null);
+ }
+ page1_node.data.pauseIntegrityChecks(false);
+ try testing.expect(try s.grow() != null);
+
+ // Iterate the active area
+ var it = s.pageIterator(.right_down, .{ .history = .{} }, null);
+ {
+ const active_tl = s.getTopLeft(.active);
+ const chunk = it.next().?;
+ try testing.expect(chunk.node == s.pages.first.?);
+ const start: usize = 0;
+ try testing.expectEqual(start, chunk.start);
+ try testing.expectEqual(active_tl.y, chunk.end);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList pageIterator reverse single page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // The viewport should be within a single page
+ try testing.expect(s.pages.first.?.next == null);
+
+ // Iterate the active area
+ var it = s.pageIterator(.left_up, .{ .active = .{} }, null);
+ {
+ const chunk = it.next().?;
+ try testing.expect(chunk.node == s.pages.first.?);
+ try testing.expectEqual(@as(usize, 0), chunk.start);
+ try testing.expectEqual(@as(usize, s.rows), chunk.end);
+ }
+
+ // Should only have one chunk
+ try testing.expect(it.next() == null);
+}
+
+test "PageList pageIterator reverse two pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow to capacity
+ const page1_node = s.pages.last.?;
+ const page1 = page1_node.data;
+ page1_node.data.pauseIntegrityChecks(true);
+ for (0..page1.capacity.rows - page1.size.rows) |_| {
+ try testing.expect(try s.grow() == null);
+ }
+ page1_node.data.pauseIntegrityChecks(false);
+ try testing.expect(try s.grow() != null);
+
+ // Iterate the active area
+ var it = s.pageIterator(.left_up, .{ .active = .{} }, null);
+ var count: usize = 0;
+ {
+ const chunk = it.next().?;
+ try testing.expect(chunk.node == s.pages.last.?);
+ const start: usize = 0;
+ try testing.expectEqual(start, chunk.start);
+ try testing.expectEqual(start + 1, chunk.end);
+ count += chunk.end - chunk.start;
+ }
+ {
+ const chunk = it.next().?;
+ try testing.expect(chunk.node == s.pages.first.?);
+ const start = chunk.node.data.size.rows - s.rows + 1;
+ try testing.expectEqual(start, chunk.start);
+ try testing.expectEqual(chunk.node.data.size.rows, chunk.end);
+ count += chunk.end - chunk.start;
+ }
+ try testing.expect(it.next() == null);
+ try testing.expectEqual(s.rows, count);
+}
+
+test "PageList pageIterator reverse history two pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Grow to capacity
+ const page1_node = s.pages.last.?;
+ const page1 = page1_node.data;
+ page1_node.data.pauseIntegrityChecks(true);
+ for (0..page1.capacity.rows - page1.size.rows) |_| {
+ try testing.expect(try s.grow() == null);
+ }
+ page1_node.data.pauseIntegrityChecks(false);
+ try testing.expect(try s.grow() != null);
+
+ // Iterate the active area
+ var it = s.pageIterator(.left_up, .{ .history = .{} }, null);
+ {
+ const active_tl = s.getTopLeft(.active);
+ const chunk = it.next().?;
+ try testing.expect(chunk.node == s.pages.first.?);
+ const start: usize = 0;
+ try testing.expectEqual(start, chunk.start);
+ try testing.expectEqual(active_tl.y, chunk.end);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList cellIterator" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ var it = s.cellIterator(.right_down, .{ .screen = .{} }, null);
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 1,
+ .y = 0,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 1,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 1,
+ .y = 1,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList cellIterator reverse" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ var it = s.cellIterator(.left_up, .{ .screen = .{} }, null);
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 1,
+ .y = 1,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 1,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 1,
+ .y = 0,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList promptIterator left_up" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ // Normal prompt
+ {
+ const rac = page.getRowAndCell(0, 3);
+ rac.row.semantic_prompt = .prompt;
+ }
+ // Continuation
+ {
+ const rac = page.getRowAndCell(0, 6);
+ rac.row.semantic_prompt = .prompt;
+ }
+ {
+ const rac = page.getRowAndCell(0, 7);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+ {
+ const rac = page.getRowAndCell(0, 8);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+ // Broken continuation that has non-prompts in between
+ {
+ const rac = page.getRowAndCell(0, 12);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+
+ var it = s.promptIterator(.left_up, .{ .screen = .{} }, null);
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 12,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 6,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 3,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList promptIterator right_down" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ // Normal prompt
+ {
+ const rac = page.getRowAndCell(0, 3);
+ rac.row.semantic_prompt = .prompt;
+ }
+ // Continuation (prompt on row 6, continuation on rows 7-8)
+ {
+ const rac = page.getRowAndCell(0, 6);
+ rac.row.semantic_prompt = .prompt;
+ }
+ {
+ const rac = page.getRowAndCell(0, 7);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+ {
+ const rac = page.getRowAndCell(0, 8);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+ // Broken continuation that has non-prompts in between (orphaned continuation at row 12)
+ {
+ const rac = page.getRowAndCell(0, 12);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+
+ var it = s.promptIterator(.right_down, .{ .screen = .{} }, null);
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 3,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 6,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 12,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList promptIterator right_down continuation at start" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback)
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+ // Normal prompt later
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ var it = s.promptIterator(.right_down, .{ .screen = .{} }, null);
+ {
+ // Should return the first continuation line since there's no prior prompt
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList promptIterator right_down with prompt before continuation" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 2, continuation on rows 3-4
+ // Starting iteration from row 3 should still find the prompt at row 2
+ {
+ const rac = page.getRowAndCell(0, 2);
+ rac.row.semantic_prompt = .prompt;
+ }
+ {
+ const rac = page.getRowAndCell(0, 3);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+ {
+ const rac = page.getRowAndCell(0, 4);
+ rac.row.semantic_prompt = .prompt_continuation;
+ }
+
+ // Start iteration from row 3 (middle of the continuation)
+ // Since we start on a continuation line, we treat it as the prompt start
+ // (handles case where scrollback pruned the actual prompt)
+ var it = s.promptIterator(.right_down, .{ .screen = .{ .y = 3 } }, null);
+ {
+ const p = it.next().?;
+ // Returns row 3 since that's the first prompt-related line we encounter
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 3,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList promptIterator right_down limit inclusive" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Iterate with limit at row 5 (the prompt row) - should include it
+ var it = s.promptIterator(.right_down, .{ .screen = .{} }, .{ .screen = .{ .y = 5 } });
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList promptIterator left_up limit inclusive" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Iterate with limit at row 10 (the prompt row) - should include it
+ // tl_pt is the limit (upper bound), bl_pt is the start point for left_up
+ var it = s.promptIterator(.left_up, .{ .screen = .{ .y = 10 } }, .{ .screen = .{ .y = 15 } });
+ {
+ const p = it.next().?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 10,
+ } }, s.pointFromPin(.screen, p).?);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList highlightSemanticContent prompt" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // Start the prompt for the first 5 cols
+ for (0..5) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Next 3 let's make input
+ for (5..8) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'B' },
+ .semantic_content = .input,
+ };
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 2, .y = 5 } }).?,
+ .prompt,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 7,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent prompt with output" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 3 cols are prompt
+ for (0..3) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Next 4 are input
+ for (3..7) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'l' },
+ .semantic_content = .input,
+ };
+ }
+
+ // Rest is output (shouldn't be included in prompt highlight)
+ for (7..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting from prompt should include prompt and input, but stop at output
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .prompt,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 6,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent prompt multiline" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt starts on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First row is all prompt
+ for (0..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+ }
+ // Row 6 continues with input
+ {
+ for (0..5) |x| {
+ const cell = page.getRowAndCell(x, 6).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'c' },
+ .semantic_content = .input,
+ };
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting should span both rows
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 2, .y = 5 } }).?,
+ .prompt,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 4,
+ .y = 6,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent prompt only" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5 with only prompt content (no input)
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ for (0..5) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting should only include the prompt cells
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .prompt,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 4,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent prompt to end of screen" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Single prompt on row 15, no following prompt
+ {
+ const rac = page.getRowAndCell(0, 15);
+ rac.row.semantic_prompt = .prompt;
+
+ for (0..3) |x| {
+ const cell = page.getRowAndCell(x, 15).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ for (3..8) |x| {
+ const cell = page.getRowAndCell(x, 15).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'c' },
+ .semantic_content = .input,
+ };
+ }
+ }
+
+ // Highlighting should include prompt and input up to column 7
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?,
+ .prompt,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 15,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 7,
+ .y = 15,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent input basic" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 3 cols are prompt
+ for (0..3) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Next 5 are input
+ for (3..8) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'l' },
+ .semantic_content = .input,
+ };
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting input should only include input cells
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .input,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 3,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 7,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent input with output" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 2 cols are prompt
+ for (0..2) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Next 3 are input
+ for (2..5) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'c' },
+ .semantic_content = .input,
+ };
+ }
+
+ // Rest is output
+ for (5..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting input should stop at output
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .input,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 2,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 4,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent input multiline with continuation" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 2 cols are prompt
+ for (0..2) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Rest is input
+ for (2..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'c' },
+ .semantic_content = .input,
+ };
+ }
+ }
+ // Row 6 has continuation prompt then more input
+ {
+ // Continuation prompt
+ for (0..2) |x| {
+ const cell = page.getRowAndCell(x, 6).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '>' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // More input
+ for (2..6) |x| {
+ const cell = page.getRowAndCell(x, 6).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'd' },
+ .semantic_content = .input,
+ };
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting input should span both rows, skipping continuation prompts
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .input,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 2,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 5,
+ .y = 6,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent input no input returns null" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5 with only prompt, then immediately output
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 3 cols are prompt
+ for (0..3) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Rest is output (no input!)
+ for (3..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting input should return null when there's no input
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .input,
+ );
+ try testing.expect(hl == null);
+}
+
+test "PageList highlightSemanticContent input to end of screen" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Single prompt on row 15, no following prompt
+ {
+ const rac = page.getRowAndCell(0, 15);
+ rac.row.semantic_prompt = .prompt;
+
+ for (0..2) |x| {
+ const cell = page.getRowAndCell(x, 15).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ for (2..7) |x| {
+ const cell = page.getRowAndCell(x, 15).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'c' },
+ .semantic_content = .input,
+ };
+ }
+ }
+
+ // Highlighting input with no following prompt
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?,
+ .input,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 2,
+ .y = 15,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 6,
+ .y = 15,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent input prompt only returns null" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5 with only prompt content, no input or output
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // All cells are prompt
+ for (0..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+ }
+ // Mark rows 6-9 as prompt to ensure no input before next prompt
+ {
+ for (6..10) |y| {
+ for (0..10) |x| {
+ const cell = page.getRowAndCell(x, y).cell;
+ cell.semantic_content = .prompt;
+ }
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting input should return null when there's only prompts
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .input,
+ );
+ try testing.expect(hl == null);
+}
+
+test "PageList highlightSemanticContent output basic" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 2 cols are prompt
+ for (0..2) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Next 3 are input
+ for (2..5) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'l' },
+ .semantic_content = .input,
+ };
+ }
+
+ // Cols 5-7 are output
+ for (5..8) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+
+ // Mark remaining cells as prompt to bound the output
+ for (8..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.semantic_content = .prompt;
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting output should only include output cells
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .output,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 5,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 7,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent output multiline" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 2 cols are prompt
+ for (0..2) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Next 2 are input
+ for (2..4) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'l' },
+ .semantic_content = .input,
+ };
+ }
+
+ // Rest of row 5 is output
+ for (4..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ }
+ // Row 6 is all output
+ {
+ for (0..10) |x| {
+ const cell = page.getRowAndCell(x, 6).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ }
+ // Row 7 has partial output then input to bound it
+ {
+ for (0..5) |x| {
+ const cell = page.getRowAndCell(x, 7).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ for (5..10) |x| {
+ const cell = page.getRowAndCell(x, 7).cell;
+ cell.semantic_content = .input;
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting output should span multiple rows
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .output,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 4,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 4,
+ .y = 7,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent output stops at next prompt" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 2 cols are prompt
+ for (0..2) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Next 2 are input
+ for (2..4) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'l' },
+ .semantic_content = .input,
+ };
+ }
+
+ // Rest is output
+ for (4..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ }
+ // Row 6 has output then prompt starts
+ {
+ for (0..3) |x| {
+ const cell = page.getRowAndCell(x, 6).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ // Next prompt marker on same row
+ for (3..6) |x| {
+ const cell = page.getRowAndCell(x, 6).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+ }
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting output should stop before prompt/input
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .output,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 4,
+ .y = 5,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 2,
+ .y = 6,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent output to end of screen" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Single prompt on row 15, no following prompt
+ {
+ const rac = page.getRowAndCell(0, 15);
+ rac.row.semantic_prompt = .prompt;
+
+ for (0..2) |x| {
+ const cell = page.getRowAndCell(x, 15).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ for (2..4) |x| {
+ const cell = page.getRowAndCell(x, 15).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'c' },
+ .semantic_content = .input,
+ };
+ }
+
+ for (4..10) |x| {
+ const cell = page.getRowAndCell(x, 15).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ }
+ // Row 16 has output then prompt to bound it
+ {
+ for (0..8) |x| {
+ const cell = page.getRowAndCell(x, 16).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ for (8..10) |x| {
+ const cell = page.getRowAndCell(x, 16).cell;
+ cell.semantic_content = .prompt;
+ }
+ }
+
+ // Highlighting output with no following prompt
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 15 } }).?,
+ .output,
+ ).?;
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 4,
+ .y = 15,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 7,
+ .y = 16,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList highlightSemanticContent output no output returns null" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5 with only prompt and input, no output
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 3 cols are prompt
+ for (0..3) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+
+ // Rest is input (must explicitly mark all cells to avoid default .output)
+ for (3..10) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'c' },
+ .semantic_content = .input,
+ };
+ }
+ }
+ // Mark rows 6-9 as input to ensure no output between prompts
+ {
+ for (6..10) |y| {
+ for (0..10) |x| {
+ const cell = page.getRowAndCell(x, y).cell;
+ cell.semantic_content = .input;
+ }
+ }
+ }
+ // Prompt on row 10 (no output between prompts)
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting output should return null when there's no output
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .output,
+ );
+ try testing.expect(hl == null);
+}
+
+test "PageList highlightSemanticContent output skips empty cells" {
+ // Tests that empty cells with default .output semantic content are
+ // not selected as output. This can happen when a prompt/input line
+ // doesn't fill the entire row - trailing cells have default .output.
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output
+ {
+ const rac = page.getRowAndCell(0, 5);
+ rac.row.semantic_prompt = .prompt;
+
+ // First 3 cols are prompt with text
+ for (0..3) |x| {
+ const cell = page.getRowAndCell(x, 5).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '$' },
+ .semantic_content = .prompt,
+ };
+ }
+ // Cells 3-9 are empty (codepoint = 0) with default .output semantic content
+ // This simulates what happens when a short prompt is written
+ }
+
+ // Row 6 has input (short, doesn't fill line)
+ {
+ for (0..4) |x| {
+ const cell = page.getRowAndCell(x, 6).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'l' },
+ .semantic_content = .input,
+ };
+ }
+ // Cells 4-9 are empty with default .output
+ }
+
+ // Row 7-8 have actual output with text
+ {
+ for (7..9) |y| {
+ for (0..5) |x| {
+ const cell = page.getRowAndCell(x, y).cell;
+ cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'o' },
+ .semantic_content = .output,
+ };
+ }
+ }
+ }
+
+ // Prompt on row 10
+ {
+ const rac = page.getRowAndCell(0, 10);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Highlighting output should skip empty cells on rows 5-6 and find
+ // the actual output starting at row 7
+ const hl = s.highlightSemanticContent(
+ s.pin(.{ .screen = .{ .x = 0, .y = 5 } }).?,
+ .output,
+ ).?;
+ // Output should start at row 7, not row 5 (where empty cells have default .output)
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 7,
+ } }, s.pointFromPin(.screen, hl.start).?);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 4,
+ .y = 8,
+ } }, s.pointFromPin(.screen, hl.end).?);
+}
+
+test "PageList erase" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+
+ // Grow so we take up at least 5 pages.
+ const page = &s.pages.last.?.data;
+ var cur_page = s.pages.last.?;
+ cur_page.data.pauseIntegrityChecks(true);
+ for (0..page.capacity.rows * 5) |_| {
+ if (try s.grow()) |new_page| {
+ cur_page.data.pauseIntegrityChecks(false);
+ cur_page = new_page;
+ cur_page.data.pauseIntegrityChecks(true);
+ }
+ }
+ cur_page.data.pauseIntegrityChecks(false);
+ try testing.expectEqual(@as(usize, 6), s.totalPages());
+
+ // Our total rows should be large
+ try testing.expect(s.total_rows > s.rows);
+
+ // Erase the entire history, we should be back to just our active set.
+ s.eraseHistory(null);
+ try testing.expectEqual(s.rows, s.total_rows);
+
+ // We should be back to just one page
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+ try testing.expect(s.pages.first == s.pages.last);
+}
+
+test "PageList erase reaccounts page size" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ const start_size = s.page_size;
+
+ // Grow so we take up at least 5 pages.
+ const page = &s.pages.last.?.data;
+ var cur_page = s.pages.last.?;
+ cur_page.data.pauseIntegrityChecks(true);
+ for (0..page.capacity.rows * 5) |_| {
+ if (try s.grow()) |new_page| {
+ cur_page.data.pauseIntegrityChecks(false);
+ cur_page = new_page;
+ cur_page.data.pauseIntegrityChecks(true);
+ }
+ }
+ cur_page.data.pauseIntegrityChecks(false);
+ try testing.expect(s.page_size > start_size);
+
+ // Erase the entire history, we should be back to just our active set.
+ s.eraseHistory(null);
+ try testing.expectEqual(start_size, s.page_size);
+}
+
+test "PageList erase row with tracked pin resets to top-left" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ var cur_page = s.pages.last.?;
+ cur_page.data.pauseIntegrityChecks(true);
+ for (0..page.capacity.rows * 5) |_| {
+ if (try s.grow()) |new_page| {
+ cur_page.data.pauseIntegrityChecks(false);
+ cur_page = new_page;
+ cur_page.data.pauseIntegrityChecks(true);
+ }
+ }
+ cur_page.data.pauseIntegrityChecks(false);
+
+ // Our total rows should be large
+ try testing.expect(s.total_rows > s.rows);
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .history = .{} }).?);
+ defer s.untrackPin(p);
+
+ // Erase the entire history, we should be back to just our active set.
+ s.eraseHistory(null);
+ try testing.expectEqual(s.rows, s.total_rows);
+
+ // Our pin should move to the first page
+ try testing.expectEqual(s.pages.first.?, p.node);
+ try testing.expectEqual(@as(usize, 0), p.y);
+ try testing.expectEqual(@as(usize, 0), p.x);
+}
+
+test "PageList erase row with tracked pin shifts" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .y = 4, .x = 2 } }).?);
+ defer s.untrackPin(p);
+
+ // Erase only a few rows in our active
+ s.eraseActive(3);
+ try testing.expectEqual(s.rows, s.total_rows);
+
+ // Our pin should move to the first page
+ try testing.expectEqual(s.pages.first.?, p.node);
+ try testing.expectEqual(@as(usize, 0), p.y);
+ try testing.expectEqual(@as(usize, 2), p.x);
+}
+
+test "PageList erase row with tracked pin is erased" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .y = 2, .x = 2 } }).?);
+ defer s.untrackPin(p);
+
+ // Erase the entire history, we should be back to just our active set.
+ s.eraseActive(3);
+ try testing.expectEqual(s.rows, s.total_rows);
+
+ // Our pin should move to the first page
+ try testing.expectEqual(s.pages.first.?, p.node);
+ try testing.expectEqual(@as(usize, 0), p.y);
+ try testing.expectEqual(@as(usize, 0), p.x);
+}
+
+test "PageList erase resets viewport to active if moves within active" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ var cur_page = s.pages.last.?;
+ cur_page.data.pauseIntegrityChecks(true);
+ for (0..page.capacity.rows * 5) |_| {
+ if (try s.grow()) |new_page| {
+ cur_page.data.pauseIntegrityChecks(false);
+ cur_page = new_page;
+ cur_page.data.pauseIntegrityChecks(true);
+ }
+ }
+ cur_page.data.pauseIntegrityChecks(false);
+
+ // Move our viewport to the top
+ s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) });
+ try testing.expect(s.viewport == .top);
+
+ // Erase the entire history, we should be back to just our active set.
+ s.eraseHistory(null);
+ try testing.expect(s.viewport == .active);
+}
+
+test "PageList erase resets viewport if inside erased page but not active" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ var cur_page = s.pages.last.?;
+ cur_page.data.pauseIntegrityChecks(true);
+ for (0..page.capacity.rows * 5) |_| {
+ if (try s.grow()) |new_page| {
+ cur_page.data.pauseIntegrityChecks(false);
+ cur_page = new_page;
+ cur_page.data.pauseIntegrityChecks(true);
+ }
+ }
+ cur_page.data.pauseIntegrityChecks(false);
+
+ // Move our viewport to the top
+ s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) });
+ try testing.expect(s.viewport == .top);
+
+ // Erase the entire history, we should be back to just our active set.
+ s.eraseHistory(.{ .history = .{ .y = 2 } });
+ try testing.expect(s.viewport == .top);
+}
+
+test "PageList erase resets viewport to active if top is inside active" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ var cur_page = s.pages.last.?;
+ cur_page.data.pauseIntegrityChecks(true);
+ for (0..page.capacity.rows * 5) |_| {
+ if (try s.grow()) |new_page| {
+ cur_page.data.pauseIntegrityChecks(false);
+ cur_page = new_page;
+ cur_page.data.pauseIntegrityChecks(true);
+ }
+ }
+ cur_page.data.pauseIntegrityChecks(false);
+
+ // Move our viewport to the top
+ s.scroll(.{ .top = {} });
+
+ // Erase the entire history, we should be back to just our active set.
+ s.eraseHistory(null);
+ try testing.expect(s.viewport == .active);
+}
+
+test "PageList erase active regrows automatically" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try testing.expect(s.totalRows() == s.rows);
+ s.eraseActive(10);
+ try testing.expect(s.totalRows() == s.rows);
+}
+
+test "PageList erase a one-row active" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 1, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+
+ // Write our letter
+ const page = &s.pages.first.?.data;
+ for (0..s.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+
+ s.eraseActive(0);
+ try testing.expectEqual(s.rows, s.total_rows);
+
+ // The row should be empty
+ {
+ const get = s.getCell(.{ .active = .{ .x = 0, .y = 0 } }).?;
+ try testing.expectEqual(@as(u21, 0), get.cell.content.codepoint);
+ }
+}
+
+test "PageList eraseRowBounded less than full row" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 10, null);
+ defer s.deinit();
+
+ // Pins
+ const p_top = try s.trackPin(s.pin(.{ .active = .{ .y = 5, .x = 0 } }).?);
+ defer s.untrackPin(p_top);
+ const p_bot = try s.trackPin(s.pin(.{ .active = .{ .y = 8, .x = 0 } }).?);
+ defer s.untrackPin(p_bot);
+ const p_out = try s.trackPin(s.pin(.{ .active = .{ .y = 9, .x = 0 } }).?);
+ defer s.untrackPin(p_out);
+
+ // Erase only a few rows in our active
+ try s.eraseRowBounded(.{ .active = .{ .y = 5 } }, 3);
+ try testing.expectEqual(s.rows, s.totalRows());
+
+ // The erased rows should be dirty
+ try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 5 } }));
+ try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 6 } }));
+ try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 7 } }));
+
+ try testing.expectEqual(s.pages.first.?, p_top.node);
+ try testing.expectEqual(@as(usize, 4), p_top.y);
+ try testing.expectEqual(@as(usize, 0), p_top.x);
+
+ try testing.expectEqual(s.pages.first.?, p_bot.node);
+ try testing.expectEqual(@as(usize, 7), p_bot.y);
+ try testing.expectEqual(@as(usize, 0), p_bot.x);
+
+ try testing.expectEqual(s.pages.first.?, p_out.node);
+ try testing.expectEqual(@as(usize, 9), p_out.y);
+ try testing.expectEqual(@as(usize, 0), p_out.x);
+}
+
+test "PageList eraseRowBounded with pin at top" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 10, null);
+ defer s.deinit();
+
+ // Pins
+ const p_top = try s.trackPin(s.pin(.{ .active = .{ .y = 0, .x = 5 } }).?);
+ defer s.untrackPin(p_top);
+
+ // Erase only a few rows in our active
+ try s.eraseRowBounded(.{ .active = .{ .y = 0 } }, 3);
+ try testing.expectEqual(s.rows, s.totalRows());
+
+ // The erased rows should be dirty
+ try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
+ try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
+ try testing.expect(s.isDirty(.{ .active = .{ .x = 0, .y = 2 } }));
+
+ try testing.expectEqual(s.pages.first.?, p_top.node);
+ try testing.expectEqual(@as(usize, 0), p_top.y);
+ try testing.expectEqual(@as(usize, 0), p_top.x);
+}
+
+test "PageList eraseRowBounded full rows single page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 10, null);
+ defer s.deinit();
+
+ // Pins
+ const p_in = try s.trackPin(s.pin(.{ .active = .{ .y = 7, .x = 0 } }).?);
+ defer s.untrackPin(p_in);
+ const p_out = try s.trackPin(s.pin(.{ .active = .{ .y = 9, .x = 0 } }).?);
+ defer s.untrackPin(p_out);
+
+ // Erase only a few rows in our active
+ try s.eraseRowBounded(.{ .active = .{ .y = 5 } }, 10);
+ try testing.expectEqual(s.rows, s.totalRows());
+
+ // The erased rows should be dirty
+ for (5..10) |y| try testing.expect(s.isDirty(.{ .active = .{
+ .x = 0,
+ .y = @intCast(y),
+ } }));
+
+ // Our pin should move to the first page
+ try testing.expectEqual(s.pages.first.?, p_in.node);
+ try testing.expectEqual(@as(usize, 6), p_in.y);
+ try testing.expectEqual(@as(usize, 0), p_in.x);
+
+ try testing.expectEqual(s.pages.first.?, p_out.node);
+ try testing.expectEqual(@as(usize, 8), p_out.y);
+ try testing.expectEqual(@as(usize, 0), p_out.x);
+}
+
+test "PageList eraseRowBounded full rows two pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 10, null);
+ defer s.deinit();
+
+ // Grow to two pages so our active area straddles
+ {
+ const page = &s.pages.last.?.data;
+ 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);
+ }
+
+ // Pins
+ const p_first = try s.trackPin(s.pin(.{ .active = .{ .y = 4, .x = 0 } }).?);
+ defer s.untrackPin(p_first);
+ const p_first_out = try s.trackPin(s.pin(.{ .active = .{ .y = 3, .x = 0 } }).?);
+ defer s.untrackPin(p_first_out);
+ const p_in = try s.trackPin(s.pin(.{ .active = .{ .y = 8, .x = 0 } }).?);
+ defer s.untrackPin(p_in);
+ const p_out = try s.trackPin(s.pin(.{ .active = .{ .y = 9, .x = 0 } }).?);
+ defer s.untrackPin(p_out);
+
+ {
+ 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, 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, 0), p_first_out.x);
+
+ try testing.expectEqual(s.pages.last.?, p_in.node);
+ try testing.expectEqual(@as(usize, 3), p_in.y);
+ try testing.expectEqual(@as(usize, 0), p_in.x);
+
+ try testing.expectEqual(s.pages.last.?, p_out.node);
+ try testing.expectEqual(@as(usize, 4), p_out.y);
+ try testing.expectEqual(@as(usize, 0), p_out.x);
+ }
+
+ // Erase only a few rows in our active
+ try s.eraseRowBounded(.{ .active = .{ .y = 4 } }, 4);
+
+ // The erased rows should be dirty
+ for (4..8) |y| try testing.expect(s.isDirty(.{ .active = .{
+ .x = 0,
+ .y = @intCast(y),
+ } }));
+
+ // 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, 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, 0), p_first_out.x);
+
+ // In page is shifted
+ try testing.expectEqual(s.pages.last.?, p_in.node);
+ try testing.expectEqual(@as(usize, 2), p_in.y);
+ try testing.expectEqual(@as(usize, 0), p_in.x);
+
+ // Out page is not shifted
+ try testing.expectEqual(s.pages.last.?, p_out.node);
+ try testing.expectEqual(@as(usize, 4), p_out.y);
+ try testing.expectEqual(@as(usize, 0), p_out.x);
+}
+
+test "PageList clone" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .screen = .{} },
+ });
+ defer s2.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s2.totalRows());
+}
+
+test "PageList clone partial trimmed right" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 20, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+ try s.growRows(30);
+
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .screen = .{} },
+ .bot = .{ .screen = .{ .y = 39 } },
+ });
+ defer s2.deinit();
+ try testing.expectEqual(@as(usize, 40), s2.totalRows());
+}
+
+test "PageList clone partial trimmed left" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 20, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+ try s.growRows(30);
+
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .screen = .{ .y = 10 } },
+ });
+ defer s2.deinit();
+ try testing.expectEqual(@as(usize, 40), s2.totalRows());
+}
+
+test "PageList clone partial trimmed left reclaims styles" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 20, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+ try s.growRows(30);
+
+ // Style the rows we're trimming
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ const style: stylepkg.Style = .{ .flags = .{ .bold = true } };
+ const style_id = try page.styles.add(page.memory, style);
+
+ var it = s.rowIterator(.left_up, .{ .screen = .{} }, .{ .screen = .{ .y = 9 } });
+ while (it.next()) |p| {
+ const rac = p.rowAndCell();
+ rac.row.styled = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ .style_id = style_id,
+ };
+ page.styles.use(page.memory, style_id);
+ }
+
+ // We're over-counted by 1 because `add` implies `use`.
+ page.styles.release(page.memory, style_id);
+
+ // Expect to have one style
+ try testing.expectEqual(1, page.styles.count());
+ }
+
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .screen = .{ .y = 10 } },
+ });
+ defer s2.deinit();
+ try testing.expectEqual(@as(usize, 40), s2.totalRows());
+
+ {
+ try testing.expect(s2.pages.first == s2.pages.last);
+ const page = &s2.pages.first.?.data;
+ try testing.expectEqual(0, page.styles.count());
+ }
+}
+
+test "PageList clone partial trimmed both" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 20, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+ try s.growRows(30);
+
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .screen = .{ .y = 10 } },
+ .bot = .{ .screen = .{ .y = 35 } },
+ });
+ defer s2.deinit();
+ try testing.expectEqual(@as(usize, 26), s2.totalRows());
+}
+
+test "PageList clone less than active" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .active = .{ .y = 5 } },
+ });
+ defer s2.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s2.totalRows());
+}
+
+test "PageList clone remap tracked pin" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ // Put a tracked pin in the screen
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 6 } }).?);
+ defer s.untrackPin(p);
+
+ var pin_remap = Clone.TrackedPinsRemap.init(alloc);
+ defer pin_remap.deinit();
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .active = .{ .y = 5 } },
+ .tracked_pins = &pin_remap,
+ });
+ defer s2.deinit();
+
+ // We should be able to find our tracked pin
+ const p2 = pin_remap.get(p).?;
+ try testing.expectEqual(
+ point.Point{ .active = .{ .x = 0, .y = 1 } },
+ s2.pointFromPin(.active, p2.*).?,
+ );
+}
+
+test "PageList clone remap tracked pin not in cloned area" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ // Put a tracked pin in the screen
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 3 } }).?);
+ defer s.untrackPin(p);
+
+ var pin_remap = Clone.TrackedPinsRemap.init(alloc);
+ defer pin_remap.deinit();
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .active = .{ .y = 5 } },
+ .tracked_pins = &pin_remap,
+ });
+ defer s2.deinit();
+
+ // We should be able to find our tracked pin
+ try testing.expect(pin_remap.get(p) == null);
+}
+
+test "PageList clone full dirty" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ // Mark a row as dirty
+ s.markDirty(.{ .active = .{ .x = 0, .y = 0 } });
+ s.markDirty(.{ .active = .{ .x = 0, .y = 12 } });
+ s.markDirty(.{ .active = .{ .x = 0, .y = 23 } });
+
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .screen = .{} },
+ });
+ defer s2.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s2.totalRows());
+
+ // Should still be dirty
+ try testing.expect(s2.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
+ try testing.expect(!s2.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
+ try testing.expect(s2.isDirty(.{ .active = .{ .x = 0, .y = 12 } }));
+ try testing.expect(!s2.isDirty(.{ .active = .{ .x = 0, .y = 14 } }));
+ try testing.expect(s2.isDirty(.{ .active = .{ .x = 0, .y = 23 } }));
+}
+
+test "PageList resize (no reflow) more rows" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 3, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 2 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .rows = 10, .reflow = false });
+ try testing.expectEqual(@as(usize, 10), s.rows);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ // Our cursor should not move because we have no scrollback so
+ // we just grew.
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 2,
+ } }, s.pointFromPin(.active, p.*).?);
+
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+}
+
+test "PageList resize (no reflow) more rows with history" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 3, null);
+ defer s.deinit();
+ try s.growRows(50);
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 50,
+ } }, pt);
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 2 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .rows = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.rows);
+ try testing.expectEqual(@as(usize, 53), s.totalRows());
+
+ // Our cursor should move since it's in the scrollback
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 4,
+ } }, s.pointFromPin(.active, p.*).?);
+
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 48,
+ } }, pt);
+ }
+}
+
+test "PageList resize (no reflow) less rows" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ // This is required for our writing below to work
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Write into all rows so we don't get trim behavior
+ for (0..s.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+
+ // Resize
+ try s.resize(.{ .rows = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.rows);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, pt);
+ }
+}
+
+test "PageList resize (no reflow) one rows" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ // This is required for our writing below to work
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Write into all rows so we don't get trim behavior
+ for (0..s.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+
+ // Resize
+ try s.resize(.{ .rows = 1, .reflow = false });
+ try testing.expectEqual(@as(usize, 1), s.rows);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 9,
+ } }, pt);
+ }
+}
+
+test "PageList resize (no reflow) less rows cursor on bottom" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ // This is required for our writing below to work
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Write into all rows so we don't get trim behavior
+ for (0..s.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(y) },
+ };
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 9 } }).?);
+ defer s.untrackPin(p);
+ {
+ const cursor = s.pointFromPin(.active, p.*).?.active;
+ const get = s.getCell(.{ .active = .{
+ .x = cursor.x,
+ .y = cursor.y,
+ } }).?;
+ try testing.expectEqual(@as(u21, 9), get.cell.content.codepoint);
+ }
+
+ // Resize
+ try s.resize(.{ .rows = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.rows);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ // Our cursor should move since it's in the scrollback
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 4,
+ } }, s.pointFromPin(.active, p.*).?);
+
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, pt);
+ }
+}
+test "PageList resize (no reflow) less rows cursor in scrollback" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ // This is required for our writing below to work
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Write into all rows so we don't get trim behavior
+ for (0..s.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(y) },
+ };
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 2 } }).?);
+ defer s.untrackPin(p);
+ {
+ const cursor = s.pointFromPin(.active, p.*).?.active;
+ const get = s.getCell(.{ .active = .{
+ .x = cursor.x,
+ .y = cursor.y,
+ } }).?;
+ try testing.expectEqual(@as(u21, 2), get.cell.content.codepoint);
+ }
+
+ // Resize
+ try s.resize(.{ .rows = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.rows);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ // Our cursor should move since it's in the scrollback
+ try testing.expect(s.pointFromPin(.active, p.*) == null);
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, s.pointFromPin(.screen, p.*).?);
+
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 5,
+ } }, pt);
+ }
+}
+
+test "PageList resize (no reflow) less rows trims blank lines" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Write codepoint into first line
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+
+ // Fill remaining lines with a background color
+ for (1..s.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .bg_color_rgb,
+ .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } },
+ };
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 0 } }).?);
+ defer s.untrackPin(p);
+ {
+ const cursor = s.pointFromPin(.active, p.*).?.active;
+ const get = s.getCell(.{ .active = .{
+ .x = cursor.x,
+ .y = cursor.y,
+ } }).?;
+ try testing.expectEqual(@as(u21, 'A'), get.cell.content.codepoint);
+ }
+
+ // Resize
+ try s.resize(.{ .rows = 2, .reflow = false });
+ try testing.expectEqual(@as(usize, 2), s.rows);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ // Our cursor should not move since we trimmed
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 0,
+ } }, s.pointFromPin(.active, p.*).?);
+
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+}
+
+test "PageList resize (no reflow) less rows trims blank lines cursor in blank line" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Write codepoint into first line
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+
+ // Fill remaining lines with a background color
+ for (1..s.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .bg_color_rgb,
+ .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } },
+ };
+ }
+
+ // Put a tracked pin in a blank line
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 3 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .rows = 2, .reflow = false });
+ try testing.expectEqual(@as(usize, 2), s.rows);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ // Our cursor should not move since we trimmed
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 1,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize (no reflow) less rows trims blank lines erases pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Resize to take up two pages
+ {
+ const rows = page.capacity.rows + 10;
+ try s.resize(.{ .rows = rows, .reflow = false });
+ try testing.expectEqual(@as(usize, 2), s.totalPages());
+ }
+
+ // Write codepoint into first line
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+
+ // Resize down. Every row except the first is blank so we
+ // should erase the second page.
+ try s.resize(.{ .rows = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.rows);
+ try testing.expectEqual(@as(usize, 5), s.totalRows());
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+}
+
+test "PageList resize (no reflow) more rows extends blank lines" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+
+ // Write codepoint into first line
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+
+ // Fill remaining lines with a background color
+ for (1..s.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .bg_color_rgb,
+ .content = .{ .color_rgb = .{ .r = 0xFF, .g = 0, .b = 0 } },
+ };
+ }
+
+ // Resize
+ try s.resize(.{ .rows = 7, .reflow = false });
+ try testing.expectEqual(@as(usize, 7), s.rows);
+ try testing.expectEqual(@as(usize, 7), s.totalRows());
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+}
+
+test "PageList resize (no reflow) more rows contains viewport" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // When the rows are increased we need to make sure that the viewport
+ // doesn't end up below the active area if it's currently in pin mode.
+
+ var s = try init(alloc, 5, 5, 1);
+ defer s.deinit();
+ try testing.expect(s.pages.first == s.pages.last);
+
+ // Make it so we have scrollback
+ _ = try s.grow();
+
+ try testing.expectEqual(@as(usize, 5), s.rows);
+ try testing.expectEqual(@as(usize, 6), s.totalRows());
+
+ // Set viewport above active by scrolling up one.
+ s.scroll(.{ .delta_row = -1 });
+ // The viewport should be a pin now.
+ try testing.expectEqual(Viewport.top, s.viewport);
+
+ // Resize
+ try s.resize(.{ .rows = 7, .reflow = false });
+ try testing.expectEqual(@as(usize, 7), s.rows);
+ try testing.expectEqual(@as(usize, 7), s.totalRows());
+
+ // Question: maybe the viewport should actually be in the active
+ // here and not pinned to the top.
+ try testing.expectEqual(Viewport.top, s.viewport);
+}
+
+test "PageList resize (no reflow) less cols" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ // Resize
+ try s.resize(.{ .cols = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.cols);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |offset| {
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expectEqual(@as(usize, 5), cells.len);
+ }
+}
+
+test "PageList resize (no reflow) less cols pin in trimmed cols" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 8, .y = 2 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.cols);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |offset| {
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expectEqual(@as(usize, 5), cells.len);
+ }
+
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 4,
+ .y = 2,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize (no reflow) less cols clears graphemes" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ // Add a grapheme.
+ const page = &s.pages.first.?.data;
+ {
+ const rac = page.getRowAndCell(9, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ try page.appendGrapheme(rac.row, rac.cell, 'A');
+ }
+ try testing.expectEqual(@as(usize, 1), page.graphemeCount());
+
+ // Resize
+ try s.resize(.{ .cols = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.cols);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ var it = s.pageIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |chunk| {
+ try testing.expectEqual(@as(usize, 0), chunk.node.data.graphemeCount());
+ }
+}
+
+test "PageList resize (no reflow) more cols" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 5, 3, 0);
+ defer s.deinit();
+
+ // Resize
+ try s.resize(.{ .cols = 10, .reflow = false });
+ try testing.expectEqual(@as(usize, 10), s.cols);
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |offset| {
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expectEqual(@as(usize, 10), cells.len);
+ }
+}
+
+test "PageList resize (no reflow) more cols with spacer head" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 3, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.row.wrap = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_head,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '😀' },
+ .wide = .wide,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 3, .reflow = false });
+ try testing.expectEqual(@as(usize, 3), s.cols);
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ // try testing.expect(!rac.row.wrap);
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(2, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ }
+ }
+}
+
+// Regression test for fuzz crash. When we shrink cols and then
+// grow back, the page retains capacity from the original size so the grow
+// takes the fast path (just bumps page.size.cols). If any row has a
+// spacer_head at the old last column, that cell is no longer at the end
+// of the wider row, violating page integrity.
+test "PageList resize (no reflow) grow cols fast path with spacer head" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 3, 0);
+ defer s.deinit();
+
+ // Shrink to 5 cols. The page keeps capacity for 10 cols.
+ try s.resize(.{ .cols = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.cols);
+
+ // 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;
+
+ // Row 0: 'x' at col 0..3, spacer_head at col 4, wrap = true
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(4, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_head,
+ };
+ rac.row.wrap = true;
+ }
+
+ // Row 1: spacer_head at col 4, wrap = true
+ {
+ const rac = page.getRowAndCell(4, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_head,
+ };
+ rac.row.wrap = true;
+ }
+ }
+
+ // Grow back to 10 cols. This must not leave stale spacer_head
+ // cells at col 4 (which is no longer the last column).
+ try s.resize(.{ .cols = 10, .reflow = false });
+ try testing.expectEqual(@as(usize, 10), s.cols);
+
+ // Verify the old spacer_head positions are now narrow.
+ {
+ const page = &s.pages.first.?.data;
+ {
+ const rac = page.getRowAndCell(4, 0);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ try testing.expect(!rac.row.wrap);
+ }
+ {
+ const rac = page.getRowAndCell(4, 1);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ try testing.expect(!rac.row.wrap);
+ }
+ }
+}
+
+// This test is a bit convoluted so I want to explain: what we are trying
+// to verify here is that when we increase cols such that our rows per page
+// shrinks, we don't fragment our rows across many pages because this ends
+// up wasting a lot of memory.
+//
+// This is particularly important for alternate screen buffers where we
+// don't have scrollback so our max size is very small. If we don't do this,
+// we end up pruning our pages and that causes resizes to fail!
+test "PageList resize (no reflow) more cols forces less rows per page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // This test requires initially that our rows fit into one page.
+ const cols: size.CellCountInt = 5;
+ const rows: size.CellCountInt = 150;
+ try testing.expect((try std_capacity.adjust(.{ .cols = cols })).rows >= rows);
+ var s = try init(alloc, cols, rows, 0);
+ defer s.deinit();
+
+ // Then we need to resize our cols so that our rows per page shrinks.
+ // This will force our resize to split our rows across two pages.
+ {
+ const new_cols = new_cols: {
+ var new_cols: size.CellCountInt = 50;
+ var cap = try std_capacity.adjust(.{ .cols = new_cols });
+ while (cap.rows >= rows) {
+ new_cols += 50;
+ cap = try std_capacity.adjust(.{ .cols = new_cols });
+ }
+
+ break :new_cols new_cols;
+ };
+ try s.resize(.{ .cols = new_cols, .reflow = false });
+ try testing.expectEqual(@as(usize, new_cols), s.cols);
+ try testing.expectEqual(@as(usize, rows), s.totalRows());
+ }
+
+ // Every page except the last should be full
+ {
+ 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);
+ }
+ }
+
+ // Now we need to resize again to a col size that further shrinks
+ // our last capacity.
+ {
+ const page = &s.pages.first.?.data;
+ try testing.expect(page.size.rows == page.capacity.rows);
+ const new_cols = new_cols: {
+ var new_cols = page.size.cols + 50;
+ var cap = try std_capacity.adjust(.{ .cols = new_cols });
+ while (cap.rows >= page.size.rows) {
+ new_cols += 50;
+ cap = try std_capacity.adjust(.{ .cols = new_cols });
+ }
+
+ break :new_cols new_cols;
+ };
+
+ try s.resize(.{ .cols = new_cols, .reflow = false });
+ try testing.expectEqual(@as(usize, new_cols), s.cols);
+ try testing.expectEqual(@as(usize, rows), s.totalRows());
+ }
+
+ // Every page except the last should be full
+ {
+ 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);
+ }
+ }
+}
+
+test "PageList resize (no reflow) less cols then more cols" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 5, 3, 0);
+ defer s.deinit();
+
+ // Resize less
+ try s.resize(.{ .cols = 2, .reflow = false });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+
+ // Resize
+ try s.resize(.{ .cols = 5, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.cols);
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |offset| {
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expectEqual(@as(usize, 5), cells.len);
+ }
+}
+
+test "PageList resize (no reflow) less rows and cols" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ // Resize less
+ try s.resize(.{ .cols = 5, .rows = 7, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.cols);
+ try testing.expectEqual(@as(usize, 7), s.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);
+ try testing.expectEqual(@as(usize, 5), cells.len);
+ }
+}
+
+test "PageList resize (no reflow) more rows and less cols" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ // Resize less
+ try s.resize(.{ .cols = 5, .rows = 20, .reflow = false });
+ try testing.expectEqual(@as(usize, 5), s.cols);
+ try testing.expectEqual(@as(usize, 20), s.rows);
+ try testing.expectEqual(@as(usize, 20), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |offset| {
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expectEqual(@as(usize, 5), cells.len);
+ }
+}
+
+test "PageList resize more rows and cols doesn't fit in single std page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ // Resize to a size that requires more than one page to fit our rows.
+ const new_cols = 600;
+ const new_rows = 600;
+ const cap = try std_capacity.adjust(.{ .cols = new_cols });
+ try testing.expect(cap.rows < new_rows);
+
+ try s.resize(.{ .cols = new_cols, .rows = new_rows, .reflow = true });
+ try testing.expectEqual(@as(usize, new_cols), s.cols);
+ try testing.expectEqual(@as(usize, new_rows), s.rows);
+ try testing.expectEqual(@as(usize, new_rows), s.totalRows());
+}
+
+test "PageList resize (no reflow) empty screen" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 5, 5, 0);
+ defer s.deinit();
+
+ // Resize
+ try s.resize(.{ .cols = 10, .rows = 10, .reflow = false });
+ try testing.expectEqual(@as(usize, 10), s.cols);
+ try testing.expectEqual(@as(usize, 10), s.rows);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |offset| {
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expectEqual(@as(usize, 10), cells.len);
+ }
+}
+
+test "PageList resize (no reflow) more cols forces smaller cap" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // We want a cap that forces us to have less rows
+ const cap = try std_capacity.adjust(.{ .cols = 100 });
+ const cap2 = try std_capacity.adjust(.{ .cols = 500 });
+ try testing.expectEqual(@as(size.CellCountInt, 500), cap2.cols);
+ try testing.expect(cap2.rows < cap.rows);
+
+ // Create initial cap, fits in one page
+ 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;
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+ }
+
+ // Resize to our large cap
+ const rows = s.totalRows();
+ try s.resize(.{ .cols = cap2.cols, .reflow = false });
+
+ // Our total rows should be the same, and contents should be the same.
+ try testing.expectEqual(rows, s.totalRows());
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |offset| {
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expectEqual(@as(usize, cap2.cols), cells.len);
+ try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint);
+ }
+}
+
+test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 5, 3, null);
+ defer s.deinit();
+
+ // 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;
+ for (0..s.totalRows()) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(y) },
+ };
+ }
+
+ // Active should be on row 3
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, pt);
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = s.rows - 2 } }).?);
+ defer s.untrackPin(p);
+ const original_cursor = s.pointFromPin(.active, p.*).?.active;
+ {
+ const get = s.getCell(.{ .active = .{
+ .x = original_cursor.x,
+ .y = original_cursor.y,
+ } }).?;
+ try testing.expectEqual(@as(u21, 3), get.cell.content.codepoint);
+ }
+
+ // Resize
+ try s.resizeWithoutReflow(.{
+ .rows = 10,
+ .reflow = false,
+ .cursor = .{ .x = 0, .y = s.rows - 2 },
+ });
+ try testing.expectEqual(@as(usize, 5), s.cols);
+ try testing.expectEqual(@as(usize, 10), s.rows);
+
+ // Our cursor should not change
+ try testing.expectEqual(original_cursor, s.pointFromPin(.active, p.*).?.active);
+
+ // 12 because we have our 10 rows in the active + 2 in the scrollback
+ // because we're preserving the cursor.
+ try testing.expectEqual(@as(usize, 12), s.totalRows());
+
+ // Active should be at the same place it was.
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, pt);
+ }
+
+ // Go through our active, we should get only 3,4,5
+ for (0..3) |y| {
+ const get = s.getCell(.{ .active = .{ .y = @intCast(y) } }).?;
+ const expected: u21 = @intCast(y + 2);
+ try testing.expectEqual(expected, get.cell.content.codepoint);
+ }
+}
+
+test "PageList resize reflow more cols no wrapped rows" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 10, .reflow = true });
+ try testing.expectEqual(@as(usize, 10), s.cols);
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |offset| {
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expectEqual(@as(usize, 10), cells.len);
+ try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint);
+ }
+}
+
+test "PageList resize reflow more cols wrapped rows" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ if (y % 2 == 0) {
+ const rac = page.getRowAndCell(0, y);
+ rac.row.wrap = true;
+ } else {
+ const rac = page.getRowAndCell(0, y);
+ rac.row.wrap_continuation = true;
+ }
+
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ // Active should still be on top
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 0,
+ } }, pt);
+ }
+
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ {
+ // First row should be unwrapped
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ try testing.expectEqual(@as(u21, 'A'), cells[2].content.codepoint);
+ }
+}
+
+test "PageList resize reflow invalidates viewport offset cache" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 4, null);
+ defer s.deinit();
+ try s.growRows(20);
+
+ const page = &s.pages.last.?.data;
+ for (0..s.rows) |y| {
+ if (y % 2 == 0) {
+ const rac = page.getRowAndCell(0, y);
+ rac.row.wrap = true;
+ } else {
+ const rac = page.getRowAndCell(0, y);
+ rac.row.wrap_continuation = true;
+ }
+
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+ }
+
+ // Scroll to a pinned viewport in history
+ const pin_y = 10;
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = pin_y } }).? });
+ try testing.expect(s.viewport == .pin);
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = pin_y,
+ .len = s.rows,
+ }, s.scrollbar());
+
+ // Resize with reflow - unwrapping rows changes total_rows
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+
+ // Verify scrollbar cache was invalidated during reflow
+ try testing.expectEqual(Scrollbar{
+ .total = s.total_rows,
+ .offset = 5,
+ .len = s.rows,
+ }, s.scrollbar());
+}
+
+test "PageList resize reflow more cols creates multiple pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // We want a wide viewport so our row limit is rather small. This will
+ // force the reflow below to create multiple pages, which we assert.
+ const cap = cap: {
+ var current: size.CellCountInt = 100;
+ while (true) : (current += 100) {
+ const cap = try std_capacity.adjust(.{ .cols = current });
+ if (cap.rows < 100) break :cap cap;
+ }
+ unreachable;
+ };
+
+ var s = try init(alloc, cap.cols, cap.rows, null);
+ defer s.deinit();
+
+ // 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;
+ for (0..s.rows) |y| {
+ if (y % 2 == 0) {
+ const rac = page.getRowAndCell(0, y);
+ rac.row.wrap = true;
+ } else {
+ const rac = page.getRowAndCell(0, y);
+ rac.row.wrap_continuation = true;
+ }
+
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+ }
+
+ // Resize
+ const newcap = try cap.adjust(.{ .cols = cap.cols + 100 });
+ try testing.expect(newcap.rows < cap.rows);
+ try s.resize(.{ .cols = newcap.cols, .reflow = true });
+ try testing.expectEqual(@as(usize, newcap.cols), s.cols);
+ try testing.expectEqual(@as(usize, cap.rows), s.totalRows());
+
+ {
+ var count: usize = 0;
+ var it = s.pages.first;
+ while (it) |page| : (it = page.next) {
+ 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);
+ }
+
+ // We should have more than one page, meaning we created at least
+ // one page. This is the critical aspect of this test so if this
+ // ever goes false we need to adjust this test.
+ try testing.expect(count > 1);
+ }
+}
+
+test "PageList resize reflow more cols wrap across page boundary" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+
+ // Grow to the capacity of the first page.
+ {
+ const page = &s.pages.first.?.data;
+ page.pauseIntegrityChecks(true);
+ for (page.size.rows..page.capacity.rows) |_| {
+ _ = try s.grow();
+ }
+ page.pauseIntegrityChecks(false);
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+ try s.growRows(1);
+ try testing.expectEqual(@as(usize, 2), s.totalPages());
+ }
+
+ // 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 y = page.size.rows - 1;
+ {
+ const rac = page.getRowAndCell(0, y);
+ rac.row.wrap = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ {
+ const page2 = &s.pages.last.?.data;
+ const y = 0;
+ {
+ const rac = page2.getRowAndCell(0, y);
+ rac.row.wrap_continuation = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page2.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // PageList.diagram ->
+ //
+ // +--+ = PAGE 0
+ // ... : :
+ // +-----+ ACTIVE
+ // 15744 | | | 0
+ // 15745 | | | 1
+ // 15746 | | | 2
+ // 15747 | | | 3
+ // 15748 | | | 4
+ // 15749 | | | 5
+ // 15750 | | | 6
+ // 15751 | | | 7
+ // 15752 |01… | 8
+ // +--+ :
+ // +--+ : = PAGE 1
+ // 0 …01| | 9
+ // +--+ :
+ // +-----+
+
+ // We expect one fewer rows since we unwrapped a row.
+ const end_rows = s.totalRows() - 1;
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, end_rows), s.totalRows());
+
+ // PageList.diagram ->
+ //
+ // +----+ = PAGE 0
+ // ... : :
+ // +----+
+ // +----+ = PAGE 1
+ // ... : :
+ // +-------+ ACTIVE
+ // 6272 | | | 0
+ // 6273 | | | 1
+ // 6274 | | | 2
+ // 6275 | | | 3
+ // 6276 | | | 4
+ // 6277 | | | 5
+ // 6278 | | | 6
+ // 6279 | | | 7
+ // 6280 | | | 8
+ // 6281 |0101| | 9
+ // +----+ :
+ // +-------+
+
+ {
+ // PAGE 1 ROW 6280, ACTIVE 8
+ const p = s.pin(.{ .active = .{ .y = 8 } }).?;
+ const row = p.rowAndCell().row;
+ try testing.expect(!row.wrap);
+ try testing.expect(!row.wrap_continuation);
+
+ const cells = p.cells(.all);
+ try testing.expect(!cells[0].hasText());
+ try testing.expect(!cells[1].hasText());
+ try testing.expect(!cells[2].hasText());
+ try testing.expect(!cells[3].hasText());
+ }
+ {
+ // PAGE 1 ROW 6281, ACTIVE 9
+ const p = s.pin(.{ .active = .{ .y = 9 } }).?;
+ const row = p.rowAndCell().row;
+ try testing.expect(!row.wrap);
+ try testing.expect(!row.wrap_continuation);
+
+ const cells = p.cells(.all);
+ try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
+ try testing.expectEqual(@as(u21, 1), cells[1].content.codepoint);
+ try testing.expectEqual(@as(u21, 0), cells[2].content.codepoint);
+ try testing.expectEqual(@as(u21, 1), cells[3].content.codepoint);
+ }
+}
+
+test "PageList resize reflow more cols wrap across page boundary cursor in second page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+
+ // Grow to the capacity of the first page.
+ {
+ const page = &s.pages.first.?.data;
+ page.pauseIntegrityChecks(true);
+ for (page.size.rows..page.capacity.rows) |_| {
+ _ = try s.grow();
+ }
+ page.pauseIntegrityChecks(false);
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+ try s.growRows(1);
+ try testing.expectEqual(@as(usize, 2), s.totalPages());
+ }
+
+ // 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 y = page.size.rows - 1;
+ {
+ const rac = page.getRowAndCell(0, y);
+ rac.row.wrap = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ {
+ const page2 = &s.pages.last.?.data;
+ const y = 0;
+ {
+ const rac = page2.getRowAndCell(0, y);
+ rac.row.wrap_continuation = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page2.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in wrapped row on the last page
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 9 } }).?);
+ defer s.untrackPin(p);
+ try testing.expect(p.node == s.pages.last.?);
+
+ // We expect one fewer rows since we unwrapped a row.
+ const end_rows = s.totalRows() - 1;
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, end_rows), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 3,
+ .y = 9,
+ } }, s.pointFromPin(.active, p.*).?);
+
+ {
+ const p2 = s.pin(.{ .active = .{ .y = 9 } }).?;
+ const row = p2.rowAndCell().row;
+ try testing.expect(!row.wrap);
+
+ const cells = p2.cells(.all);
+ try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
+ try testing.expectEqual(@as(u21, 1), cells[1].content.codepoint);
+ try testing.expectEqual(@as(u21, 0), cells[2].content.codepoint);
+ try testing.expectEqual(@as(u21, 1), cells[3].content.codepoint);
+ }
+}
+
+test "PageList resize reflow less cols wrap across page boundary cursor in second page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 5, 10, null);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+
+ // Grow to the capacity of the first page.
+ {
+ const page = &s.pages.first.?.data;
+ page.pauseIntegrityChecks(true);
+ for (page.size.rows..page.capacity.rows) |_| {
+ _ = try s.grow();
+ }
+ page.pauseIntegrityChecks(false);
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+ try s.growRows(5);
+ try testing.expectEqual(@as(usize, 2), s.totalPages());
+ }
+
+ // 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 y = page.size.rows - 1;
+ {
+ const rac = page.getRowAndCell(0, y);
+ rac.row.wrap = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ {
+ const page2 = &s.pages.last.?.data;
+ const y = 0;
+ {
+ const rac = page2.getRowAndCell(0, y);
+ rac.row.wrap_continuation = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page2.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in wrapped row on the last page
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 5 } }).?);
+ defer s.untrackPin(p);
+ try testing.expect(p.node == s.pages.last.?);
+ try testing.expect(p.y == 0);
+
+ // PageList.diagram ->
+ //
+ // +-----+ = PAGE 0
+ // ... : :
+ // +--------+ ACTIVE
+ // 7892 | | | 0
+ // 7893 | | | 1
+ // 7894 | | | 2
+ // 7895 | | | 3
+ // 7896 |01234… | 4
+ // +-----+ :
+ // +-----+ : = PAGE 1
+ // 0 …01234| | 5
+ // : ^ : : = PIN 0
+ // 1 | | | 6
+ // 2 | | | 7
+ // 3 | | | 8
+ // 4 | | | 9
+ // +-----+ :
+ // +--------+
+
+ // Resize
+ try s.resize(.{
+ .cols = 4,
+ .reflow = true,
+ .cursor = .{ .x = 2, .y = 5 },
+ });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+
+ // PageList.diagram ->
+ //
+ // +----+ = PAGE 0
+ // ... : :
+ // +-------+ ACTIVE
+ // 7892 | | | 0
+ // 7893 | | | 1
+ // 7894 | | | 2
+ // 7895 | | | 3
+ // 7896 |0123… | 4
+ // 7897 …4012… | 5
+ // : ^: : = PIN 0
+ // 7898 …3400| | 6
+ // 7899 | | | 7
+ // 7900 | | | 8
+ // 7901 | | | 9
+ // +----+ :
+ // +-------+
+
+ // Our cursor should remain on the same cell
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 3,
+ .y = 5,
+ } }, s.pointFromPin(.active, p.*).?);
+
+ {
+ // PAGE 0 ROW 7895, ACTIVE 3
+ const p2 = s.pin(.{ .active = .{ .y = 3 } }).?;
+ const row = p2.rowAndCell().row;
+ try testing.expect(!row.wrap);
+ try testing.expect(!row.wrap_continuation);
+
+ const cells = p2.cells(.all);
+ try testing.expect(!cells[0].hasText());
+ try testing.expect(!cells[1].hasText());
+ try testing.expect(!cells[2].hasText());
+ try testing.expect(!cells[3].hasText());
+ }
+ {
+ // PAGE 0 ROW 7896, ACTIVE 4
+ const p2 = s.pin(.{ .active = .{ .y = 4 } }).?;
+ const row = p2.rowAndCell().row;
+ try testing.expect(row.wrap);
+ try testing.expect(!row.wrap_continuation);
+
+ const cells = p2.cells(.all);
+ try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
+ try testing.expectEqual(@as(u21, 1), cells[1].content.codepoint);
+ try testing.expectEqual(@as(u21, 2), cells[2].content.codepoint);
+ try testing.expectEqual(@as(u21, 3), cells[3].content.codepoint);
+ }
+ {
+ // PAGE 0 ROW 7897, ACTIVE 5
+ const p2 = s.pin(.{ .active = .{ .y = 5 } }).?;
+ const row = p2.rowAndCell().row;
+ try testing.expect(row.wrap);
+ try testing.expect(row.wrap_continuation);
+
+ const cells = p2.cells(.all);
+ try testing.expectEqual(@as(u21, 4), cells[0].content.codepoint);
+ try testing.expectEqual(@as(u21, 0), cells[1].content.codepoint);
+ try testing.expectEqual(@as(u21, 1), cells[2].content.codepoint);
+ try testing.expectEqual(@as(u21, 2), cells[3].content.codepoint);
+ }
+ {
+ // PAGE 0 ROW 7898, ACTIVE 6
+ const p2 = s.pin(.{ .active = .{ .y = 6 } }).?;
+ const row = p2.rowAndCell().row;
+ try testing.expect(!row.wrap);
+ try testing.expect(row.wrap_continuation);
+
+ const cells = p2.cells(.all);
+ try testing.expectEqual(@as(u21, 3), cells[0].content.codepoint);
+ try testing.expectEqual(@as(u21, 4), cells[1].content.codepoint);
+ }
+ {
+ // PAGE 0 ROW 7899, ACTIVE 7
+ const p2 = s.pin(.{ .active = .{ .y = 7 } }).?;
+ const row = p2.rowAndCell().row;
+ try testing.expect(!row.wrap);
+ try testing.expect(!row.wrap_continuation);
+
+ const cells = p2.cells(.all);
+ try testing.expect(!cells[0].hasText());
+ try testing.expect(!cells[1].hasText());
+ try testing.expect(!cells[2].hasText());
+ try testing.expect(!cells[3].hasText());
+ }
+}
+
+test "PageList resize reflow more cols cursor in wrapped row" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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 rac = page.getRowAndCell(0, 0);
+ rac.row.wrap = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ {
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.wrap_continuation = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 1 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 3,
+ .y = 0,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize reflow more cols cursor in not wrapped row" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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 rac = page.getRowAndCell(0, 0);
+ rac.row.wrap = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ {
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.wrap_continuation = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 0 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 1,
+ .y = 0,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize reflow more cols cursor in wrapped row that isn't unwrapped" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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 rac = page.getRowAndCell(0, 0);
+ rac.row.wrap = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ {
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.wrap = true;
+ rac.row.wrap_continuation = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ {
+ {
+ const rac = page.getRowAndCell(0, 2);
+ rac.row.wrap_continuation = true;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, 2);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 2 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 1,
+ .y = 1,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize reflow more cols no reflow preserves semantic prompt" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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 rac = page.getRowAndCell(0, 1);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+ const rac = page.getRowAndCell(0, 1);
+ try testing.expect(rac.row.semantic_prompt == .prompt);
+ }
+}
+
+test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+
+ // 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;
+ page.pauseIntegrityChecks(true);
+ for (page.size.rows..page.capacity.rows) |_| {
+ _ = try s.grow();
+ }
+ page.pauseIntegrityChecks(false);
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+ try s.growRows(1);
+ try testing.expectEqual(@as(usize, 2), s.totalPages());
+
+ // We now have two pages.
+ try std.testing.expect(s.pages.first.? != s.pages.last.?);
+ try std.testing.expectEqual(s.pages.last.?, s.pages.first.?.next);
+ }
+
+ // We use almost all string alloc capacity with a hyperlink in the final
+ // row of the first page, and do the same on the first row of the second
+ // page. We also mark the row as wrapped so that when we resize with more
+ // cols the row unwraps and we have a single row that requires almost two
+ // times the base string alloc capacity.
+ //
+ // This forces the reflow to increase capacity.
+ //
+ // +--+ = PAGE 0
+ // : :
+ // | X… <- where X is hyperlinked with almost all string cap.
+ // +--+
+ // +--+ = PAGE 1
+ // …X | <- X here also almost hits string cap with a hyperlink.
+ // +--+
+
+ // Almost hit string alloc cap in bottom right of first page.
+ // Mark the final row as wrapped.
+ {
+ const page = &s.pages.first.?.data;
+ const id = try page.insertHyperlink(.{
+ .id = .{ .implicit = 0 },
+ .uri = "a" ** (pagepkg.string_bytes_default - 1),
+ });
+ const rac = page.getRowAndCell(page.size.cols - 1, page.size.rows - 1);
+ rac.row.wrap = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'X' },
+ };
+ try page.setHyperlink(rac.row, rac.cell, id);
+ try std.testing.expectError(
+ error.StringsOutOfMemory,
+ page.insertHyperlink(.{
+ .id = .{ .implicit = 1 },
+ .uri = "AAAAAAAAAAAAAAAAAAAAAAAAAA",
+ }),
+ );
+ }
+
+ // 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 id = try page.insertHyperlink(.{
+ .id = .{ .implicit = 1 },
+ .uri = "a" ** (pagepkg.string_bytes_default - 1),
+ });
+ const rac = page.getRowAndCell(0, 0);
+ rac.row.wrap_continuation = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'X' },
+ };
+ try page.setHyperlink(rac.row, rac.cell, id);
+ try std.testing.expectError(
+ error.StringsOutOfMemory,
+ page.insertHyperlink(.{
+ .id = .{ .implicit = 2 },
+ .uri = "AAAAAAAAAAAAAAAAAAAAAAAAAA",
+ }),
+ );
+ }
+
+ // Resize to 1 column wider, unwrapping the row.
+ try s.resize(.{ .cols = s.cols + 1, .reflow = true });
+}
+
+test "PageList resize reflow exceeds grapheme memory forcing capacity increase" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+
+ // 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;
+ page.pauseIntegrityChecks(true);
+ for (page.size.rows..page.capacity.rows) |_| {
+ _ = try s.grow();
+ }
+ page.pauseIntegrityChecks(false);
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+ try s.growRows(1);
+ try testing.expectEqual(@as(usize, 2), s.totalPages());
+
+ // We now have two pages.
+ try std.testing.expect(s.pages.first.? != s.pages.last.?);
+ try std.testing.expectEqual(s.pages.last.?, s.pages.first.?.next);
+ }
+
+ // We use almost all grapheme alloc capacity with a grapheme in the final
+ // row of the first page, and do the same on the first row of the second
+ // page. We also mark the row as wrapped so that when we resize with more
+ // cols the row unwraps and we have a single row that requires almost two
+ // times the base grapheme alloc capacity.
+ //
+ // This forces the reflow to increase capacity.
+ //
+ // +--+ = PAGE 0
+ // : :
+ // | X… <- where X is a grapheme which uses almost all the capacity.
+ // +--+
+ // +--+ = PAGE 1
+ // …X | <- X here also almost hits grapheme cap.
+ // +--+
+
+ // Almost hit grapheme alloc cap in bottom right of first page.
+ // Mark the final row as wrapped.
+ {
+ const page = &s.pages.first.?.data;
+ const rac = page.getRowAndCell(page.size.cols - 1, page.size.rows - 1);
+ rac.row.wrap = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'X' },
+ };
+ try page.setGraphemes(
+ rac.row,
+ rac.cell,
+ &@as(
+ [
+ @divFloor(
+ pagepkg.grapheme_bytes_default - 1,
+ @sizeOf(u21),
+ )
+ ]u21,
+ @splat('a'),
+ ),
+ );
+ try std.testing.expectError(
+ error.OutOfMemory,
+ page.grapheme_alloc.alloc(
+ u21,
+ page.memory,
+ 16,
+ ),
+ );
+ }
+
+ // 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 rac = page.getRowAndCell(0, 0);
+ rac.row.wrap = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'X' },
+ };
+ try page.setGraphemes(
+ rac.row,
+ rac.cell,
+ &@as(
+ [
+ @divFloor(
+ pagepkg.grapheme_bytes_default - 1,
+ @sizeOf(u21),
+ )
+ ]u21,
+ @splat('a'),
+ ),
+ );
+ try std.testing.expectError(
+ error.OutOfMemory,
+ page.grapheme_alloc.alloc(
+ u21,
+ page.memory,
+ 16,
+ ),
+ );
+ }
+
+ // Resize to 1 column wider, unwrapping the row.
+ try s.resize(.{ .cols = s.cols + 1, .reflow = true });
+}
+
+test "PageList resize reflow exceeds style memory forcing capacity increase" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, pagepkg.std_capacity.styles - 1, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+
+ // 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;
+ page.pauseIntegrityChecks(true);
+ for (page.size.rows..page.capacity.rows) |_| {
+ _ = try s.grow();
+ }
+ page.pauseIntegrityChecks(false);
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+ try s.growRows(1);
+ try testing.expectEqual(@as(usize, 2), s.totalPages());
+
+ // We now have two pages.
+ try std.testing.expect(s.pages.first.? != s.pages.last.?);
+ try std.testing.expectEqual(s.pages.last.?, s.pages.first.?.next);
+ }
+
+ // 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;
+ for (0..s.cols) |x| {
+ const id = page.styles.add(
+ page.memory,
+ .{
+ .bg_color = .{ .rgb = .{
+ .r = @truncate(x),
+ .g = @truncate(x >> 8),
+ .b = @truncate(x >> 16),
+ } },
+ },
+ ) catch break;
+
+ const rac = page.getRowAndCell(x, page.size.rows - 1);
+ rac.row.wrap = true;
+ rac.row.styled = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'X' },
+ .style_id = id,
+ };
+ }
+ }
+
+ // 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;
+ for (0..s.cols) |x| {
+ const id = page.styles.add(
+ page.memory,
+ .{
+ .fg_color = .{ .rgb = .{
+ .r = @truncate(x),
+ .g = @truncate(x >> 8),
+ .b = @truncate(x >> 16),
+ } },
+ },
+ ) catch break;
+
+ const rac = page.getRowAndCell(x, 0);
+ rac.row.wrap_continuation = true;
+ rac.row.styled = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'X' },
+ .style_id = id,
+ };
+ }
+ }
+
+ // Resize to twice as wide, fully unwrapping the row.
+ try s.resize(.{ .cols = s.cols * 2, .reflow = true });
+}
+
+test "PageList resize reflow more cols unwrap wide spacer head" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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 rac = page.getRowAndCell(0, 0);
+ rac.row.wrap = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_head,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.wrap_continuation = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '😀' },
+ .wide = .wide,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ try testing.expect(!rac.row.wrap);
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(2, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide);
+ }
+ }
+}
+
+test "PageList resize reflow more cols unwrap wide spacer head across two rows" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 3, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.row.wrap = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.wrap_continuation = true;
+ rac.row.wrap = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_head,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(0, 2);
+ rac.row.wrap_continuation = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '😀' },
+ .wide = .wide,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 2);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ try testing.expect(rac.row.wrap);
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(2, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(3, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide);
+ }
+ }
+}
+
+test "PageList resize reflow more cols unwrap still requires wide spacer head" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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 rac = page.getRowAndCell(0, 0);
+ rac.row.wrap = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.wrap_continuation = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '😀' },
+ .wide = .wide,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 3, .reflow = true });
+ try testing.expectEqual(@as(usize, 3), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ try testing.expect(rac.row.wrap);
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(2, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide);
+ }
+ }
+}
+test "PageList resize reflow less cols no reflow preserves semantic prompt" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 4, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.semantic_prompt = .prompt;
+ }
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ {
+ const p = s.pin(.{ .active = .{ .y = 1 } }).?;
+ const rac = p.rowAndCell();
+ try testing.expect(rac.row.wrap);
+ try testing.expect(rac.row.semantic_prompt == .prompt);
+ }
+ {
+ const p = s.pin(.{ .active = .{ .y = 2 } }).?;
+ const rac = p.rowAndCell();
+ try testing.expect(rac.row.semantic_prompt == .prompt);
+ }
+ }
+}
+
+test "PageList resize reflow less cols no reflow preserves semantic prompt on first line" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 4, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+ const rac = page.getRowAndCell(0, 0);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expect(rac.row.semantic_prompt == .prompt);
+ }
+}
+
+test "PageList resize reflow less cols wrap preserves semantic prompt" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 4, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+ const rac = page.getRowAndCell(0, 0);
+ rac.row.semantic_prompt = .prompt;
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expect(rac.row.semantic_prompt == .prompt);
+ }
+}
+
+test "PageList resize reflow less cols no wrapped rows" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ const end = 4;
+ assert(end < s.cols);
+ for (0..4) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 5, .reflow = true });
+ try testing.expectEqual(@as(usize, 5), s.cols);
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
+ while (it.next()) |offset| {
+ for (0..4) |x| {
+ var offset_copy = offset;
+ offset_copy.x = @intCast(x);
+ const rac = offset_copy.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expectEqual(@as(usize, 5), cells.len);
+ try testing.expectEqual(@as(u21, @intCast(x)), cells[x].content.codepoint);
+ }
+ }
+}
+
+test "PageList resize reflow less cols wrapped rows" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ // Active moves due to scrollback
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, pt);
+ }
+
+ 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);
+ try testing.expect(rac.row.wrap);
+ try testing.expectEqual(@as(usize, 2), cells.len);
+ 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);
+ try testing.expect(!rac.row.wrap);
+ try testing.expectEqual(@as(usize, 2), cells.len);
+ try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint);
+ }
+ {
+ // First row should be wrapped
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ }
+ {
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ }
+}
+
+test "PageList resize reflow less cols wrapped rows with graphemes" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+
+ const rac = page.getRowAndCell(2, y);
+ try page.appendGrapheme(rac.row, rac.cell, 'A');
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ // Active moves due to scrollback
+ {
+ const pt = s.getCell(.{ .active = .{} }).?.screenPoint();
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = 2,
+ } }, pt);
+ }
+
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+ 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);
+ try testing.expect(rac.row.wrap);
+ try testing.expectEqual(@as(usize, 2), cells.len);
+ 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);
+ try testing.expect(!rac.row.wrap);
+ try testing.expect(rac.row.grapheme);
+ try testing.expectEqual(@as(usize, 2), cells.len);
+ try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint);
+
+ const cps = page.lookupGrapheme(rac.cell).?;
+ try testing.expectEqual(@as(usize, 1), cps.len);
+ try testing.expectEqual(@as(u21, 'A'), cps[0]);
+ }
+ {
+ // First row should be wrapped
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ }
+ {
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.getCells(rac.row);
+ try testing.expect(!rac.row.wrap);
+ try testing.expect(rac.row.grapheme);
+ try testing.expectEqual(@as(usize, 2), cells.len);
+ try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint);
+
+ const cps = page.lookupGrapheme(rac.cell).?;
+ try testing.expectEqual(@as(usize, 1), cps.len);
+ try testing.expectEqual(@as(u21, 'A'), cps[0]);
+ }
+}
+
+test "PageList resize reflow less cols cursor in wrapped row" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 1 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 1,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize reflow less cols wraps spacer head" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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 rac = page.getRowAndCell(0, 0);
+ rac.row.wrap = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(2, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(3, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_head,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ rac.row.wrap_continuation = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '😀' },
+ .wide = .wide,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 3, .reflow = true });
+ try testing.expectEqual(@as(usize, 3), s.cols);
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ try testing.expect(rac.row.wrap);
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(2, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide);
+ }
+ }
+}
+test "PageList resize reflow less cols cursor goes to scrollback" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 0 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 4), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expect(s.pointFromPin(.active, p.*) == null);
+}
+
+test "PageList resize reflow less cols cursor in unchanged row" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..2) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 1, .y = 0 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 1,
+ .y = 0,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize reflow less cols cursor in blank cell" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..2) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 2, .y = 0 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ // Our cursor should not move
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 2,
+ .y = 0,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize reflow less cols cursor in final blank cell" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..2) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 3, .y = 0 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 3,
+ .y = 0,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize reflow less cols cursor in wrapped blank cell" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..2) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 5, .y = 0 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 3,
+ .y = 0,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize reflow less cols blank lines" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..1) |y| {
+ for (0..4) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .active = .{} }, null);
+ {
+ // First row should be wrapped
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ }
+ {
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ }
+}
+
+test "PageList resize reflow less cols blank lines between" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ {
+ for (0..4) |x| {
+ const rac = page.getRowAndCell(x, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+ {
+ for (0..4) |x| {
+ const rac = page.getRowAndCell(x, 2);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 5), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .active = .{} }, null);
+ {
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ try testing.expect(!rac.row.wrap);
+ }
+ {
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ }
+ {
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ }
+}
+
+test "PageList resize reflow less cols blank lines between no scrollback" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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 rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(0, 2);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'C' },
+ };
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 3), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .active = .{} }, null);
+ {
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ }
+ {
+ const offset = it.next().?;
+ const rac = offset.rowAndCell();
+ const cells = offset.node.data.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);
+ try testing.expect(!rac.row.wrap);
+ try testing.expectEqual(@as(usize, 2), cells.len);
+ try testing.expectEqual(@as(u21, 'C'), cells[0].content.codepoint);
+ }
+}
+
+test "PageList resize reflow less cols cursor not on last line preserves location" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ 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;
+ for (0..s.rows) |y| {
+ for (0..2) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ };
+ }
+ }
+
+ // Grow blank rows to push our rows back into scrollback
+ try s.growRows(5);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ // Put a tracked pin in the history
+ const p = try s.trackPin(s.pin(.{ .active = .{ .x = 0, .y = 0 } }).?);
+ defer s.untrackPin(p);
+
+ // Resize
+ try s.resize(.{
+ .cols = 4,
+ .reflow = true,
+
+ // Important: not on last row
+ .cursor = .{ .x = 1, .y = 1 },
+ });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 10), s.totalRows());
+
+ // Our cursor should move to the first row
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 0,
+ } }, s.pointFromPin(.active, p.*).?);
+}
+
+test "PageList resize reflow less cols copy style" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 2, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Create a style
+ const style: stylepkg.Style = .{ .flags = .{ .bold = true } };
+ const style_id = try page.styles.add(page.memory, style);
+
+ for (0..s.cols - 1) |x| {
+ const rac = page.getRowAndCell(x, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x) },
+ .style_id = style_id,
+ };
+ page.styles.use(page.memory, style_id);
+ }
+
+ // We're over-counted by 1 because `add` implies `use`.
+ page.styles.release(page.memory, style_id);
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .active = .{} }, null);
+ while (it.next()) |offset| {
+ for (0..s.cols - 1) |x| {
+ var offset_copy = offset;
+ offset_copy.x = @intCast(x);
+ const rac = offset_copy.rowAndCell();
+ const style_id = rac.cell.style_id;
+ try testing.expect(style_id != 0);
+
+ const style = offset.node.data.styles.get(
+ offset.node.data.memory,
+ style_id,
+ );
+ try testing.expect(style.flags.bold);
+
+ const row = rac.row;
+ try testing.expect(row.styled);
+ }
+ }
+}
+
+test "PageList resize reflow less cols to eliminate a wide char" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 1, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '😀' },
+ .wide = .wide,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 1, .reflow = true });
+ try testing.expectEqual(@as(usize, 1), s.cols);
+ try testing.expectEqual(@as(usize, 1), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ }
+ }
+}
+
+test "PageList resize reflow less cols to wrap a wide char" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 3, 1, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'x' },
+ };
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = '😀' },
+ .wide = .wide,
+ };
+ }
+ {
+ const rac = page.getRowAndCell(2, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 'x'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
+ try testing.expect(rac.row.wrap);
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(0, 1);
+ try testing.expectEqual(@as(u21, '😀'), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide);
+ }
+ }
+}
+
+test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a spacer head" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 2, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // We want to make the screen look like this:
+ //
+ // 👨‍👨‍👦‍👦👨‍👨‍👦‍👦
+
+ // First family emoji at (0, 0)
+ {
+ const rac = page.getRowAndCell(0, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme
+ .wide = .wide,
+ };
+ try page.setGraphemes(rac.row, rac.cell, &.{
+ 0x200D, 0x1F468,
+ 0x200D, 0x1F466,
+ 0x200D, 0x1F466,
+ });
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ // Second family emoji at (2, 0)
+ {
+ const rac = page.getRowAndCell(2, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0x1F468 }, // First codepoint of the grapheme
+ .wide = .wide,
+ };
+ try page.setGraphemes(rac.row, rac.cell, &.{
+ 0x200D, 0x1F468,
+ 0x200D, 0x1F466,
+ 0x200D, 0x1F466,
+ });
+ }
+ {
+ const rac = page.getRowAndCell(3, 0);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .wide = .spacer_tail,
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 3, .reflow = true });
+ try testing.expectEqual(@as(usize, 3), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide);
+
+ const cps = page.lookupGrapheme(rac.cell).?;
+ try testing.expectEqual(@as(usize, 6), cps.len);
+ try testing.expectEqual(@as(u21, 0x200D), cps[0]);
+ try testing.expectEqual(@as(u21, 0x1F468), cps[1]);
+ try testing.expectEqual(@as(u21, 0x200D), cps[2]);
+ try testing.expectEqual(@as(u21, 0x1F466), cps[3]);
+ try testing.expectEqual(@as(u21, 0x200D), cps[4]);
+ try testing.expectEqual(@as(u21, 0x1F466), cps[5]);
+
+ // Row should be wrapped
+ try testing.expect(rac.row.wrap);
+ }
+ {
+ const rac = page.getRowAndCell(1, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide);
+ }
+ {
+ const rac = page.getRowAndCell(2, 0);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_head, rac.cell.wide);
+ }
+
+ {
+ const rac = page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.wide, rac.cell.wide);
+
+ const cps = page.lookupGrapheme(rac.cell).?;
+ try testing.expectEqual(@as(usize, 6), cps.len);
+ try testing.expectEqual(@as(u21, 0x200D), cps[0]);
+ try testing.expectEqual(@as(u21, 0x1F468), cps[1]);
+ try testing.expectEqual(@as(u21, 0x200D), cps[2]);
+ try testing.expectEqual(@as(u21, 0x1F466), cps[3]);
+ try testing.expectEqual(@as(u21, 0x200D), cps[4]);
+ try testing.expectEqual(@as(u21, 0x1F466), cps[5]);
+ }
+ {
+ const rac = page.getRowAndCell(1, 1);
+ try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint);
+ try testing.expectEqual(pagepkg.Cell.Wide.spacer_tail, rac.cell.wide);
+ }
+ }
+}
+
+test "PageList resize reflow less cols copy kitty placeholder" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 2, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Write unicode placeholders
+ for (0..s.cols - 1) |x| {
+ const rac = page.getRowAndCell(x, 0);
+ rac.row.kitty_virtual_placeholder = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = kitty.graphics.unicode.placeholder },
+ };
+ }
+ }
+
+ // Resize
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .active = .{} }, null);
+ while (it.next()) |offset| {
+ for (0..s.cols - 1) |x| {
+ var offset_copy = offset;
+ offset_copy.x = @intCast(x);
+ const rac = offset_copy.rowAndCell();
+
+ const row = rac.row;
+ try testing.expect(row.kitty_virtual_placeholder);
+ }
+ }
+}
+
+test "PageList resize reflow more cols clears kitty placeholder" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 2, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Write unicode placeholders
+ for (0..s.cols - 1) |x| {
+ const rac = page.getRowAndCell(x, 0);
+ rac.row.kitty_virtual_placeholder = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = kitty.graphics.unicode.placeholder },
+ };
+ }
+ }
+
+ // Resize smaller then larger
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .active = .{} }, null);
+ {
+ const row = it.next().?;
+ const rac = row.rowAndCell();
+ try testing.expect(rac.row.kitty_virtual_placeholder);
+ }
+ {
+ const row = it.next().?;
+ const rac = row.rowAndCell();
+ try testing.expect(!rac.row.kitty_virtual_placeholder);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList resize reflow wrap moves kitty placeholder" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 2, 0);
+ defer s.deinit();
+ {
+ try testing.expect(s.pages.first == s.pages.last);
+ const page = &s.pages.first.?.data;
+
+ // Write unicode placeholders
+ for (2..s.cols - 1) |x| {
+ const rac = page.getRowAndCell(x, 0);
+ rac.row.kitty_virtual_placeholder = true;
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = kitty.graphics.unicode.placeholder },
+ };
+ }
+ }
+
+ try s.resize(.{ .cols = 2, .reflow = true });
+ try testing.expectEqual(@as(usize, 2), s.cols);
+ try testing.expectEqual(@as(usize, 2), s.totalRows());
+
+ var it = s.rowIterator(.right_down, .{ .active = .{} }, null);
+ {
+ const row = it.next().?;
+ const rac = row.rowAndCell();
+ try testing.expect(!rac.row.kitty_virtual_placeholder);
+ }
+ {
+ const row = it.next().?;
+ const rac = row.rowAndCell();
+ try testing.expect(rac.row.kitty_virtual_placeholder);
+ }
+ try testing.expect(it.next() == null);
+}
+
+test "PageList reset" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ s.reset();
+ try testing.expect(s.viewport == .active);
+ try testing.expect(s.pages.first != null);
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ // Active area should be the top
+ try testing.expectEqual(Pin{
+ .node = s.pages.first.?,
+ .y = 0,
+ .x = 0,
+ }, s.getTopLeft(.active));
+}
+
+test "PageList reset across two pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Find a cap that makes it so that rows don't fit on one page.
+ const rows = 100;
+ const cap = cap: {
+ var cap = try std_capacity.adjust(.{ .cols = 50 });
+ while (cap.rows >= rows) cap = try std_capacity.adjust(.{
+ .cols = cap.cols + 50,
+ });
+
+ break :cap cap;
+ };
+
+ // Init
+ var s = try init(alloc, cap.cols, rows, null);
+ defer s.deinit();
+ s.reset();
+ try testing.expect(s.viewport == .active);
+ try testing.expect(s.pages.first != null);
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+}
+
+test "PageList reset moves tracked pins and marks them as garbage" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Create a tracked pin into the active area
+ const p = try s.trackPin(s.pin(.{ .active = .{
+ .x = 42,
+ .y = 12,
+ } }).?);
+ defer s.untrackPin(p);
+
+ s.reset();
+
+ // Our added pin should now be garbage
+ try testing.expect(p.garbage);
+
+ // Viewport pin should not be garbage because it makes sense.
+ try testing.expect(!s.viewport_pin.garbage);
+}
+
+test "PageList clears history" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growRows(30);
+ s.reset();
+ try testing.expect(s.viewport == .active);
+ try testing.expect(s.pages.first != null);
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+
+ // Active area should be the top
+ try testing.expectEqual(Pin{
+ .node = s.pages.first.?,
+ .y = 0,
+ .x = 0,
+ }, s.getTopLeft(.active));
+}
+
+test "PageList resize reflow grapheme map capacity exceeded" {
+ // This test verifies that when reflowing content with many graphemes,
+ // the grapheme map capacity is correctly increased when needed.
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 4, 10, 0);
+ defer s.deinit();
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+
+ // Get the grapheme capacity from the page. We need more than this many
+ // graphemes in a single destination page to trigger capacity increase
+ // 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();
+ // 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;
+
+ // 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;
+ page.pauseIntegrityChecks(true);
+ for (page.size.rows..page.capacity.rows) |_| {
+ _ = try s.grow();
+ }
+ page.pauseIntegrityChecks(false);
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+ try s.growRows(graphemes_per_page);
+ try testing.expectEqual(@as(usize, 2), s.totalPages());
+
+ // We now have two pages.
+ try testing.expect(s.pages.first.? != s.pages.last.?);
+ try testing.expectEqual(s.pages.last.?, s.pages.first.?.next);
+ }
+
+ // Add graphemes to both pages. We add graphemes to rows at the END of the
+ // first page, and graphemes to rows at the START of the second page.
+ // When reflowing to 2 columns, these rows will wrap and stay together
+ // on the same destination page, requiring capacity increase.
+
+ // Add graphemes to the end of the first page (last rows)
+ {
+ const page = &s.pages.first.?.data;
+ const start_row = page.size.rows - graphemes_per_page;
+ for (0..graphemes_per_page) |i| {
+ const y = start_row + i;
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0301)));
+ }
+ }
+
+ // Add graphemes to the beginning of the second page
+ {
+ const page = &s.pages.last.?.data;
+ const count = @min(graphemes_per_page, page.size.rows);
+ for (0..count) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'B' },
+ };
+ try page.appendGrapheme(rac.row, rac.cell, @as(u21, @intCast(0x0302)));
+ }
+ }
+
+ // Resize to fewer columns to trigger reflow.
+ // The graphemes from both pages will be copied to destination pages.
+ // They will all end up in a contiguous region of the destination.
+ // If the bug exists (hyperlink_bytes increased instead of grapheme_bytes),
+ // this will fail with GraphemeMapOutOfMemory when we exceed capacity.
+ try s.resize(.{ .cols = 2, .reflow = true });
+
+ // Verify the resize succeeded
+ try testing.expectEqual(@as(usize, 2), s.cols);
+}
+
+test "PageList resize grow cols with unwrap fixes viewport pin" {
+ // Regression test: after resize/reflow, the viewport pin can end up at a
+ // position where pin.y + rows > total_rows, causing getBottomRight to panic.
+
+ // The plan is to pin viewport in history, then grow columns to unwrap rows.
+ // The unwrap reduces total_rows, but the tracked pin moves to a position
+ // that no longer has enough rows below it for the viewport height.
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 10, null);
+ defer s.deinit();
+
+ // Make sure we have some history, in this case we have 30 rows of history
+ try s.growRows(30);
+ try testing.expectEqual(@as(usize, 40), s.totalRows());
+
+ // 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;
+ for (chunk.start..chunk.end) |y| {
+ const rac = page.getRowAndCell(0, y);
+ if (y % 2 == 0) {
+ rac.row.wrap = true;
+ } else {
+ rac.row.wrap_continuation = true;
+ }
+ for (0..s.cols) |x| {
+ page.getRowAndCell(x, y).cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'A' },
+ };
+ }
+ }
+ }
+
+ // Pin viewport at row 28 (in history, 2 rows before active area at row 30).
+ // After unwrap: row 28 -> row 14, total_rows 40 -> 20, active starts at 10.
+ // Pin at 14 needs rows 14-23, but only 0-19 exist -> overflow.
+ s.scroll(.{ .pin = s.pin(.{ .screen = .{ .y = 28 } }).? });
+ try testing.expect(s.viewport == .pin);
+ try testing.expect(s.getBottomRight(.viewport) != null);
+
+ // Resize with reflow: unwraps rows, reducing total_rows
+ try s.resize(.{ .cols = 4, .reflow = true });
+ try testing.expectEqual(@as(usize, 4), s.cols);
+ try testing.expect(s.totalRows() < 40);
+
+ // Used to panic here, so test that we can get the bottom right.
+ const br_after = s.getBottomRight(.viewport);
+ try testing.expect(br_after != null);
+}
+
+test "PageList grow reuses non-standard page without leak" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Create a PageList with 3 * std_size max so we can fit multiple pages
+ // but will still trigger reuse.
+ var s = try init(alloc, 80, 24, 3 * std_size);
+ 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) {
+ _ = 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);
+
+ // 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) {
+ _ = try s.grow();
+ }
+
+ // Now grow to create a second page
+ _ = try s.grow();
+ try testing.expect(s.pages.first != s.pages.last);
+
+ // 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)
+ {
+ _ = try s.grow();
+ }
+
+ // The first page should still be non-standard
+ try testing.expect(s.pages.first.?.data.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);
+
+ // 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;
+
+ // 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 });
+ defer s.untrackPin(tracked_pin);
+
+ // Now grow one more time to trigger the reuse path. Since the first page
+ // is non-standard, it should be destroyed (not reused). The testing
+ // allocator will detect a leak if destroyNode doesn't properly free
+ // the non-standard memory.
+ _ = try s.grow();
+
+ // After grow, check if the first page is a different one
+ // (meaning the non-standard page was pruned, not reused at the end)
+ // The original first page should no longer be the first page
+ try testing.expect(s.pages.first.? != first_page_ptr);
+
+ // 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);
+
+ // 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);
+ try testing.expectEqual(0, tracked_pin.x);
+ try testing.expectEqual(0, tracked_pin.y);
+ try testing.expect(tracked_pin.garbage);
+}
+
+test "PageList grow non-standard page prune protection" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // This test specifically verifies the fix for the bug where pruning a
+ // non-standard page would cause totalRows() < self.rows.
+ //
+ // Bug trigger conditions (all must be true simultaneously):
+ // 1. first page is non-standard (memory.len > std_size)
+ // 2. page_size + PagePool.item_size > maxSize() (triggers prune consideration)
+ // 3. pages.first != pages.last (have multiple pages)
+ // 4. total_rows >= self.rows (have enough rows for active area)
+ // 5. total_rows - first.size.rows + 1 < self.rows (prune would lose too many)
+
+ // This is kind of magic and likely depends on std_size.
+ const rows_count = 600;
+ var s = try init(alloc, 80, rows_count, std_size);
+ defer s.deinit();
+
+ // Make the first page non-standard
+ while (s.pages.first.?.data.memory.len <= std_size) {
+ _ = try s.increaseCapacity(
+ s.pages.first.?,
+ .grapheme_bytes,
+ );
+ }
+ try testing.expect(s.pages.first.?.data.memory.len > std_size);
+
+ const first_page_node = s.pages.first.?;
+ const first_page_cap = first_page_node.data.capacity.rows;
+
+ // Fill first page to capacity
+ while (first_page_node.data.size.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;
+ while (s.pages.first == s.pages.last) second_node = try s.grow();
+ try testing.expect(s.pages.first != s.pages.last);
+
+ // 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();
+
+ // Now the last page is full. The next grow must either:
+ // 1. Prune the first page and reuse it, OR
+ // 2. Allocate a new page
+ const total = s.totalRows();
+ const would_remain = total - first_page_cap + 1;
+
+ // Verify the bug condition is present: pruning first page would leave < rows
+ try testing.expect(would_remain < s.rows);
+
+ // Verify prune path conditions are met
+ try testing.expect(s.pages.first != s.pages.last);
+ try testing.expect(s.page_size + PagePool.item_size > s.maxSize());
+ 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);
+
+ // The next grow should trigger prune consideration.
+ // Without the fix, this would destroy the non-standard first page,
+ // leaving only second_cap + 1 rows, which is < self.rows.
+ _ = try s.grow();
+
+ // Verify the invariant holds - the fix prevents the destructive prune
+ try testing.expect(s.totalRows() >= s.rows);
+}
+
+test "PageList resize (no reflow) more cols remaps pins in backfill path" {
+ // Regression test: when resizeWithoutReflowGrowCols copies rows to a previous
+ // page with spare capacity, tracked pins in those rows must be remapped.
+ // Without the fix, pins become dangling pointers when the original page is destroyed.
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ const cols: size.CellCountInt = 5;
+ const cap = try std_capacity.adjust(.{ .cols = cols });
+ var s = try init(alloc, cols, cap.rows, null);
+ defer s.deinit();
+
+ // Grow until we have two pages.
+ while (s.pages.first == s.pages.last) {
+ _ = try s.grow();
+ }
+ const first_page = s.pages.first.?;
+ const second_page = s.pages.last.?;
+ try testing.expect(first_page != second_page);
+
+ // 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);
+
+ // 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);
+
+ // 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.
+ const tracked = try s.trackPin(.{ .node = second_page, .x = 0, .y = 0 });
+ defer s.untrackPin(tracked);
+
+ // Write a marker character to the tracked cell so we can verify
+ // the pin points to the correct cell after resize.
+ const marker: u21 = 'X';
+ tracked.rowAndCell().cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = marker },
+ };
+
+ try s.resize(.{ .cols = new_cols, .reflow = false });
+
+ // Verify the pin points to a valid node still in the page list.
+ var found = false;
+ var it = s.pages.first;
+ while (it) |node| : (it = node.next) {
+ if (node == tracked.node) {
+ found = true;
+ break;
+ }
+ }
+ try testing.expect(found);
+ try testing.expect(tracked.y < tracked.node.data.size.rows);
+
+ // Verify the pin still points to the cell with our marker content.
+ const cell = tracked.rowAndCell().cell;
+ try testing.expectEqual(.codepoint, cell.content_tag);
+ try testing.expectEqual(marker, cell.content.codepoint);
+}
+
+test "PageList compact std_size page returns null" {
+ 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
+ const node = s.pages.first.?;
+ try testing.expect(node.data.memory.len <= std_size);
+
+ // compact should return null since there's nothing to compact
+ const result = try s.compact(node);
+ try testing.expectEqual(null, result);
+
+ // Page should still be the same
+ try testing.expectEqual(node, s.pages.first.?);
+}
+
+test "PageList compact oversized page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // 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) |_| {
+ _ = try s.grow();
+ }
+ page1_node.data.pauseIntegrityChecks(false);
+ _ = try s.grow();
+ try testing.expect(s.pages.first != s.pages.last);
+
+ var node = s.pages.first.?;
+
+ // Write content to verify it's preserved
+ {
+ const page = &node.data;
+ for (0..page.size.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(x + y * s.cols) },
+ };
+ }
+ }
+ }
+
+ // Create a tracked pin on this page
+ const tracked = try s.trackPin(.{ .node = node, .x = 5, .y = 10 });
+ defer s.untrackPin(tracked);
+
+ // Make the page oversized
+ while (node.data.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;
+ const second_node = node.next.?;
+
+ // Set dirty flag after increaseCapacity
+ node.data.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);
+
+ // 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);
+
+ // Verify dirty flag preserved
+ try testing.expect(new_node.?.data.dirty);
+
+ // Verify linked list integrity
+ try testing.expectEqual(new_node.?, s.pages.first.?);
+ try testing.expectEqual(null, new_node.?.prev);
+ try testing.expectEqual(second_node, new_node.?.next);
+ try testing.expectEqual(new_node.?, second_node.prev);
+
+ // Verify pin updated correctly
+ try testing.expectEqual(new_node.?, tracked.node);
+ try testing.expectEqual(@as(size.CellCountInt, 5), tracked.x);
+ try testing.expectEqual(@as(size.CellCountInt, 10), tracked.y);
+
+ // Verify content preserved
+ const page = &new_node.?.data;
+ for (0..page.size.rows) |y| {
+ for (0..s.cols) |x| {
+ const rac = page.getRowAndCell(x, y);
+ try testing.expectEqual(
+ @as(u21, @intCast(x + y * s.cols)),
+ rac.cell.content.codepoint,
+ );
+ }
+ }
+}
+
+test "PageList compact insufficient savings returns null" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, 0);
+ defer s.deinit();
+
+ var node = s.pages.first.?;
+
+ // Make the page slightly oversized (just one increase)
+ // This might not provide enough savings to justify compaction
+ node = try s.increaseCapacity(node, .grapheme_bytes);
+
+ // 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);
+ }
+ }
+}
+
+test "PageList split at middle row" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const page = &s.pages.first.?.data;
+
+ // Write content to rows: row 0 gets codepoint 0, row 1 gets 1, etc.
+ for (0..page.size.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(y) },
+ };
+ }
+
+ // Split at row 5 (middle)
+ const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
+ try s.split(split_pin);
+
+ // Verify two pages exist
+ 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;
+
+ // First page should have rows 0-4 (5 rows)
+ try testing.expectEqual(@as(usize, 5), first_page.size.rows);
+ // Second page should have rows 5-9 (5 rows)
+ try testing.expectEqual(@as(usize, 5), second_page.size.rows);
+
+ // Verify content in first page is preserved (rows 0-4 have codepoints 0-4)
+ for (0..5) |y| {
+ const rac = first_page.getRowAndCell(0, y);
+ try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint);
+ }
+
+ // Verify content in second page (original rows 5-9, now at y=0-4)
+ for (0..5) |y| {
+ const rac = second_page.getRowAndCell(0, y);
+ try testing.expectEqual(@as(u21, @intCast(y + 5)), rac.cell.content.codepoint);
+ }
+}
+
+test "PageList split at row 0 is no-op" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const page = &s.pages.first.?.data;
+
+ // Write content to all rows
+ for (0..page.size.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(y) },
+ };
+ }
+
+ // Split at row 0 should be a no-op
+ const split_pin: Pin = .{ .node = s.pages.first.?, .y = 0, .x = 0 };
+ try s.split(split_pin);
+
+ // Verify only one page exists (no split occurred)
+ try testing.expect(s.pages.first != null);
+ try testing.expect(s.pages.first.?.next == null);
+
+ // Verify all content is still in the original page
+ try testing.expectEqual(@as(usize, 10), page.size.rows);
+ for (0..10) |y| {
+ const rac = page.getRowAndCell(0, y);
+ try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint);
+ }
+}
+
+test "PageList split at last row" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const page = &s.pages.first.?.data;
+
+ // Write content to all rows
+ for (0..page.size.rows) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast(y) },
+ };
+ }
+
+ // Split at last row (row 9)
+ const split_pin: Pin = .{ .node = s.pages.first.?, .y = 9, .x = 0 };
+ try s.split(split_pin);
+
+ // Verify two pages exist
+ 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;
+
+ // First page should have 9 rows
+ try testing.expectEqual(@as(usize, 9), first_page.size.rows);
+ // Second page should have 1 row
+ try testing.expectEqual(@as(usize, 1), second_page.size.rows);
+
+ // Verify content in second page (original row 9, now at y=0)
+ const rac = second_page.getRowAndCell(0, 0);
+ try testing.expectEqual(@as(u21, 9), rac.cell.content.codepoint);
+}
+
+test "PageList split single row page returns OutOfSpace" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Initialize with 1 row
+ var s = try init(alloc, 10, 1, 0);
+ defer s.deinit();
+
+ const split_pin: Pin = .{ .node = s.pages.first.?, .y = 0, .x = 0 };
+ const result = s.split(split_pin);
+
+ try testing.expectError(error.OutOfSpace, result);
+}
+
+test "PageList split moves tracked pins" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ // Track a pin at row 7
+ const tracked = try s.trackPin(.{ .node = s.pages.first.?, .y = 7, .x = 3 });
+ defer s.untrackPin(tracked);
+
+ // Split at row 5
+ const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
+ try s.split(split_pin);
+
+ // The tracked pin should now be in the second page
+ try testing.expect(tracked.node == s.pages.first.?.next.?);
+ // y should be adjusted: was 7, split at 5, so new y = 7 - 5 = 2
+ try testing.expectEqual(@as(usize, 2), tracked.y);
+ // x should remain unchanged
+ try testing.expectEqual(@as(usize, 3), tracked.x);
+}
+
+test "PageList split tracked pin before split point unchanged" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const original_node = s.pages.first.?;
+
+ // Track a pin at row 2 (before the split point)
+ const tracked = try s.trackPin(.{ .node = original_node, .y = 2, .x = 5 });
+ defer s.untrackPin(tracked);
+
+ // Split at row 5
+ const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 };
+ try s.split(split_pin);
+
+ // The tracked pin should remain in the original page
+ try testing.expect(tracked.node == s.pages.first.?);
+ // y and x should be unchanged
+ try testing.expectEqual(@as(usize, 2), tracked.y);
+ try testing.expectEqual(@as(usize, 5), tracked.x);
+}
+
+test "PageList split tracked pin at split point moves to new page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const original_node = s.pages.first.?;
+
+ // Track a pin at the exact split point (row 5)
+ const tracked = try s.trackPin(.{ .node = original_node, .y = 5, .x = 4 });
+ defer s.untrackPin(tracked);
+
+ // Split at row 5
+ const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 };
+ try s.split(split_pin);
+
+ // The tracked pin should be in the new page
+ try testing.expect(tracked.node == s.pages.first.?.next.?);
+ // y should be 0 since it was at the split point: 5 - 5 = 0
+ try testing.expectEqual(@as(usize, 0), tracked.y);
+ // x should remain unchanged
+ try testing.expectEqual(@as(usize, 4), tracked.x);
+}
+
+test "PageList split multiple tracked pins across regions" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const original_node = s.pages.first.?;
+
+ // Track multiple pins in different regions
+ const pin_before = try s.trackPin(.{ .node = original_node, .y = 1, .x = 0 });
+ defer s.untrackPin(pin_before);
+ const pin_at_split = try s.trackPin(.{ .node = original_node, .y = 5, .x = 2 });
+ defer s.untrackPin(pin_at_split);
+ const pin_after1 = try s.trackPin(.{ .node = original_node, .y = 7, .x = 3 });
+ defer s.untrackPin(pin_after1);
+ const pin_after2 = try s.trackPin(.{ .node = original_node, .y = 9, .x = 8 });
+ defer s.untrackPin(pin_after2);
+
+ // Split at row 5
+ const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 };
+ try s.split(split_pin);
+
+ const first_page = s.pages.first.?;
+ const second_page = first_page.next.?;
+
+ // Pin before split point stays in original page
+ try testing.expect(pin_before.node == first_page);
+ try testing.expectEqual(@as(usize, 1), pin_before.y);
+ try testing.expectEqual(@as(usize, 0), pin_before.x);
+
+ // Pin at split point moves to new page with y=0
+ try testing.expect(pin_at_split.node == second_page);
+ try testing.expectEqual(@as(usize, 0), pin_at_split.y);
+ try testing.expectEqual(@as(usize, 2), pin_at_split.x);
+
+ // Pins after split point move to new page with adjusted y
+ try testing.expect(pin_after1.node == second_page);
+ try testing.expectEqual(@as(usize, 2), pin_after1.y); // 7 - 5 = 2
+ try testing.expectEqual(@as(usize, 3), pin_after1.x);
+
+ try testing.expect(pin_after2.node == second_page);
+ try testing.expectEqual(@as(usize, 4), pin_after2.y); // 9 - 5 = 4
+ try testing.expectEqual(@as(usize, 8), pin_after2.x);
+}
+
+test "PageList split tracked viewport_pin in split region moves correctly" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const original_node = s.pages.first.?;
+
+ // Set viewport_pin to row 7 (after split point)
+ s.viewport_pin.node = original_node;
+ s.viewport_pin.y = 7;
+ s.viewport_pin.x = 6;
+
+ // Split at row 5
+ const split_pin: Pin = .{ .node = original_node, .y = 5, .x = 0 };
+ try s.split(split_pin);
+
+ // viewport_pin should be in the new page
+ try testing.expect(s.viewport_pin.node == s.pages.first.?.next.?);
+ // y should be adjusted: 7 - 5 = 2
+ try testing.expectEqual(@as(usize, 2), s.viewport_pin.y);
+ // x should remain unchanged
+ try testing.expectEqual(@as(usize, 6), s.viewport_pin.x);
+}
+
+test "PageList split middle page preserves linked list order" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Create a single page with 12 rows
+ var s = try init(alloc, 10, 12, 0);
+ defer s.deinit();
+
+ // Split at row 4 to create: page1 (rows 0-3), page2 (rows 4-11)
+ const first_node = s.pages.first.?;
+ const split_pin1: Pin = .{ .node = first_node, .y = 4, .x = 0 };
+ try s.split(split_pin1);
+
+ // 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);
+
+ // 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 };
+ try s.split(split_pin2);
+
+ // Now we have 3 pages
+ const first = s.pages.first.?;
+ const middle = first.next.?;
+ const last = middle.next.?;
+
+ // Verify linked list order: first -> middle -> last
+ try testing.expectEqual(page1, first);
+ try testing.expectEqual(page2, middle);
+ try testing.expectEqual(s.pages.last.?, last);
+
+ // Verify prev pointers
+ try testing.expect(first.prev == null);
+ try testing.expectEqual(first, middle.prev.?);
+ try testing.expectEqual(middle, last.prev.?);
+
+ // Verify next pointers
+ try testing.expectEqual(middle, first.next.?);
+ try testing.expectEqual(last, middle.next.?);
+ 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);
+}
+
+test "PageList split last page makes new page the last" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Create a single page with 10 rows
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ // Split to create 2 pages first
+ const first_node = s.pages.first.?;
+ const split_pin1: Pin = .{ .node = first_node, .y = 5, .x = 0 };
+ try s.split(split_pin1);
+
+ // Now split the last page
+ const last_before_split = s.pages.last.?;
+ try testing.expectEqual(@as(usize, 5), last_before_split.data.size.rows);
+
+ const split_pin2: Pin = .{ .node = last_before_split, .y = 2, .x = 0 };
+ try s.split(split_pin2);
+
+ // The new page should be the new last
+ const new_last = s.pages.last.?;
+ try testing.expect(new_last != last_before_split);
+ try testing.expectEqual(last_before_split, new_last.prev.?);
+ 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);
+}
+
+test "PageList split first page keeps original as first" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Create 2 pages by splitting
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const original_first = s.pages.first.?;
+ const split_pin1: Pin = .{ .node = original_first, .y = 5, .x = 0 };
+ try s.split(split_pin1);
+
+ // Get second page (created by first split)
+ const second_page = s.pages.first.?.next.?;
+
+ // Now split the first page again
+ const split_pin2: Pin = .{ .node = s.pages.first.?, .y = 2, .x = 0 };
+ try s.split(split_pin2);
+
+ // Original first should still be first
+ try testing.expectEqual(original_first, s.pages.first.?);
+ try testing.expect(s.pages.first.?.prev == null);
+
+ // New page should be inserted between first and second
+ const inserted = s.pages.first.?.next.?;
+ try testing.expect(inserted != second_page);
+ 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);
+}
+
+test "PageList split preserves wrap flags" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const page = &s.pages.first.?.data;
+
+ // 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)
+ // Row 6: wrap_continuation = true (this continues the wrap)
+ // Row 7: wrap = true, wrap_continuation = true (wrapped and continues)
+ {
+ const rac5 = page.getRowAndCell(0, 5);
+ rac5.row.wrap = true;
+
+ const rac6 = page.getRowAndCell(0, 6);
+ rac6.row.wrap_continuation = true;
+
+ const rac7 = page.getRowAndCell(0, 7);
+ rac7.row.wrap = true;
+ rac7.row.wrap_continuation = true;
+ }
+
+ // Split at row 5
+ 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;
+
+ // Verify wrap flags are preserved in new page
+ // Original row 5 is now row 0 in second page
+ {
+ const rac0 = second_page.getRowAndCell(0, 0);
+ try testing.expect(rac0.row.wrap);
+ try testing.expect(!rac0.row.wrap_continuation);
+ }
+
+ // Original row 6 is now row 1 in second page
+ {
+ const rac1 = second_page.getRowAndCell(0, 1);
+ try testing.expect(!rac1.row.wrap);
+ try testing.expect(rac1.row.wrap_continuation);
+ }
+
+ // Original row 7 is now row 2 in second page
+ {
+ const rac2 = second_page.getRowAndCell(0, 2);
+ try testing.expect(rac2.row.wrap);
+ try testing.expect(rac2.row.wrap_continuation);
+ }
+}
+
+test "PageList split preserves styled cells" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const page = &s.pages.first.?.data;
+
+ // 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 } };
+ const style_id = try page.styles.add(page.memory, style);
+
+ for (5..8) |y| {
+ const rac = page.getRowAndCell(0, y);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'S' },
+ .style_id = style_id,
+ };
+ rac.row.styled = true;
+ page.styles.use(page.memory, style_id);
+ }
+ // Release the extra ref from add
+ page.styles.release(page.memory, style_id);
+
+ // Split at row 5
+ 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;
+
+ // First page should have no styles (all styled rows moved to second page)
+ try testing.expectEqual(@as(usize, 0), first_page.styles.count());
+
+ // Second page should have exactly 1 style (the bold style, used by 3 cells)
+ try testing.expectEqual(@as(usize, 1), second_page.styles.count());
+
+ // Verify styled cells are preserved in new page
+ for (0..3) |y| {
+ const rac = second_page.getRowAndCell(0, y);
+ try testing.expectEqual(@as(u21, 'S'), rac.cell.content.codepoint);
+ try testing.expect(rac.cell.style_id != 0);
+
+ const got_style = second_page.styles.get(second_page.memory, rac.cell.style_id);
+ try testing.expect(got_style.flags.bold);
+ try testing.expect(rac.row.styled);
+ }
+}
+
+test "PageList split preserves grapheme clusters" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const page = &s.pages.first.?.data;
+
+ // Add a grapheme cluster to row 6 (will be row 1 in second page after split at 5)
+ {
+ const rac = page.getRowAndCell(0, 6);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0x1F468 }, // Man emoji
+ };
+ try page.setGraphemes(rac.row, rac.cell, &.{
+ 0x200D, // ZWJ
+ 0x1F469, // Woman emoji
+ });
+ }
+
+ // Split at row 5
+ 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;
+
+ // First page should have no graphemes (the grapheme row moved to second page)
+ try testing.expectEqual(@as(usize, 0), first_page.graphemeCount());
+
+ // Second page should have exactly 1 grapheme
+ try testing.expectEqual(@as(usize, 1), second_page.graphemeCount());
+
+ // Verify grapheme is preserved in new page (original row 6 is now row 1)
+ {
+ const rac = second_page.getRowAndCell(0, 1);
+ try testing.expectEqual(@as(u21, 0x1F468), rac.cell.content.codepoint);
+ try testing.expect(rac.row.grapheme);
+
+ const cps = second_page.lookupGrapheme(rac.cell).?;
+ try testing.expectEqual(@as(usize, 2), cps.len);
+ try testing.expectEqual(@as(u21, 0x200D), cps[0]);
+ try testing.expectEqual(@as(u21, 0x1F469), cps[1]);
+ }
+}
+
+test "PageList split preserves hyperlinks" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 10, 10, 0);
+ defer s.deinit();
+
+ const page = &s.pages.first.?.data;
+
+ // Add a hyperlink to row 7 (will be row 2 in second page after split at 5)
+ const hyperlink_id = try page.insertHyperlink(.{
+ .id = .{ .implicit = 0 },
+ .uri = "https://example.com",
+ });
+ {
+ const rac = page.getRowAndCell(0, 7);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'L' },
+ };
+ try page.setHyperlink(rac.row, rac.cell, hyperlink_id);
+ }
+
+ // Split at row 5
+ 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;
+
+ // First page should have no hyperlinks (the hyperlink row moved to second page)
+ try testing.expectEqual(@as(usize, 0), first_page.hyperlink_set.count());
+
+ // Second page should have exactly 1 hyperlink
+ try testing.expectEqual(@as(usize, 1), second_page.hyperlink_set.count());
+
+ // Verify hyperlink is preserved in new page (original row 7 is now row 2)
+ {
+ const rac = second_page.getRowAndCell(0, 2);
+ try testing.expectEqual(@as(u21, 'L'), rac.cell.content.codepoint);
+ try testing.expect(rac.cell.hyperlink);
+
+ const link_id = second_page.lookupHyperlink(rac.cell).?;
+ const link = second_page.hyperlink_set.get(second_page.memory, link_id);
+ try testing.expectEqualStrings("https://example.com", link.uri.slice(second_page.memory));
+ }
+}