diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-04-04 19:22:04 +0300 |
|---|---|---|
| committer | Can Celik <ogulcancelik@gmail.com> | 2026-04-05 01:23:06 +0300 |
| commit | 41dd733ca7123c9ac4bfc6c781697a660e95b052 (patch) | |
| tree | 3fc91815ca139ef2e0b281bba0abda4ef15175f4 /vendor/libghostty-vt/src/terminal/page.zig | |
| parent | 55c138263e190a1e79d4b997bfb9397306c0ec1f (diff) | |
feat: finish ghostty pane backend integration
Diffstat (limited to 'vendor/libghostty-vt/src/terminal/page.zig')
| -rw-r--r-- | vendor/libghostty-vt/src/terminal/page.zig | 3918 |
1 files changed, 3918 insertions, 0 deletions
diff --git a/vendor/libghostty-vt/src/terminal/page.zig b/vendor/libghostty-vt/src/terminal/page.zig new file mode 100644 index 00000000..0942e260 --- /dev/null +++ b/vendor/libghostty-vt/src/terminal/page.zig @@ -0,0 +1,3918 @@ +const std = @import("std"); +const builtin = @import("builtin"); +const build_options = @import("terminal_options"); +const Allocator = std.mem.Allocator; +const ArenaAllocator = std.heap.ArenaAllocator; +const assert = @import("../quirks.zig").inlineAssert; +const testing = std.testing; +const posix = std.posix; +const windows = std.os.windows; +const fastmem = @import("../fastmem.zig"); +const color = @import("color.zig"); +const hyperlink = @import("hyperlink.zig"); +const kitty = @import("kitty.zig"); +const stylepkg = @import("style.zig"); +const Style = stylepkg.Style; +const StyleId = stylepkg.Id; +const StyleSet = stylepkg.Set; +const size = @import("size.zig"); +const getOffset = size.getOffset; +const Offset = size.Offset; +const OffsetBuf = size.OffsetBuf; +const BitmapAllocator = @import("bitmap_allocator.zig").BitmapAllocator; +const hash_map = @import("hash_map.zig"); +const AutoOffsetHashMap = hash_map.AutoOffsetHashMap; +const alignForward = std.mem.alignForward; +const alignBackward = std.mem.alignBackward; + +const log = std.log.scoped(.page); + +/// Page-aligned allocator used for terminal page backing memory. Pages +/// require page-aligned, zeroed memory obtained directly from the OS +/// (not the Zig allocator) because the allocation fast-path is +/// performance-critical and the OS guarantees zeroed pages. +const PageAlloc = switch (builtin.os.tag) { + .windows => AllocWindows, + else => AllocPosix, +}; + +/// Allocate page-aligned, zeroed backing memory using mmap with +/// MAP_PRIVATE | MAP_ANONYMOUS which guarantees zeroed pages. +const AllocPosix = struct { + pub fn alloc(n: usize) ![]align(std.heap.page_size_min) u8 { + return try posix.mmap( + null, + n, + posix.PROT.READ | posix.PROT.WRITE, + .{ .TYPE = .PRIVATE, .ANONYMOUS = true }, + -1, + 0, + ); + } + + pub fn free(mem: []align(std.heap.page_size_min) u8) void { + posix.munmap(mem); + } +}; + +/// Allocate page-aligned, zeroed backing memory using VirtualAlloc with +/// MEM_COMMIT | MEM_RESERVE which guarantees zeroed pages. +const AllocWindows = struct { + pub fn alloc(n: usize) error{OutOfMemory}![]align(std.heap.page_size_min) u8 { + const addr = windows.VirtualAlloc( + null, + n, + windows.MEM_COMMIT | windows.MEM_RESERVE, + windows.PAGE_READWRITE, + ) catch return error.OutOfMemory; + + return @as( + [*]align(std.heap.page_size_min) u8, + @ptrCast(@alignCast(addr)), + )[0..n]; + } + + pub fn free(mem: []align(std.heap.page_size_min) u8) void { + windows.VirtualFree( + @ptrCast(@alignCast(mem.ptr)), + 0, + windows.MEM_RELEASE, + ); + } +}; + +/// The allocator to use for multi-codepoint grapheme data. We use +/// a chunk size of 4 codepoints. It'd be best to set this empirically +/// but it is currently set based on vibes. My thinking around 4 codepoints +/// is that most skin-tone emoji are <= 4 codepoints, letter combiners +/// are usually <= 4 codepoints, and 4 codepoints is a nice power of two +/// for alignment. +const grapheme_chunk_len = 4; +const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21); +const GraphemeAlloc = BitmapAllocator(grapheme_chunk); +const grapheme_count_default = GraphemeAlloc.bitmap_bit_size; +pub const grapheme_bytes_default = grapheme_count_default * grapheme_chunk; +const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice); + +/// The allocator used for shared utf8-encoded strings within a page. +/// Note the chunk size below is the minimum size of a single allocation +/// and requires a single bit of metadata in our bitmap allocator. Therefore +/// it should be tuned carefully (too small and we waste metadata, too large +/// and we have fragmentation). We can probably use a better allocation +/// strategy in the future. +/// +/// At the time of writing this, the strings table is only used for OSC8 +/// IDs and URIs. IDs are usually short and URIs are usually longer. I chose +/// 32 bytes as a compromise between these two since it represents single +/// domain links quite well and is not too wasteful for short IDs. We can +/// continue to tune this as we see how it's used. +const string_chunk_len = 32; +const string_chunk = string_chunk_len * @sizeOf(u8); +const StringAlloc = BitmapAllocator(string_chunk); +const string_count_default = StringAlloc.bitmap_bit_size; +pub const string_bytes_default = string_count_default * string_chunk; + +/// Default number of hyperlinks we support. +/// +/// The cell multiplier is the number of cells per hyperlink entry that +/// we support. A hyperlink can be longer than this multiplier; the multiplier +/// just sets the total capacity to simplify adjustable size metrics. +const hyperlink_count_default = 4; +const hyperlink_bytes_default = hyperlink_count_default * @sizeOf(hyperlink.Set.Item); +const hyperlink_cell_multiplier = 16; + +/// A page represents a specific section of terminal screen. The primary +/// idea of a page is that it is a fully self-contained unit that can be +/// serialized, copied, etc. as a convenient way to represent a section +/// of the screen. +/// +/// This property is useful for renderers which want to copy just the pages +/// for the visible portion of the screen, or for infinite scrollback where +/// we may want to serialize and store pages that are sufficiently far +/// away from the current viewport. +/// +/// Pages are always backed by a single contiguous block of memory that is +/// aligned on a page boundary. This makes it easy and fast to copy pages +/// around. Within the contiguous block of memory, the contents of a page are +/// thoughtfully laid out to optimize primarily for terminal IO (VT streams) +/// and to minimize memory usage. +pub const Page = struct { + comptime { + // The alignment of our members. We want to ensure that the page + // alignment is always divisible by this. + assert(std.heap.page_size_min % @max( + @alignOf(Row), + @alignOf(Cell), + StyleSet.base_align.toByteUnits(), + ) == 0); + } + + /// The backing memory for the page. A page is always made up of a + /// a single contiguous block of memory that is aligned on a page + /// boundary and is a multiple of the system page size. + memory: []align(std.heap.page_size_min) u8, + + /// The array of rows in the page. The rows are always in row order + /// (i.e. index 0 is the top row, index 1 is the row below that, etc.) + rows: Offset(Row), + + /// The array of cells in the page. The cells are NOT in row order, + /// but they are in column order. To determine the mapping of cells + /// to row, you must use the `rows` field. From the pointer to the + /// first column, all cells in that row are laid out in column order. + cells: Offset(Cell), + + /// Set to true when an operation is performed that dirties all rows in + /// the page. See `Row.dirty` for more information on dirty tracking. + /// + /// NOTE: A value of false does NOT indicate that + /// the page has no dirty rows in it, only + /// that no full-page-dirtying operations + /// have occurred since it was last cleared. + dirty: bool, + + /// The string allocator for this page used for shared utf-8 encoded + /// strings. Liveness of strings and memory management is deferred to + /// the individual use case. + string_alloc: StringAlloc, + + /// The multi-codepoint grapheme data for this page. This is where + /// any cell that has more than one codepoint will be stored. This is + /// relatively rare (typically only emoji) so this defaults to a very small + /// size and we force page realloc when it grows. + grapheme_alloc: GraphemeAlloc, + + /// The mapping of cell to grapheme data. The exact mapping is the + /// cell offset to the grapheme data offset. Therefore, whenever a + /// cell is moved (i.e. `erase`) then the grapheme data must be updated. + /// Grapheme data is relatively rare so this is considered a slow + /// path. + grapheme_map: GraphemeMap, + + /// The available set of styles in use on this page. + styles: StyleSet, + + /// The structures used for tracking hyperlinks within the page. + /// The map maps cell offsets to hyperlink IDs and the IDs are in + /// the ref counted set. The strings within the hyperlink structures + /// are allocated in the string allocator. + hyperlink_map: hyperlink.Map, + hyperlink_set: hyperlink.Set, + + /// The current dimensions of the page. The capacity may be larger + /// than this. This allows us to allocate a larger page than necessary + /// and also to resize a page smaller without reallocating. + size: Size, + + /// The capacity of this page. This is the full size of the backing + /// memory and is fixed at page creation time. + capacity: Capacity, + + /// 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 {}, + + /// Initialize a new page, allocating the required backing memory. + /// The size of the initialized page defaults to the full capacity. + /// + /// The backing memory is always allocated using mmap directly. + /// You cannot use custom allocators with this structure because + /// it is critical to performance that we use mmap. + pub inline fn init(cap: Capacity) !Page { + const l = layout(cap); + + // We allocate page-aligned zeroed memory directly to avoid Zig + // allocator overhead (small but meaningful for this path). Both + // mmap (POSIX) and VirtualAlloc (Windows) guarantee zeroed pages, + // which is a critical property for us. + assert(l.total_size % std.heap.page_size_min == 0); + const backing = try PageAlloc.alloc(l.total_size); + errdefer PageAlloc.free(backing); + + const buf = OffsetBuf.init(backing); + return initBuf(buf, l); + } + + /// Initialize a new page using the given backing memory. + /// It is up to the caller to not call deinit on these pages. + pub inline fn initBuf(buf: OffsetBuf, l: Layout) Page { + const cap = l.capacity; + const rows = buf.member(Row, l.rows_start); + const cells = buf.member(Cell, l.cells_start); + + // We need to go through and initialize all the rows so that + // they point to a valid offset into the cells, since the rows + // zero-initialized aren't valid. + const cells_len = @as(usize, cap.cols) * @as(usize, cap.rows); + const cells_ptr = cells.ptr(buf)[0..cells_len]; + for (rows.ptr(buf)[0..cap.rows], 0..) |*row, y| { + const start = y * cap.cols; + row.* = .{ + .cells = getOffset(Cell, buf, &cells_ptr[start]), + }; + } + + return .{ + .memory = @alignCast(buf.start()[0..l.total_size]), + .rows = rows, + .cells = cells, + .styles = StyleSet.init( + buf.add(l.styles_start), + l.styles_layout, + .{}, + ), + .string_alloc = .init( + buf.add(l.string_alloc_start), + l.string_alloc_layout, + ), + .grapheme_alloc = .init( + buf.add(l.grapheme_alloc_start), + l.grapheme_alloc_layout, + ), + .grapheme_map = .init( + buf.add(l.grapheme_map_start), + l.grapheme_map_layout, + ), + .hyperlink_map = .init( + buf.add(l.hyperlink_map_start), + l.hyperlink_map_layout, + ), + .hyperlink_set = .init( + buf.add(l.hyperlink_set_start), + l.hyperlink_set_layout, + .{}, + ), + .size = .{ .cols = cap.cols, .rows = cap.rows }, + .capacity = cap, + .dirty = false, + }; + } + + /// Deinitialize the page, freeing any backing memory. Do NOT call + /// this if you allocated the backing memory yourself (i.e. you used + /// initBuf). + pub inline fn deinit(self: *Page) void { + PageAlloc.free(self.memory); + self.* = undefined; + } + + /// Reinitialize the page with the same capacity. + pub inline fn reinit(self: *Page) void { + // We zero the page memory as u64 instead of u8 because + // we can and it's empirically quite a bit faster. + @memset(@as([*]u64, @ptrCast(self.memory))[0 .. self.memory.len / 8], 0); + self.* = initBuf(.init(self.memory), layout(self.capacity)); + } + + pub const IntegrityError = error{ + ZeroRowCount, + ZeroColCount, + UnmarkedGraphemeRow, + MissingGraphemeData, + InvalidGraphemeCount, + UnmarkedGraphemeCell, + MissingStyle, + UnmarkedStyleRow, + MismatchedStyleRef, + InvalidStyleCount, + MissingHyperlinkData, + MismatchedHyperlinkRef, + UnmarkedHyperlinkCell, + UnmarkedHyperlinkRow, + InvalidSpacerTailLocation, + InvalidSpacerHeadLocation, + UnwrappedSpacerHead, + }; + + /// Temporarily pause integrity checks. This is useful when you are + /// doing a lot of operations that would trigger integrity check + /// violations but you know the page will end up in a consistent state. + pub inline fn pauseIntegrityChecks(self: *Page, v: bool) void { + if (build_options.slow_runtime_safety) { + if (v) { + self.pause_integrity_checks += 1; + } else { + self.pause_integrity_checks -= 1; + } + } + } + + /// A helper that can be used to assert the integrity of the page + /// when runtime safety is enabled. This is a no-op when runtime + /// safety is disabled. This uses the libc allocator. + pub inline fn assertIntegrity(self: *const Page) void { + if (comptime build_options.slow_runtime_safety) { + var debug_allocator: std.heap.DebugAllocator(.{}) = .init; + defer _ = debug_allocator.deinit(); + const alloc = debug_allocator.allocator(); + self.verifyIntegrity(alloc) catch |err| { + log.err("page integrity violation, crashing. err={}", .{err}); + @panic("page integrity violation"); + }; + } + } + + /// Verifies the integrity of the page data. This is not fast, + /// but it is useful for assertions, deserialization, etc. The + /// allocator is only used for temporary allocations -- all memory + /// is freed before this function returns. + /// + /// Integrity errors are also logged as warnings. + pub fn verifyIntegrity(self: *const Page, alloc_gpa: Allocator) !void { + // Some things that seem like we should check but do not: + // + // - We do not check that the style ref count is exact, only that + // it is at least what we see. We do this because some fast paths + // trim rows without clearing data. + // - We do not check that styles seen is exactly the same as the + // styles count in the page for the same reason as above. + // - We only check that we saw less graphemes than the total memory + // used for the same reason as styles above. + // + + // We don't run integrity checks on Valgrind because its soooooo slow, + // Valgrind is our integrity checker, and we run these during unit + // tests (non-Valgrind) anyways so we're verifying anyways. + if (std.valgrind.runningOnValgrind() > 0) return; + + if (build_options.slow_runtime_safety) { + if (self.pause_integrity_checks > 0) return; + } + + if (self.size.rows == 0) { + log.warn("page integrity violation zero row count", .{}); + return IntegrityError.ZeroRowCount; + } + if (self.size.cols == 0) { + log.warn("page integrity violation zero col count", .{}); + return IntegrityError.ZeroColCount; + } + + var arena = ArenaAllocator.init(alloc_gpa); + defer arena.deinit(); + const alloc = arena.allocator(); + + var graphemes_seen: usize = 0; + var styles_seen = std.AutoHashMap(StyleId, usize).init(alloc); + defer styles_seen.deinit(); + var hyperlinks_seen = std.AutoHashMap(hyperlink.Id, usize).init(alloc); + defer hyperlinks_seen.deinit(); + + const grapheme_count = self.graphemeCount(); + + const rows = self.rows.ptr(self.memory)[0..self.size.rows]; + for (rows, 0..) |*row, y| { + const graphemes_start = graphemes_seen; + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells, 0..) |*cell, x| { + if (cell.hasGrapheme()) { + // If a cell has grapheme data, it must be present in + // the grapheme map. + _ = self.lookupGrapheme(cell) orelse { + log.warn( + "page integrity violation y={} x={} grapheme data missing", + .{ y, x }, + ); + return IntegrityError.MissingGraphemeData; + }; + + graphemes_seen += 1; + } else if (grapheme_count > 0) { + // It should not have grapheme data if it isn't marked. + // The grapheme_count check above is just an optimization + // to speed up integrity checks. + if (self.lookupGrapheme(cell) != null) { + log.warn( + "page integrity violation y={} x={} cell not marked as grapheme", + .{ y, x }, + ); + return IntegrityError.UnmarkedGraphemeCell; + } + } + + if (cell.style_id != stylepkg.default_id) { + // If a cell has a style, it must be present in the styles + // set. Accessing it with `get` asserts that. + _ = self.styles.get( + self.memory, + cell.style_id, + ); + + if (!row.styled) { + log.warn( + "page integrity violation y={} x={} row not marked as styled", + .{ y, x }, + ); + return IntegrityError.UnmarkedStyleRow; + } + + const gop = try styles_seen.getOrPut(cell.style_id); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += 1; + } + + if (cell.hyperlink) { + const id = self.lookupHyperlink(cell) orelse { + log.warn( + "page integrity violation y={} x={} hyperlink data missing", + .{ y, x }, + ); + return IntegrityError.MissingHyperlinkData; + }; + + if (!row.hyperlink) { + log.warn( + "page integrity violation y={} x={} row not marked as hyperlink", + .{ y, x }, + ); + return IntegrityError.UnmarkedHyperlinkRow; + } + + const gop = try hyperlinks_seen.getOrPut(id); + if (!gop.found_existing) gop.value_ptr.* = 0; + gop.value_ptr.* += 1; + + // Hyperlink ID should be valid. This just straight crashes + // if this fails due to assertions. + _ = self.hyperlink_set.get(self.memory, id); + } else { + // It should not have hyperlink data if it isn't marked + if (self.lookupHyperlink(cell) != null) { + log.warn( + "page integrity violation y={} x={} cell not marked as hyperlink", + .{ y, x }, + ); + return IntegrityError.UnmarkedHyperlinkCell; + } + } + + switch (cell.wide) { + .narrow => {}, + .wide => {}, + + .spacer_tail => { + // Spacer tails can't be at the start because they follow + // a wide char. + if (x == 0) { + log.warn( + "page integrity violation y={} x={} spacer tail at start", + .{ y, x }, + ); + return IntegrityError.InvalidSpacerTailLocation; + } + + // Spacer tails must follow a wide char + const prev = cells[x - 1]; + if (prev.wide != .wide) { + log.warn( + "page integrity violation y={} x={} spacer tail not following wide", + .{ y, x }, + ); + return IntegrityError.InvalidSpacerTailLocation; + } + }, + + .spacer_head => { + // Spacer heads must be at the end + if (x != self.size.cols - 1) { + log.warn( + "page integrity violation y={} x={} spacer head not at end", + .{ y, x }, + ); + return IntegrityError.InvalidSpacerHeadLocation; + } + + // The row must be wrapped + if (!row.wrap) { + log.warn( + "page integrity violation y={} spacer head not wrapped", + .{y}, + ); + return IntegrityError.UnwrappedSpacerHead; + } + }, + } + } + + // Check row grapheme data + if (graphemes_seen > graphemes_start) { + // If a cell in a row has grapheme data, the row must + // be marked as having grapheme data. + if (!row.grapheme) { + log.warn( + "page integrity violation y={} grapheme data but row not marked", + .{y}, + ); + return IntegrityError.UnmarkedGraphemeRow; + } + } + } + + // Our graphemes seen should exactly match the grapheme count + if (graphemes_seen > self.graphemeCount()) { + log.warn( + "page integrity violation grapheme count mismatch expected={} actual={}", + .{ graphemes_seen, self.graphemeCount() }, + ); + return IntegrityError.InvalidGraphemeCount; + } + + // Verify all our styles have the correct ref count. + { + var it = styles_seen.iterator(); + while (it.next()) |entry| { + const ref_count = self.styles.refCount(self.memory, entry.key_ptr.*); + if (ref_count < entry.value_ptr.*) { + log.warn( + "page integrity violation style ref count mismatch id={} expected={} actual={}", + .{ entry.key_ptr.*, entry.value_ptr.*, ref_count }, + ); + return IntegrityError.MismatchedStyleRef; + } + } + } + + // Verify all our hyperlinks have the correct ref count. + { + var it = hyperlinks_seen.iterator(); + while (it.next()) |entry| { + const ref_count = self.hyperlink_set.refCount(self.memory, entry.key_ptr.*); + if (ref_count < entry.value_ptr.*) { + log.warn( + "page integrity violation hyperlink ref count mismatch id={} expected={} actual={}", + .{ entry.key_ptr.*, entry.value_ptr.*, ref_count }, + ); + return IntegrityError.MismatchedHyperlinkRef; + } + } + } + + // Verify there are no zombie styles, that is, styles in the + // set with ref counts > 0, which are not present in the page. + { + const styles_table = self.styles.table.ptr(self.memory)[0..self.styles.layout.table_cap]; + const styles_items = self.styles.items.ptr(self.memory)[0..self.styles.layout.cap]; + + var zombies: usize = 0; + + for (styles_table) |id| { + if (id == 0) continue; + const item = styles_items[id]; + if (item.meta.ref == 0) continue; + + const expected = styles_seen.get(id) orelse 0; + if (expected > 0) continue; + + if (item.meta.ref > expected) { + zombies += 1; + } + } + + // NOTE: This is currently disabled because @qwerasd says that + // certain fast paths can cause this but its okay. + // Just 1 zombie style might be the cursor style, so ignore it. + // if (zombies > 1) { + // log.warn( + // "page integrity violation zombie styles count={}", + // .{zombies}, + // ); + // return IntegrityError.ZombieStyles; + // } + } + } + + /// Clone the contents of this page. This will allocate new memory + /// using the page allocator. If you want to manage memory manually, + /// use cloneBuf. + pub inline fn clone(self: *const Page) !Page { + const backing = try PageAlloc.alloc(self.memory.len); + errdefer PageAlloc.free(backing); + return self.cloneBuf(backing); + } + + /// Clone the entire contents of this page. + /// + /// The buffer must be at least the size of self.memory. + pub inline fn cloneBuf(self: *const Page, buf: []align(std.heap.page_size_min) u8) Page { + assert(buf.len >= self.memory.len); + + // The entire concept behind a page is that everything is stored + // as offsets so we can do a simple linear copy of the backing + // memory and copy all the offsets and everything will work. + var result = self.*; + result.memory = buf[0..self.memory.len]; + + // This is a memcpy. We may want to investigate if there are + // faster ways to do this (i.e. copy-on-write tricks) but I suspect + // they'll be slower. I haven't experimented though. + // std.log.warn("copy bytes={}", .{self.memory.len}); + fastmem.copy(u8, result.memory, self.memory); + + return result; + } + + pub const StyleSetError = error{ + StyleSetOutOfMemory, + StyleSetNeedsRehash, + }; + + pub const HyperlinkError = error{ + StringAllocOutOfMemory, + HyperlinkSetOutOfMemory, + HyperlinkSetNeedsRehash, + HyperlinkMapOutOfMemory, + }; + + pub const GraphemeError = error{ + GraphemeMapOutOfMemory, + GraphemeAllocOutOfMemory, + }; + + pub const CloneFromError = + StyleSetError || + HyperlinkError || + GraphemeError; + + /// Compute the exact capacity required to store a range of rows from + /// this page. + /// + /// The returned capacity will have the same number of columns as this + /// page and the number of rows equal to the range given. The returned + /// capacity is by definition strictly less than or equal to this + /// page's capacity, so the layout is guaranteed to succeed. + /// + /// Preconditions: + /// - Range must be at least 1 row + /// - Start and end must be valid for this page + pub fn exactRowCapacity( + self: *const Page, + y_start: usize, + y_end: usize, + ) Capacity { + assert(y_start < y_end); + assert(y_end <= self.size.rows); + + // Track unique IDs using a bitset. Both style IDs and hyperlink IDs + // are CellCountInt (u16), so we reuse this set for both to save + // stack memory (~8KB instead of ~16KB). + const CellCountSet = std.StaticBitSet(std.math.maxInt(size.CellCountInt) + 1); + comptime assert(size.StyleCountInt == size.CellCountInt); + comptime assert(size.HyperlinkCountInt == size.CellCountInt); + + // Accumulators + var id_set: CellCountSet = .initEmpty(); + var grapheme_bytes: usize = 0; + var string_bytes: usize = 0; + + // First pass: count styles and grapheme bytes + const rows = self.rows.ptr(self.memory)[y_start..y_end]; + for (rows) |*row| { + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |*cell| { + if (cell.style_id != stylepkg.default_id) { + id_set.set(cell.style_id); + } + + if (cell.hasGrapheme()) { + if (self.lookupGrapheme(cell)) |cps| { + grapheme_bytes += GraphemeAlloc.bytesRequired(u21, cps.len); + } + } + } + } + const styles_cap = StyleSet.capacityForCount(id_set.count()); + + // Second pass: count hyperlinks and string bytes + // We count both unique hyperlinks (for hyperlink_set) and total + // hyperlink cells (for hyperlink_map capacity). + id_set = .initEmpty(); + var hyperlink_cells: usize = 0; + for (rows) |*row| { + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |*cell| { + if (cell.hyperlink) { + hyperlink_cells += 1; + if (self.lookupHyperlink(cell)) |id| { + // Only count each unique hyperlink once for set sizing + if (!id_set.isSet(id)) { + id_set.set(id); + + // Get the hyperlink entry to compute string bytes + const entry = self.hyperlink_set.get(self.memory, id); + string_bytes += StringAlloc.bytesRequired(u8, entry.uri.len); + + switch (entry.id) { + .implicit => {}, + .explicit => |slice| { + string_bytes += StringAlloc.bytesRequired(u8, slice.len); + }, + } + } + } + } + } + } + + // The hyperlink_map capacity in layout() is computed as: + // hyperlink_count * hyperlink_cell_multiplier (rounded to power of 2) + // We need enough hyperlink_bytes so that when layout() computes + // the map capacity, it can accommodate all hyperlink cells. This + // is unit tested. + const hyperlink_cap = cap: { + const hyperlink_count = id_set.count(); + const hyperlink_set_cap = hyperlink.Set.capacityForCount(hyperlink_count); + const hyperlink_map_min = std.math.divCeil( + usize, + hyperlink_cells, + hyperlink_cell_multiplier, + ) catch 0; + break :cap @max(hyperlink_set_cap, hyperlink_map_min); + }; + + // All the intCasts below are safe because we should have a + // capacity strictly less than or equal to this page's capacity. + return .{ + .cols = self.size.cols, + .rows = @intCast(y_end - y_start), + .styles = @intCast(styles_cap), + .grapheme_bytes = @intCast(grapheme_bytes), + .hyperlink_bytes = @intCast(hyperlink_cap * @sizeOf(hyperlink.Set.Item)), + .string_bytes = @intCast(string_bytes), + }; + } + + /// Clone the contents of another page into this page. The capacities + /// can be different, but the size of the other page must fit into + /// this page. + /// + /// The y_start and y_end parameters allow you to clone only a portion + /// of the other page. This is useful for splitting a page into two + /// or more pages. + /// + /// The column count of this page will always be the same as this page. + /// If the other page has more columns, the extra columns will be + /// truncated. If the other page has fewer columns, the extra columns + /// will be zeroed. + pub inline fn cloneFrom( + self: *Page, + other: *const Page, + y_start: usize, + y_end: usize, + ) CloneFromError!void { + assert(y_start <= y_end); + assert(y_end <= other.size.rows); + assert(y_end - y_start <= self.size.rows); + + const other_rows = other.rows.ptr(other.memory)[y_start..y_end]; + const rows = self.rows.ptr(self.memory)[0 .. y_end - y_start]; + for (rows, other_rows) |*dst_row, *src_row| { + try self.cloneRowFrom(other, dst_row, src_row); + } + + // We should remain consistent + self.assertIntegrity(); + } + + /// Clone a single row from another page into this page. + pub inline fn cloneRowFrom( + self: *Page, + other: *const Page, + dst_row: *Row, + src_row: *const Row, + ) CloneFromError!void { + try self.clonePartialRowFrom( + other, + dst_row, + src_row, + 0, + self.size.cols, + ); + } + + /// Clone a single row from another page into this page, supporting + /// partial copy. cloneRowFrom calls this. + pub fn clonePartialRowFrom( + self: *Page, + other: *const Page, + dst_row: *Row, + src_row: *const Row, + x_start: usize, + x_end_req: usize, + ) CloneFromError!void { + // This whole operation breaks integrity until the end. + self.pauseIntegrityChecks(true); + defer { + self.pauseIntegrityChecks(false); + self.assertIntegrity(); + } + + const cell_len = @min(self.size.cols, other.size.cols); + const x_end = @min(x_end_req, cell_len); + assert(x_start <= x_end); + const other_cells = src_row.cells.ptr(other.memory)[x_start..x_end]; + const cells = dst_row.cells.ptr(self.memory)[x_start..x_end]; + + // If our destination has styles or graphemes then we need to + // clear some state. This will free up the managed memory as well. + if (dst_row.managedMemory()) self.clearCells(dst_row, x_start, x_end); + + // Copy all the row metadata but keep our cells offset + dst_row.* = copy: { + var copy = src_row.*; + + // If we're not copying the full row then we want to preserve + // some original state from our dst row. + if ((x_end - x_start) < self.size.cols) { + copy.wrap = dst_row.wrap; + copy.wrap_continuation = dst_row.wrap_continuation; + copy.grapheme = dst_row.grapheme; + copy.hyperlink = dst_row.hyperlink; + copy.styled = dst_row.styled; + copy.dirty |= dst_row.dirty; + } + + // Our cell offset remains the same + copy.cells = dst_row.cells; + + break :copy copy; + }; + + // If we have no managed memory in the source, then we can just + // copy it directly. + if (!src_row.managedMemory()) { + // This is an integrity check: if the row claims it doesn't + // have managed memory then all cells must also not have + // managed memory. + if (build_options.slow_runtime_safety) { + for (other_cells) |cell| { + assert(!cell.hasGrapheme()); + assert(!cell.hyperlink); + assert(cell.style_id == stylepkg.default_id); + } + } + + fastmem.copy(Cell, cells, other_cells); + } else { + // We have managed memory, so we have to do a slower copy to + // get all of that right. + for (cells, other_cells) |*dst_cell, *src_cell| { + dst_cell.* = src_cell.*; + + // Reset any managed memory markers on the cell so that we don't + // hit an integrity check if we have to return an error because + // the page can't fit the new memory. + dst_cell.hyperlink = false; + dst_cell.style_id = stylepkg.default_id; + if (dst_cell.content_tag == .codepoint_grapheme) { + dst_cell.content_tag = .codepoint; + } + + if (src_cell.hasGrapheme()) { + // Copy the grapheme codepoints + const cps = other.lookupGrapheme(src_cell).?; + + // Safe to use setGraphemes because we cleared all + // managed memory for our destination cell range. + try self.setGraphemes(dst_row, dst_cell, cps); + } + if (src_cell.hyperlink) hyperlink: { + const id = other.lookupHyperlink(src_cell).?; + + // Fast-path: same page we can add with the same id. + if (other == self) { + self.hyperlink_set.use(self.memory, id); + try self.setHyperlink(dst_row, dst_cell, id); + break :hyperlink; + } + + // Slow-path: get the hyperlink from the other page, + // add it, and migrate. + + // If our page can't support an additional cell with + // a hyperlink then we have to return an error. + if (self.hyperlinkCount() >= self.hyperlinkCapacity()) { + // The hyperlink map capacity needs to be increased. + return error.HyperlinkMapOutOfMemory; + } + + const other_link = other.hyperlink_set.get(other.memory, id); + const dst_id = dst_id: { + // First check if the link already exists in our page, + // and increment its refcount if so, since we're about + // to use it. + if (self.hyperlink_set.lookupContext( + self.memory, + other_link.*, + .{ .page = self, .src_page = @constCast(other) }, + )) |i| { + self.hyperlink_set.use(self.memory, i); + break :dst_id i; + } + + // If we don't have this link in our page yet then + // we need to clone it over and add it to our set. + + // Clone the link. + const dst_link = other_link.dupe(other, self) catch |e| { + comptime assert(@TypeOf(e) == error{OutOfMemory}); + // The string alloc capacity needs to be increased. + return error.StringAllocOutOfMemory; + }; + + // Add it, preferring to use the same ID as the other + // page, since this *probably* speeds up full-page + // clones. + // + // TODO(qwerasd): verify the assumption that `addWithId` + // is ever actually useful, I think it may not be. + break :dst_id self.hyperlink_set.addWithIdContext( + self.memory, + dst_link, + id, + .{ .page = self }, + ) catch |e| switch (e) { + // The hyperlink set capacity needs to be increased. + error.OutOfMemory => return error.HyperlinkSetOutOfMemory, + + // The hyperlink set needs to be rehashed. + error.NeedsRehash => return error.HyperlinkSetNeedsRehash, + } orelse id; + }; + + try self.setHyperlink(dst_row, dst_cell, dst_id); + } + if (src_cell.style_id != stylepkg.default_id) style: { + dst_row.styled = true; + + if (other == self) { + // If it's the same page we don't have to worry about + // copying the style, we can use the style ID directly. + dst_cell.style_id = src_cell.style_id; + self.styles.use(self.memory, dst_cell.style_id); + break :style; + } + + // Slow path: Get the style from the other + // page and add it to this page's style set. + const other_style = other.styles.get(other.memory, src_cell.style_id); + dst_cell.style_id = self.styles.addWithId( + self.memory, + other_style.*, + src_cell.style_id, + ) catch |e| switch (e) { + // The style set capacity needs to be increased. + error.OutOfMemory => return error.StyleSetOutOfMemory, + + // The style set needs to be rehashed. + error.NeedsRehash => return error.StyleSetNeedsRehash, + } orelse src_cell.style_id; + } + if (comptime build_options.kitty_graphics) { + if (src_cell.codepoint() == kitty.graphics.unicode.placeholder) { + dst_row.kitty_virtual_placeholder = true; + } + } + } + } + + // If we are growing columns, then we need to ensure spacer heads + // are cleared. + if (self.size.cols > other.size.cols) { + const last = &cells[other.size.cols - 1]; + if (last.wide == .spacer_head) { + last.wide = .narrow; + } + } + } + + /// Get a single row. y must be valid. + pub inline fn getRow(self: *const Page, y: usize) *Row { + assert(y < self.size.rows); + return &self.rows.ptr(self.memory)[y]; + } + + /// Get the cells for a row. + pub inline fn getCells(self: *const Page, row: *Row) []Cell { + if (build_options.slow_runtime_safety) { + const rows = self.rows.ptr(self.memory); + const cells = self.cells.ptr(self.memory); + assert(@intFromPtr(row) >= @intFromPtr(rows)); + assert(@intFromPtr(row) < @intFromPtr(cells)); + } + + const cells = row.cells.ptr(self.memory); + return cells[0..self.size.cols]; + } + + /// Get the row and cell for the given X/Y within this page. + pub inline fn getRowAndCell(self: *const Page, x: usize, y: usize) struct { + row: *Row, + cell: *Cell, + } { + assert(y < self.size.rows); + assert(x < self.size.cols); + + const rows = self.rows.ptr(self.memory); + const row = &rows[y]; + const cell = &row.cells.ptr(self.memory)[x]; + + return .{ .row = row, .cell = cell }; + } + + /// Move a cell from one location to another. This will replace the + /// previous contents with a blank cell. Because this is a move, this + /// doesn't allocate and can't fail. + pub fn moveCells( + self: *Page, + src_row: *Row, + src_left: usize, + dst_row: *Row, + dst_left: usize, + len: usize, + ) void { + defer self.assertIntegrity(); + + const src_cells = src_row.cells.ptr(self.memory)[src_left .. src_left + len]; + const dst_cells = dst_row.cells.ptr(self.memory)[dst_left .. dst_left + len]; + + // Clear our destination now matter what + self.clearCells(dst_row, dst_left, dst_left + len); + + // If src has no managed memory, this is very fast. + if (!src_row.managedMemory()) { + fastmem.copy(Cell, dst_cells, src_cells); + } else { + // Source has graphemes or hyperlinks... + for (src_cells, dst_cells) |*src, *dst| { + dst.* = src.*; + if (src.hasGrapheme()) { + // Required for moveGrapheme assertions + dst.content_tag = .codepoint; + self.moveGrapheme(src, dst); + src.content_tag = .codepoint; + dst.content_tag = .codepoint_grapheme; + dst_row.grapheme = true; + } + if (src.hyperlink) { + dst.hyperlink = false; + self.moveHyperlink(src, dst); + dst.hyperlink = true; + dst_row.hyperlink = true; + } + if (comptime build_options.kitty_graphics) { + if (src.codepoint() == kitty.graphics.unicode.placeholder) { + dst_row.kitty_virtual_placeholder = true; + } + } + } + } + + // The destination row has styles if any of the cells are styled + if (!dst_row.styled) dst_row.styled = styled: for (dst_cells) |c| { + if (c.style_id != stylepkg.default_id) break :styled true; + } else false; + + // Clear our source row now that the copy is complete. We can NOT + // use clearCells here because clearCells will garbage collect our + // styles and graphames but we moved them above. + // + // Zero the cells as u64s since empirically this seems + // to be a bit faster than using @memset(src_cells, .{}) + @memset(@as([]u64, @ptrCast(src_cells)), 0); + if (src_cells.len == self.size.cols) { + src_row.grapheme = false; + src_row.hyperlink = false; + src_row.styled = false; + if (comptime build_options.kitty_graphics) { + src_row.kitty_virtual_placeholder = false; + } + } + } + + /// Swap two cells within the same row as quickly as possible. + pub inline fn swapCells( + self: *Page, + src: *Cell, + dst: *Cell, + ) void { + defer self.assertIntegrity(); + + // Graphemes are keyed by cell offset so we do have to move them. + // We do this first so that all our grapheme state is correct. + if (src.hasGrapheme() or dst.hasGrapheme()) { + if (src.hasGrapheme() and !dst.hasGrapheme()) { + self.moveGrapheme(src, dst); + } else if (!src.hasGrapheme() and dst.hasGrapheme()) { + self.moveGrapheme(dst, src); + } else { + // Both had graphemes, so we have to manually swap + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.grapheme_map.map(self.memory); + const src_entry = map.getEntry(src_offset).?; + const dst_entry = map.getEntry(dst_offset).?; + const src_value = src_entry.value_ptr.*; + const dst_value = dst_entry.value_ptr.*; + src_entry.value_ptr.* = dst_value; + dst_entry.value_ptr.* = src_value; + } + } + + // Hyperlinks are keyed by cell offset. + if (src.hyperlink or dst.hyperlink) { + if (src.hyperlink and !dst.hyperlink) { + self.moveHyperlink(src, dst); + } else if (!src.hyperlink and dst.hyperlink) { + self.moveHyperlink(dst, src); + } else { + // Both had hyperlinks, so we have to manually swap + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.hyperlink_map.map(self.memory); + const src_entry = map.getEntry(src_offset).?; + const dst_entry = map.getEntry(dst_offset).?; + const src_value = src_entry.value_ptr.*; + const dst_value = dst_entry.value_ptr.*; + src_entry.value_ptr.* = dst_value; + dst_entry.value_ptr.* = src_value; + } + } + + // Copy the metadata. Note that we do NOT have to worry about + // styles because styles are keyed by ID and we're preserving the + // exact ref count and row state here. + const old_dst = dst.*; + dst.* = src.*; + src.* = old_dst; + } + + /// Clear the cells in the given row. This will reclaim memory used + /// by graphemes and styles. Note that if the style cleared is still + /// active, Page cannot know this and it will still be ref counted down. + /// The best solution for this is to artificially increment the ref count + /// prior to calling this function. + pub inline fn clearCells( + self: *Page, + row: *Row, + left: usize, + end: usize, + ) void { + defer self.assertIntegrity(); + + const cells = row.cells.ptr(self.memory)[left..end]; + + // If we have managed memory (styles, graphemes, or hyperlinks) + // in this row then we go cell by cell and clear them if present. + if (row.grapheme) { + for (cells) |*cell| { + if (cell.hasGrapheme()) + @call(.always_inline, clearGrapheme, .{ self, cell }); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the graphemes, so we clear the + // flag, otherwise we use the update function to update. + if (cells.len == self.size.cols) { + row.grapheme = false; + } else { + self.updateRowGraphemeFlag(row); + } + } + + if (row.hyperlink) { + for (cells) |*cell| { + if (cell.hyperlink) + @call(.always_inline, clearHyperlink, .{ self, cell }); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the hyperlinks, so we clear the + // flag, otherwise we use the update function to update. + if (cells.len == self.size.cols) { + row.hyperlink = false; + } else { + self.updateRowHyperlinkFlag(row); + } + } + + if (row.styled) { + for (cells) |*cell| { + if (cell.hasStyling()) + self.styles.release(self.memory, cell.style_id); + } + + // If we have no left/right scroll region we can be sure + // that we've cleared all the styles, so we clear the + // flag, otherwise we use the update function to update. + if (cells.len == self.size.cols) { + row.styled = false; + } else { + self.updateRowStyledFlag(row); + } + } + + if (comptime build_options.kitty_graphics) { + if (row.kitty_virtual_placeholder and + cells.len == self.size.cols) + { + for (cells) |c| { + if (c.codepoint() == kitty.graphics.unicode.placeholder) { + break; + } + } else row.kitty_virtual_placeholder = false; + } + } + + // Zero the cells as u64s since empirically this seems + // to be a bit faster than using @memset(cells, .{}) + @memset(@as([]u64, @ptrCast(cells)), 0); + } + + /// Returns the hyperlink ID for the given cell. + pub inline fn lookupHyperlink(self: *const Page, cell: *const Cell) ?hyperlink.Id { + const cell_offset = getOffset(Cell, self.memory, cell); + const map = self.hyperlink_map.map(self.memory); + return map.get(cell_offset); + } + + /// Clear the hyperlink from the given cell. + /// + /// In order to update the hyperlink flag on the row, call + /// `updateRowHyperlinkFlag` after you finish clearing any + /// hyperlinks in the row. + pub fn clearHyperlink(self: *Page, cell: *Cell) void { + defer self.assertIntegrity(); + + // Get our ID + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.hyperlink_map.map(self.memory); + const entry = map.getEntry(cell_offset) orelse return; + + // Release our usage of this, free memory, unset flag + self.hyperlink_set.release(self.memory, entry.value_ptr.*); + map.removeByPtr(entry.key_ptr); + cell.hyperlink = false; + } + + /// Checks if the row contains any hyperlinks and sets + /// the hyperlink flag to false if none are found. + /// + /// Call after removing hyperlinks in a row. + pub inline fn updateRowHyperlinkFlag(self: *Page, row: *Row) void { + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |c| if (c.hyperlink) return; + row.hyperlink = false; + } + + pub const InsertHyperlinkError = error{ + /// string_alloc errors + StringsOutOfMemory, + + /// hyperlink_set errors + SetOutOfMemory, + SetNeedsRehash, + }; + + /// Convert a hyperlink into a page entry, returning the ID. + /// + /// This does not de-dupe any strings, so if the URI, explicit ID, + /// etc. is already in the strings table this will duplicate it. + /// + /// To release the memory associated with the given hyperlink, + /// release the ID from the `hyperlink_set`. If the refcount reaches + /// zero and the slot is needed then the context will reap the + /// memory. + pub fn insertHyperlink( + self: *Page, + link: hyperlink.Hyperlink, + ) InsertHyperlinkError!hyperlink.Id { + // Insert our URI into the page strings table. + const page_uri: Offset(u8).Slice = uri: { + const buf = self.string_alloc.alloc( + u8, + self.memory, + link.uri.len, + ) catch |err| switch (err) { + error.OutOfMemory => return error.StringsOutOfMemory, + }; + errdefer self.string_alloc.free(self.memory, buf); + @memcpy(buf, link.uri); + + break :uri .{ + .offset = size.getOffset(u8, self.memory, &buf[0]), + .len = link.uri.len, + }; + }; + errdefer self.string_alloc.free( + self.memory, + page_uri.slice(self.memory), + ); + + // Allocate an ID for our page memory if we have to. + const page_id: hyperlink.PageEntry.Id = switch (link.id) { + .explicit => |id| explicit: { + const buf = self.string_alloc.alloc( + u8, + self.memory, + id.len, + ) catch |err| switch (err) { + error.OutOfMemory => return error.StringsOutOfMemory, + }; + errdefer self.string_alloc.free(self.memory, buf); + @memcpy(buf, id); + + break :explicit .{ + .explicit = .{ + .offset = size.getOffset(u8, self.memory, &buf[0]), + .len = id.len, + }, + }; + }, + + .implicit => |id| .{ .implicit = id }, + }; + errdefer switch (page_id) { + .implicit => {}, + .explicit => |slice| self.string_alloc.free( + self.memory, + slice.slice(self.memory), + ), + }; + + // Build our entry + const entry: hyperlink.PageEntry = .{ + .id = page_id, + .uri = page_uri, + }; + + // Put our hyperlink into the hyperlink set to get an ID + const id = self.hyperlink_set.addContext( + self.memory, + entry, + .{ .page = self }, + ) catch |err| switch (err) { + error.OutOfMemory => return error.SetOutOfMemory, + error.NeedsRehash => return error.SetNeedsRehash, + }; + errdefer self.hyperlink_set.release(self.memory, id); + + return id; + } + + /// Set the hyperlink for the given cell. If the cell already has a + /// hyperlink, then this will handle memory management and refcount + /// update for the prior hyperlink. + /// + /// DOES NOT increment the reference count for the new hyperlink! + /// + /// Caller is responsible for updating the refcount in the hyperlink + /// set as necessary by calling `use` if the id was not acquired with + /// `add`. + pub inline fn setHyperlink(self: *Page, row: *Row, cell: *Cell, id: hyperlink.Id) error{HyperlinkMapOutOfMemory}!void { + defer self.assertIntegrity(); + + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.hyperlink_map.map(self.memory); + const gop = map.getOrPut(cell_offset) catch |e| { + comptime assert(@TypeOf(e) == error{OutOfMemory}); + // The hyperlink map capacity needs to be increased. + return error.HyperlinkMapOutOfMemory; + }; + + if (gop.found_existing) { + // Always release the old hyperlink, because even if it's actually + // the same as the one we're setting, we'd end up double-counting + // if we left the reference count be, because the caller does not + // know whether it's the same and will have increased the count + // outside of this function. + self.hyperlink_set.release(self.memory, gop.value_ptr.*); + + // If the hyperlink matches then we don't need to do anything. + if (gop.value_ptr.* == id) { + // It is possible for cell hyperlink to be false but row + // must never be false. The cell hyperlink can be false because + // in Terminal.print we clear the hyperlink for the cursor cell + // before writing the cell again, so if someone prints over + // a cell with a matching hyperlink this state can happen. + // This is tested in Terminal.zig. + assert(row.hyperlink); + cell.hyperlink = true; + return; + } + } + + // Set the hyperlink on the cell and in the map. + gop.value_ptr.* = id; + cell.hyperlink = true; + row.hyperlink = true; + } + + /// Move the hyperlink from one cell to another. This can't fail + /// because we avoid any allocations since we're just moving data. + /// Destination must NOT have a hyperlink. + inline fn moveHyperlink(self: *Page, src: *Cell, dst: *Cell) void { + assert(src.hyperlink); + assert(!dst.hyperlink); + + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.hyperlink_map.map(self.memory); + const entry = map.getEntry(src_offset).?; + const value = entry.value_ptr.*; + map.removeByPtr(entry.key_ptr); + map.putAssumeCapacity(dst_offset, value); + + // NOTE: We must not set src/dst.hyperlink here because this + // function is used in various cases where we swap cell contents + // and its unsafe. The flip side: the caller must be careful + // to set the proper cell state to represent the move. + } + + /// Returns the number of hyperlinks in the page. This isn't the byte + /// size but the total number of unique cells that have hyperlink data. + pub inline fn hyperlinkCount(self: *const Page) usize { + return self.hyperlink_map.map(self.memory).count(); + } + + /// Returns the hyperlink capacity for the page. This isn't the byte + /// size but the number of unique cells that can have hyperlink data. + pub inline fn hyperlinkCapacity(self: *const Page) usize { + return self.hyperlink_map.map(self.memory).capacity(); + } + + /// Set the graphemes for the given cell. This asserts that the cell + /// has no graphemes set, and only contains a single codepoint. + pub inline fn setGraphemes( + self: *Page, + row: *Row, + cell: *Cell, + cps: []const u21, + ) GraphemeError!void { + defer self.assertIntegrity(); + + assert(cell.codepoint() > 0); + assert(cell.content_tag == .codepoint); + + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.grapheme_map.map(self.memory); + + const slice = self.grapheme_alloc.alloc(u21, self.memory, cps.len) catch |e| { + comptime assert(@TypeOf(e) == error{OutOfMemory}); + // The grapheme alloc capacity needs to be increased. + return error.GraphemeAllocOutOfMemory; + }; + errdefer self.grapheme_alloc.free(self.memory, slice); + @memcpy(slice, cps); + + map.putNoClobber(cell_offset, .{ + .offset = getOffset(u21, self.memory, @ptrCast(slice.ptr)), + .len = slice.len, + }) catch |e| { + comptime assert(@TypeOf(e) == error{OutOfMemory}); + // The grapheme map capacity needs to be increased. + return error.GraphemeMapOutOfMemory; + }; + errdefer map.remove(cell_offset); + + cell.content_tag = .codepoint_grapheme; + row.grapheme = true; + + return; + } + + /// Append a codepoint to the given cell as a grapheme. + pub fn appendGrapheme(self: *Page, row: *Row, cell: *Cell, cp: u21) Allocator.Error!void { + defer self.assertIntegrity(); + + if (build_options.slow_runtime_safety) assert(cell.codepoint() != 0); + + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.grapheme_map.map(self.memory); + + // If this cell has no graphemes, we can go faster by knowing we + // need to allocate a new grapheme slice and update the map. + if (cell.content_tag != .codepoint_grapheme) { + const cps = try self.grapheme_alloc.alloc(u21, self.memory, 1); + errdefer self.grapheme_alloc.free(self.memory, cps); + cps[0] = cp; + + try map.putNoClobber(cell_offset, .{ + .offset = getOffset(u21, self.memory, @ptrCast(cps.ptr)), + .len = 1, + }); + errdefer map.remove(cell_offset); + + cell.content_tag = .codepoint_grapheme; + row.grapheme = true; + + return; + } + + // The cell already has graphemes. We need to append to the existing + // grapheme slice and update the map. + assert(row.grapheme); + + const slice = map.getPtr(cell_offset).?; + + // If our slice len doesn't divide evenly by the grapheme chunk + // length then we can utilize the additional chunk space. + if (slice.len % grapheme_chunk_len != 0) { + const cps = slice.offset.ptr(self.memory); + cps[slice.len] = cp; + slice.len += 1; + return; + } + + // We are out of chunk space. There is no fast path here. We need + // to allocate a larger chunk. This is a very slow path. We expect + // most graphemes to fit within our chunk size. + const cps = try self.grapheme_alloc.alloc(u21, self.memory, slice.len + 1); + errdefer self.grapheme_alloc.free(self.memory, cps); + const old_cps = slice.slice(self.memory); + fastmem.copy(u21, cps[0..old_cps.len], old_cps); + cps[slice.len] = cp; + slice.* = .{ + .offset = getOffset(u21, self.memory, @ptrCast(cps.ptr)), + .len = slice.len + 1, + }; + + // Free our old chunk + self.grapheme_alloc.free(self.memory, old_cps); + } + + /// Returns the codepoints for the given cell. These are the codepoints + /// in addition to the first codepoint. The first codepoint is NOT + /// included since it is on the cell itself. + pub inline fn lookupGrapheme(self: *const Page, cell: *const Cell) ?[]u21 { + const cell_offset = getOffset(Cell, self.memory, cell); + const map = self.grapheme_map.map(self.memory); + const slice = map.get(cell_offset) orelse return null; + return slice.slice(self.memory); + } + + /// Move the graphemes from one cell to another. This can't fail + /// because we avoid any allocations since we're just moving data. + /// + /// WARNING: This will NOT change the content_tag on the cells because + /// there are scenarios where we want to move graphemes without changing + /// the content tag. Callers beware but assertIntegrity should catch this. + pub inline fn moveGrapheme(self: *Page, src: *Cell, dst: *Cell) void { + if (build_options.slow_runtime_safety) { + assert(src.hasGrapheme()); + assert(!dst.hasGrapheme()); + } + + const src_offset = getOffset(Cell, self.memory, src); + const dst_offset = getOffset(Cell, self.memory, dst); + var map = self.grapheme_map.map(self.memory); + const entry = map.getEntry(src_offset).?; + const value = entry.value_ptr.*; + map.removeByPtr(entry.key_ptr); + map.putAssumeCapacity(dst_offset, value); + } + + /// Clear the graphemes for a given cell. + /// + /// In order to update the grapheme flag on the row, call + /// `updateRowGraphemeFlag` after you finish clearing any + /// graphemes in the row. + pub fn clearGrapheme(self: *Page, cell: *Cell) void { + defer self.assertIntegrity(); + if (build_options.slow_runtime_safety) assert(cell.hasGrapheme()); + + // Get our entry in the map, which must exist + const cell_offset = getOffset(Cell, self.memory, cell); + var map = self.grapheme_map.map(self.memory); + const entry = map.getEntry(cell_offset).?; + + // Free our grapheme data + const cps = entry.value_ptr.slice(self.memory); + self.grapheme_alloc.free(self.memory, cps); + + // Remove the entry + map.removeByPtr(entry.key_ptr); + + // Mark that we no longer have graphemes by changing the content tag. + cell.content_tag = .codepoint; + } + + /// Checks if the row contains any graphemes and sets + /// the grapheme flag to false if none are found. + /// + /// Call after removing graphemes in a row. + pub inline fn updateRowGraphemeFlag(self: *Page, row: *Row) void { + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |c| if (c.hasGrapheme()) return; + row.grapheme = false; + } + + /// Returns the number of graphemes in the page. This isn't the byte + /// size but the total number of unique cells that have grapheme data. + pub inline fn graphemeCount(self: *const Page) usize { + return self.grapheme_map.map(self.memory).count(); + } + + /// Returns the grapheme capacity for the page. This isn't the byte + /// size but the number of unique cells that can have grapheme data. + pub inline fn graphemeCapacity(self: *const Page) usize { + return self.grapheme_map.map(self.memory).capacity(); + } + + /// Checks if the row contains any styles and sets + /// the styled flag to false if none are found. + /// + /// Call after removing styles in a row. + pub inline fn updateRowStyledFlag(self: *Page, row: *Row) void { + const cells = row.cells.ptr(self.memory)[0..self.size.cols]; + for (cells) |c| if (c.hasStyling()) return; + row.styled = false; + } + + /// Returns true if this page is dirty at all. + pub inline fn isDirty(self: *const Page) bool { + if (self.dirty) return true; + for (self.rows.ptr(self.memory)[0..self.size.rows]) |row| { + if (row.dirty) return true; + } + return false; + } + + pub const Layout = struct { + total_size: usize, + rows_start: usize, + rows_size: usize, + cells_start: usize, + cells_size: usize, + styles_start: usize, + styles_layout: StyleSet.Layout, + grapheme_alloc_start: usize, + grapheme_alloc_layout: GraphemeAlloc.Layout, + grapheme_map_start: usize, + grapheme_map_layout: GraphemeMap.Layout, + string_alloc_start: usize, + string_alloc_layout: StringAlloc.Layout, + hyperlink_map_start: usize, + hyperlink_map_layout: hyperlink.Map.Layout, + hyperlink_set_start: usize, + hyperlink_set_layout: hyperlink.Set.Layout, + capacity: Capacity, + }; + + /// The memory layout for a page given a desired minimum cols + /// and rows size. + pub inline fn layout(cap: Capacity) Layout { + const rows_count: usize = @intCast(cap.rows); + const rows_start = 0; + const rows_end: usize = rows_start + (rows_count * @sizeOf(Row)); + + const cells_count: usize = @as(usize, cap.cols) * @as(usize, cap.rows); + const cells_start = alignForward(usize, rows_end, @alignOf(Cell)); + const cells_end = cells_start + (cells_count * @sizeOf(Cell)); + + const styles_layout: StyleSet.Layout = .init(cap.styles); + const styles_start = alignForward(usize, cells_end, StyleSet.base_align.toByteUnits()); + const styles_end = styles_start + styles_layout.total_size; + + const grapheme_alloc_layout = GraphemeAlloc.layout(cap.grapheme_bytes); + const grapheme_alloc_start = alignForward(usize, styles_end, GraphemeAlloc.base_align.toByteUnits()); + const grapheme_alloc_end = grapheme_alloc_start + grapheme_alloc_layout.total_size; + + const grapheme_count: usize = count: { + if (cap.grapheme_bytes == 0) break :count 0; + // Use divCeil to match GraphemeAlloc.layout() which uses alignForward, + // ensuring grapheme_map has capacity when grapheme_alloc has chunks. + const base = std.math.divCeil(usize, cap.grapheme_bytes, grapheme_chunk) catch unreachable; + break :count std.math.ceilPowerOfTwo(usize, base) catch unreachable; + }; + const grapheme_map_layout = GraphemeMap.layout(@intCast(grapheme_count)); + const grapheme_map_start = alignForward(usize, grapheme_alloc_end, GraphemeMap.base_align.toByteUnits()); + const grapheme_map_end = grapheme_map_start + grapheme_map_layout.total_size; + + const string_layout = StringAlloc.layout(cap.string_bytes); + const string_start = alignForward(usize, grapheme_map_end, StringAlloc.base_align.toByteUnits()); + const string_end = string_start + string_layout.total_size; + + const hyperlink_count = @divFloor(cap.hyperlink_bytes, @sizeOf(hyperlink.Set.Item)); + const hyperlink_set_layout: hyperlink.Set.Layout = .init(@intCast(hyperlink_count)); + const hyperlink_set_start = alignForward(usize, string_end, hyperlink.Set.base_align.toByteUnits()); + const hyperlink_set_end = hyperlink_set_start + hyperlink_set_layout.total_size; + + const hyperlink_map_count: u32 = count: { + if (hyperlink_count == 0) break :count 0; + const mult = std.math.cast( + u32, + hyperlink_count * hyperlink_cell_multiplier, + ) orelse break :count std.math.maxInt(u32); + break :count std.math.ceilPowerOfTwoAssert(u32, mult); + }; + const hyperlink_map_layout = hyperlink.Map.layout(hyperlink_map_count); + const hyperlink_map_start = alignForward(usize, hyperlink_set_end, hyperlink.Map.base_align.toByteUnits()); + const hyperlink_map_end = hyperlink_map_start + hyperlink_map_layout.total_size; + + const total_size = alignForward(usize, hyperlink_map_end, std.heap.page_size_min); + + return .{ + .total_size = total_size, + .rows_start = rows_start, + .rows_size = rows_end - rows_start, + .cells_start = cells_start, + .cells_size = cells_end - cells_start, + .styles_start = styles_start, + .styles_layout = styles_layout, + .grapheme_alloc_start = grapheme_alloc_start, + .grapheme_alloc_layout = grapheme_alloc_layout, + .grapheme_map_start = grapheme_map_start, + .grapheme_map_layout = grapheme_map_layout, + .string_alloc_start = string_start, + .string_alloc_layout = string_layout, + .hyperlink_map_start = hyperlink_map_start, + .hyperlink_map_layout = hyperlink_map_layout, + .hyperlink_set_start = hyperlink_set_start, + .hyperlink_set_layout = hyperlink_set_layout, + .capacity = cap, + }; + } +}; + +/// The standard capacity for a page that doesn't have special +/// requirements. This is enough to support a very large number of cells. +/// The standard capacity is chosen as the fast-path for allocation since +/// pages of standard capacity use a pooled allocator instead of single-use +/// mmaps. +pub const std_capacity: Capacity = .{ + .cols = 215, + .rows = 215, + .styles = 128, + .grapheme_bytes = if (builtin.is_test) 512 else 8192, +}; + +/// The size of this page. +pub const Size = struct { + cols: size.CellCountInt, + rows: size.CellCountInt, +}; + +/// Capacity of this page. +/// +/// This capacity can be maxed out (every field max) and still fit +/// within a 64-bit memory space. If you need more than this, you will +/// need to split data across separate pages. +/// +/// For 32-bit systems, it is possible to overflow the addressable +/// space and this is something we still need to address in the future +/// likely by limiting the maximum capacity on 32-bit systems further. +pub const Capacity = struct { + /// Number of columns and rows we can know about. + cols: size.CellCountInt, + rows: size.CellCountInt, + + /// Number of unique styles that can be used on this page. + styles: size.StyleCountInt = 16, + + /// Number of bytes to allocate for hyperlink data. Note that the + /// amount of data used for hyperlinks in total is more than this because + /// hyperlinks use string data as well as a small amount of lookup metadata. + /// This number is a rough approximation. + hyperlink_bytes: size.HyperlinkCountInt = hyperlink_bytes_default, + + /// Number of bytes to allocate for grapheme data. + grapheme_bytes: size.GraphemeBytesInt = grapheme_bytes_default, + + /// Number of bytes to allocate for strings. + string_bytes: size.StringBytesInt = string_bytes_default, + + pub const Adjustment = struct { + cols: ?size.CellCountInt = null, + }; + + /// Returns the maximum number of columns that can be used with this + /// capacity while still fitting at least one row. Returns null if even + /// a single column cannot fit (which would indicate an unusable capacity). + /// + /// Note that this is the maximum number of columns that never increases + /// the amount of memory the original capacity will take. If you modify + /// the original capacity to add rows, then you can fit more columns. + pub fn maxCols(self: Capacity) ?size.CellCountInt { + const available_bits = self.availableBitsForGrid(); + + // If we can't even fit the row metadata, return null + if (available_bits <= @bitSizeOf(Row)) return null; + + // We do the math of how many columns we can fit in the remaining + // bits ignoring the metadata of a row. + const remaining_bits = available_bits - @bitSizeOf(Row); + const max_cols = remaining_bits / @bitSizeOf(Cell); + + // Clamp to CellCountInt max + return @min(std.math.maxInt(size.CellCountInt), max_cols); + } + + /// Adjust the capacity parameters while retaining the same total size. + /// + /// Adjustments always happen by limiting the rows in the page. Everything + /// else can grow. If it is impossible to achieve the desired adjustment, + /// OutOfMemory is returned. + pub fn adjust(self: Capacity, req: Adjustment) Allocator.Error!Capacity { + var adjusted = self; + if (req.cols) |cols| { + const available_bits = self.availableBitsForGrid(); + + // The size per row is: + // - The row metadata itself + // - The cells per row (n=cols) + const bits_per_row: usize = @bitSizeOf(Row) + @bitSizeOf(Cell) * @as(usize, @intCast(cols)); + const new_rows: usize = @divFloor(available_bits, bits_per_row); + + // If our rows go to zero then we can't fit any row metadata + // for the desired number of columns. + if (new_rows == 0) return error.OutOfMemory; + + adjusted.cols = cols; + adjusted.rows = @intCast(new_rows); + } + + return adjusted; + } + + /// Computes the number of bits available for rows and cells in the page. + /// + /// This is done by laying out the "meta" members (styles, graphemes, + /// hyperlinks, strings) from the end of the page and finding where they + /// start, which gives us the space available for rows and cells. + fn availableBitsForGrid(self: Capacity) usize { + // The math below only works if there is no alignment gap between + // the end of the rows array and the start of the cells array. + // + // To guarantee this, we assert that Row's size is a multiple of + // Cell's alignment, so that any length array of Rows will end on + // a valid alignment for the start of the Cell array. + assert(@sizeOf(Row) % @alignOf(Cell) == 0); + + const l = Page.layout(self); + + // Layout meta members from the end to find styles_start + const hyperlink_map_start = alignBackward(usize, l.total_size - l.hyperlink_map_layout.total_size, hyperlink.Map.base_align.toByteUnits()); + const hyperlink_set_start = alignBackward(usize, hyperlink_map_start - l.hyperlink_set_layout.total_size, hyperlink.Set.base_align.toByteUnits()); + const string_alloc_start = alignBackward(usize, hyperlink_set_start - l.string_alloc_layout.total_size, StringAlloc.base_align.toByteUnits()); + const grapheme_map_start = alignBackward(usize, string_alloc_start - l.grapheme_map_layout.total_size, GraphemeMap.base_align.toByteUnits()); + const grapheme_alloc_start = alignBackward(usize, grapheme_map_start - l.grapheme_alloc_layout.total_size, GraphemeAlloc.base_align.toByteUnits()); + const styles_start = alignBackward(usize, grapheme_alloc_start - l.styles_layout.total_size, StyleSet.base_align.toByteUnits()); + + // Multiply by 8 to convert bytes to bits + return styles_start * 8; + } +}; + +pub const Row = packed struct(u64) { + /// The cells in the row offset from the page. + cells: Offset(Cell), + + /// True if this row is soft-wrapped. The first cell of the next + /// row is a continuation of this row. + wrap: bool = false, + + /// True if the previous row to this one is soft-wrapped and + /// this row is a continuation of that row. + wrap_continuation: bool = false, + + /// True if any of the cells in this row have multi-codepoint + /// grapheme clusters. If this is true, some fast paths are not + /// possible because erasing for example may need to clear existing + /// grapheme data. + grapheme: bool = false, + + /// True if any of the cells in this row have a ref-counted style. + /// This can have false positives but never a false negative. Meaning: + /// this will be set to true the first time a style is used, but it + /// will not be set to false if the style is no longer used, because + /// checking for that condition is too expensive. + /// + /// Why have this weird false positive flag at all? This makes VT operations + /// that erase cells (such as insert lines, delete lines, erase chars, + /// etc.) MUCH MUCH faster in the case that the row was never styled. + /// At the time of writing this, the speed difference is around 4x. + styled: bool = false, + + /// True if any of the cells in this row are part of a hyperlink. + /// This is similar to styled: it can have false positives but never + /// false negatives. This is used to optimize hyperlink operations. + hyperlink: bool = false, + + /// The semantic prompt state for this row. + /// + /// This is ONLY meant to note if there are ANY cells in this + /// row that are part of a prompt. This is an optimization for more + /// efficiently implementing jump-to-prompt operations. + /// + /// This may contain false positives but never false negatives. If + /// this is set, you should still check individual cells to see if they + /// have prompt semantics. + semantic_prompt: SemanticPrompt = .none, + + /// True if this row contains a virtual placeholder for the Kitty + /// graphics protocol. (U+10EEEE) + // Note: We keep this as memory-using even if the kitty graphics + // feature is disabled because we want to keep our padding and + // everything throughout the same. + kitty_virtual_placeholder: bool = false, + + /// True if this row is dirty and requires a redraw. This is set to true + /// by any operation that modifies the row's contents or position, and + /// consumers of the page are expected to clear it when they redraw. + /// + /// Dirty status is only ever meant to convey that one or more cells in + /// the row have changed visually. A cell which changes in a way that + /// doesn't affect the visual representation may not be marked as dirty. + /// + /// Dirty tracking may have false positives but should never have false + /// negatives. A false negative would result in a visual artifact on the + /// screen. + dirty: bool = false, + + _padding: u23 = 0, + + /// The semantic prompt state of the row. See `semantic_prompt`. + pub const SemanticPrompt = enum(u2) { + /// No prompt cells in this row. + none = 0, + /// Prompt cells exist in this row and this is a primary prompt + /// line. A primary prompt line is one that is not a continuation + /// and is the beginning of a prompt. + prompt = 1, + /// Prompt cells exist in this row that had k=c set (continuation) + /// line. This is used as a way to detect when a line should + /// be considered part of some prior prompt. If no prior prompt + /// is found, the last (most historical) prompt continuation line is + /// considered the prompt. + prompt_continuation = 2, + }; + + /// C ABI type. + pub const C = u64; + + /// Returns this row as a C ABI value. + pub fn cval(self: Row) C { + return @bitCast(self); + } + + /// Returns true if this row has any managed memory outside of the + /// row structure (graphemes, styles, etc.) + pub inline fn managedMemory(self: Row) bool { + // Ordered on purpose for likelihood. + return self.styled or self.hyperlink or self.grapheme; + } +}; + +/// A cell represents a single terminal grid cell. +/// +/// The zero value of this struct must be a valid cell representing empty, +/// since we zero initialize the backing memory for a page. +pub const Cell = packed struct(u64) { + /// The content tag dictates the active tag in content and possibly + /// some other behaviors. + content_tag: ContentTag = .codepoint, + + /// The content of the cell. This is a union based on content_tag. + content: packed union { + /// The codepoint that this cell contains. If `grapheme` is false, + /// then this is the only codepoint in the cell. If `grapheme` is + /// true, then this is the first codepoint in the grapheme cluster. + codepoint: u21, + + /// The content is an empty cell with a background color. + color_palette: u8, + color_rgb: RGB, + } = .{ .codepoint = 0 }, + + /// The style ID to use for this cell within the style map. Zero + /// is always the default style so no lookup is required. + style_id: StyleId = 0, + + /// The wide property of this cell, for wide characters. Characters in + /// a terminal grid can only be 1 or 2 cells wide. A wide character + /// is always next to a spacer. This is used to determine both the width + /// and spacer properties of a cell. + wide: Wide = .narrow, + + /// Whether this was written with the protection flag set. + protected: bool = false, + + /// Whether this cell is a hyperlink. If this is true then you must + /// look up the hyperlink ID in the page hyperlink_map and the ID in + /// the hyperlink_set to get the actual hyperlink data. + hyperlink: bool = false, + + /// The semantic type of the content of this cell. This is used + /// by the semantic prompt (OSC 133) set of sequences to understand + /// boundary points for content. + semantic_content: SemanticContent = .output, + + _padding: u16 = 0, + + pub const ContentTag = enum(u2) { + /// A single codepoint, could be zero to be empty cell. + codepoint = 0, + + /// A codepoint that is part of a multi-codepoint grapheme cluster. + /// The codepoint tag is active in content, but also expect more + /// codepoints in the grapheme data. + codepoint_grapheme = 1, + + /// The cell has no text but only a background color. This is an + /// optimization so that cells with only backgrounds don't take up + /// style map space and also don't require a style map lookup. + bg_color_palette = 2, + bg_color_rgb = 3, + }; + + pub const RGB = packed struct { + r: u8, + g: u8, + b: u8, + }; + + pub const Wide = enum(u2) { + /// Not a wide character, cell width 1. + narrow = 0, + + /// Wide character, cell width 2. + wide = 1, + + /// Spacer after wide character. Do not render. + spacer_tail = 2, + + /// Spacer at the end of a soft-wrapped line to indicate that a wide + /// character is continued on the next line. + spacer_head = 3, + }; + + pub const SemanticContent = enum(u2) { + /// Regular output content, such as command output. + output = 0, + + /// Content that is part of user input, such as the command + /// to execute at a prompt. + input = 1, + + /// Content that is part of prompt emitted by the interactive + /// application, such as "user@host >" + prompt = 2, + }; + + /// C ABI type. + pub const C = u64; + + /// Returns this cell as a C ABI value. + pub fn cval(self: Cell) C { + return @bitCast(self); + } + + /// Helper to make a cell that just has a codepoint. + pub fn init(cp: u21) Cell { + // We have to use this bitCast here to ensure that our memory is + // zeroed. Otherwise, the content below will leave some uninitialized + // memory in the packed union. Valgrind verifies this. + var cell: Cell = @bitCast(@as(u64, 0)); + cell.content_tag = .codepoint; + cell.content = .{ .codepoint = cp }; + return cell; + } + + pub inline fn isZero(self: Cell) bool { + return @as(u64, @bitCast(self)) == 0; + } + + /// Returns true if this cell represents a cell with text to render. + /// + /// Cases this returns false: + /// - Cell text is blank + /// - Cell is styled but only with a background color and no text + /// - Cell has a unicode placeholder for Kitty graphics protocol + pub inline fn hasText(self: Cell) bool { + return switch (self.content_tag) { + .codepoint, + .codepoint_grapheme, + => self.content.codepoint != 0, + + .bg_color_palette, + .bg_color_rgb, + => false, + }; + } + + pub inline fn codepoint(self: Cell) u21 { + return switch (self.content_tag) { + .codepoint, + .codepoint_grapheme, + => self.content.codepoint, + + .bg_color_palette, + .bg_color_rgb, + => 0, + }; + } + + /// The width in grid cells that this cell takes up. + pub inline fn gridWidth(self: Cell) u2 { + return switch (self.wide) { + .narrow, .spacer_head, .spacer_tail => 1, + .wide => 2, + }; + } + + pub inline fn hasStyling(self: Cell) bool { + return self.style_id != stylepkg.default_id; + } + + /// Returns true if the cell has no text or styling. + pub fn isEmpty(self: Cell) bool { + return switch (self.content_tag) { + // Textual cells are empty if they have no text and are narrow. + // The "narrow" requirement is because wide spacers are meaningful. + .codepoint, + .codepoint_grapheme, + => !self.hasText() and self.wide == .narrow, + + .bg_color_palette, + .bg_color_rgb, + => false, + }; + } + + pub inline fn hasGrapheme(self: Cell) bool { + return self.content_tag == .codepoint_grapheme; + } + + /// Returns true if the set of cells has text in it. + pub inline fn hasTextAny(cells: []const Cell) bool { + for (cells) |cell| { + if (cell.hasText()) return true; + } + + return false; + } +}; + +// Uncomment this when you want to do some math. +// test "Page size calculator" { +// const total_size = alignForward( +// usize, +// Page.layout(.{ +// .cols = 250, +// .rows = 250, +// .styles = 128, +// .grapheme_bytes = 1024, +// }).total_size, +// std.heap.page_size_min, +// ); +// +// std.log.warn("total_size={} pages={}", .{ +// total_size, +// total_size / std.heap.page_size_min, +// }); +// } +// +// test "Page std size" { +// // We want to ensure that the standard capacity is what we +// // expect it to be. Changing this is fine but should be done with care +// // so we fail a test if it changes. +// const total_size = Page.layout(std_capacity).total_size; +// try testing.expectEqual(@as(usize, 524_288), total_size); // 512 KiB +// //const pages = total_size / std.heap.page_size_min; +// } + +test "Page.layout can take a maxed capacity" { + // Our intention is for a maxed-out capacity to always fit + // within a page layout without triggering runtime safety on any + // overflow. This simplifies some of our handling downstream of the + // call (relevant to: https://github.com/ghostty-org/ghostty/issues/10258) + var cap: Capacity = undefined; + inline for (@typeInfo(Capacity).@"struct".fields) |field| { + @field(cap, field.name) = std.math.maxInt(field.type); + } + + // Note that a max capacity will exceed our max_page_size so we + // can't init a page with it, but it should layout. + _ = Page.layout(cap); +} + +test "Cell is zero by default" { + const cell = Cell.init(0); + const cell_int: u64 = @bitCast(cell); + try std.testing.expectEqual(@as(u64, 0), cell_int); + + // The zero value should be output type for semantic content. + // This is very important for our assumptions elsewhere. + try std.testing.expectEqual(Cell.SemanticContent.output, cell.semantic_content); +} + +test "Page capacity adjust cols down" { + const original = std_capacity; + const original_size = Page.layout(original).total_size; + const adjusted = try original.adjust(.{ .cols = original.cols / 2 }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = adjusted; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); +} + +test "Page capacity adjust cols down to 1" { + const original = std_capacity; + const original_size = Page.layout(original).total_size; + const adjusted = try original.adjust(.{ .cols = 1 }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = adjusted; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); +} + +test "Page capacity adjust cols up" { + const original = std_capacity; + const original_size = Page.layout(original).total_size; + const adjusted = try original.adjust(.{ .cols = original.cols * 2 }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = adjusted; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); +} + +test "Page capacity adjust cols sweep" { + var cap = std_capacity; + const original_cols = cap.cols; + const original_size = Page.layout(cap).total_size; + for (1..original_cols * 2) |c| { + cap = try cap.adjust(.{ .cols = @as(u16, @intCast(c)) }); + const adjusted_size = Page.layout(cap).total_size; + try testing.expectEqual(original_size, adjusted_size); + // If we layout a page with 1 more row and it's still the same size + // then adjust is not producing enough rows. + var bigger = cap; + bigger.rows += 1; + const bigger_size = Page.layout(bigger).total_size; + try testing.expect(bigger_size > original_size); + } +} + +test "Page capacity adjust cols too high" { + const original = std_capacity; + try testing.expectError( + error.OutOfMemory, + original.adjust(.{ .cols = std.math.maxInt(size.CellCountInt) }), + ); +} + +test "Capacity maxCols basic" { + const cap = std_capacity; + const max = cap.maxCols().?; + + // maxCols should be >= current cols (since current capacity is valid) + try testing.expect(max >= cap.cols); + + // Adjusting to maxCols should succeed with at least 1 row + const adjusted = try cap.adjust(.{ .cols = max }); + try testing.expect(adjusted.rows >= 1); + + // Adjusting to maxCols + 1 should fail + try testing.expectError( + error.OutOfMemory, + cap.adjust(.{ .cols = max + 1 }), + ); +} + +test "Capacity maxCols preserves total size" { + const cap = std_capacity; + const original_size = Page.layout(cap).total_size; + const max = cap.maxCols().?; + const adjusted = try cap.adjust(.{ .cols = max }); + const adjusted_size = Page.layout(adjusted).total_size; + try testing.expectEqual(original_size, adjusted_size); +} + +test "Capacity maxCols with 1 row exactly" { + const cap = std_capacity; + const max = cap.maxCols().?; + const adjusted = try cap.adjust(.{ .cols = max }); + try testing.expectEqual(@as(size.CellCountInt, 1), adjusted.rows); +} + +test "Page init" { + var page = try Page.init(.{ + .cols = 120, + .rows = 80, + .styles = 32, + }); + defer page.deinit(); +} + +test "Page read and write cells" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Read it again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } +} + +test "Page appendGrapheme small" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init(0x09); + + // One + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0A}, page.lookupGrapheme(rac.cell).?); + + // Two + try page.appendGrapheme(rac.row, rac.cell, 0x0B); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page.lookupGrapheme(rac.cell).?); + + // Clear it + page.clearGrapheme(rac.cell); + page.updateRowGraphemeFlag(rac.row); + try testing.expect(!rac.row.grapheme); + try testing.expect(!rac.cell.hasGrapheme()); +} + +test "Page appendGrapheme larger than chunk" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init(0x09); + + const count = grapheme_chunk_len * 10; + for (0..count) |i| { + try page.appendGrapheme(rac.row, rac.cell, @intCast(0x0A + i)); + } + + const cps = page.lookupGrapheme(rac.cell).?; + try testing.expectEqual(@as(usize, count), cps.len); + for (0..count) |i| { + try testing.expectEqual(@as(u21, @intCast(0x0A + i)), cps[i]); + } +} + +test "Page clearGrapheme not all cells" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init(0x09); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + + const rac2 = page.getRowAndCell(1, 0); + rac2.cell.* = .init(0x09); + try page.appendGrapheme(rac2.row, rac2.cell, 0x0A); + + // Clear it + page.clearGrapheme(rac.cell); + page.updateRowGraphemeFlag(rac.row); + try testing.expect(rac.row.grapheme); + try testing.expect(!rac.cell.hasGrapheme()); + try testing.expect(rac2.cell.hasGrapheme()); +} + +test "Page clone" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try page.clone(); + defer page2.deinit(); + try testing.expectEqual(page2.capacity, page.capacity); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Write again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + }; + } + + // Read it again, should be unchanged + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Read the original + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} + +test "Page clone graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Append some graphemes + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init(0x09); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + try page.appendGrapheme(rac.row, rac.cell, 0x0B); + } + + // Clone it + var page2 = try page.clone(); + defer page2.deinit(); + { + const rac = page2.getRowAndCell(0, 0); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{ 0x0A, 0x0B }, page2.lookupGrapheme(rac.cell).?); + } +} + +test "Page clone styles" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write with some styles + { + const id = try page.styles.add(page.memory, .{ .flags = .{ + .bold = true, + } }); + + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = id, + }; + page.styles.use(page.memory, id); + } + } + + // Clone it + var page2 = try page.clone(); + defer page2.deinit(); + { + const id: u16 = style: { + const rac = page2.getRowAndCell(0, 0); + break :style rac.cell.style_id; + }; + + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + try testing.expect(rac.row.styled); + try testing.expectEqual(id, rac.cell.style_id); + } + + const style = page.styles.get( + page.memory, + id, + ); + try testing.expect((Style{ .flags = .{ + .bold = true, + } }).eql(style.*)); + } +} + +test "Page cloneFrom" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Write again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + }; + } + + // Read it again, should be unchanged + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + + // Read the original + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom shrink columns" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 5, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + try testing.expectEqual(@as(size.CellCountInt, 5), page2.size.cols); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom partial" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, 5); + + // Read it again + for (0..5) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y)), rac.cell.content.codepoint); + } + for (5..page2.size.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom hyperlinks exact capacity" { + var page = try Page.init(.{ + .cols = 50, + .rows = 50, + }); + defer page.deinit(); + + // Ensure our page can accommodate the capacity. + const hyperlink_cap = page.hyperlinkCapacity(); + try testing.expect(hyperlink_cap <= page.size.cols * page.size.rows); + + // Create a hyperlink. + const hyperlink_id = try page.insertHyperlink(.{ + .id = .{ .implicit = 0 }, + .uri = "https://example.com", + }); + + // Fill the exact cap with cells. + fill: for (0..page.size.cols) |x| { + for (0..page.size.rows) |y| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 42 }, + }; + try page.setHyperlink(rac.row, rac.cell, hyperlink_id); + page.hyperlink_set.use(page.memory, hyperlink_id); + + if (page.hyperlinkCount() == hyperlink_cap) { + break :fill; + } + } + } + try testing.expectEqual(page.hyperlinkCount(), page.hyperlinkCapacity()); + + // Clone the full page + var page2 = try Page.init(page.capacity); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + + // We should have the same number of hyperlinks + try testing.expectEqual(page2.hyperlinkCount(), page.hyperlinkCount()); +} + +test "Page cloneFrom graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.cloneFrom(&page, 0, page.size.rows); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y + 1)), rac.cell.content.codepoint); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0A}, page2.lookupGrapheme(rac.cell).?); + } + + // Write again + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + page.clearGrapheme(rac.cell); + page.updateRowGraphemeFlag(rac.row); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0 }, + }; + } + + // Read it again, should be unchanged + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y + 1)), rac.cell.content.codepoint); + try testing.expect(rac.row.grapheme); + try testing.expect(rac.cell.hasGrapheme()); + try testing.expectEqualSlices(u21, &.{0x0A}, page2.lookupGrapheme(rac.cell).?); + } + + // Read the original + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, 0), rac.cell.content.codepoint); + } +} + +test "Page cloneFrom frees dst graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + for (0..page.capacity.rows) |y| { + const rac = page.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y + 1) }, + }; + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(y + 1) }, + }; + try page2.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + // Clone from page which has no graphemes. + try page2.cloneFrom(&page, 0, page.size.rows); + + // Read it again + for (0..page2.capacity.rows) |y| { + const rac = page2.getRowAndCell(1, y); + try testing.expectEqual(@as(u21, @intCast(y + 1)), rac.cell.content.codepoint); + try testing.expect(!rac.row.grapheme); + try testing.expect(!rac.cell.hasGrapheme()); + } + try testing.expectEqual(@as(usize, 0), page2.graphemeCount()); +} + +test "Page cloneRowFrom partial" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + } + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.clonePartialRowFrom( + &page, + page2.getRow(0), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 0; + for (0..page2.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0; + const rac = page2.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + } + } +} + +test "Page cloneRowFrom partial grapheme in non-copied source region" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + { + const rac = page.getRowAndCell(0, y); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + { + const rac = page.getRowAndCell(9, y); + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + } + try testing.expectEqual(@as(usize, 2), page.graphemeCount()); + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + try page2.clonePartialRowFrom( + &page, + page2.getRow(0), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 0; + for (0..page2.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0; + const rac = page2.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + try testing.expect(!rac.cell.hasGrapheme()); + } + { + const rac = page2.getRowAndCell(9, y); + try testing.expect(!rac.row.grapheme); + } + } + try testing.expectEqual(@as(usize, 0), page2.graphemeCount()); +} + +test "Page cloneRowFrom partial grapheme in non-copied dest region" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + } + try testing.expectEqual(@as(usize, 0), page.graphemeCount()); + + // Clone + var page2 = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page2.deinit(); + { + const y = 0; + for (0..page2.size.cols) |x| { + const rac = page2.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = 0xBB }, + }; + } + { + const rac = page2.getRowAndCell(0, y); + try page2.appendGrapheme(rac.row, rac.cell, 0x0A); + } + { + const rac = page2.getRowAndCell(9, y); + try page2.appendGrapheme(rac.row, rac.cell, 0x0A); + } + } + try page2.clonePartialRowFrom( + &page, + page2.getRow(0), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 0; + for (0..page2.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0xBB; + const rac = page2.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + } + { + const rac = page2.getRowAndCell(9, y); + try testing.expect(rac.row.grapheme); + } + } + try testing.expectEqual(@as(usize, 2), page2.graphemeCount()); +} + +test "Page cloneRowFrom partial hyperlink in same page copy" { + var page = try Page.init(.{ .cols = 10, .rows = 10 }); + defer page.deinit(); + + // We need to create a hyperlink. + const hyperlink_id = try page.hyperlink_set.addContext( + page.memory, + .{ .id = .{ .implicit = 0 }, .uri = .{} }, + .{ .page = &page }, + ); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + + // Hyperlink in a single cell + { + const rac = page.getRowAndCell(7, y); + try page.setHyperlink(rac.row, rac.cell, hyperlink_id); + } + } + try testing.expectEqual(@as(usize, 1), page.hyperlinkCount()); + + // Clone into the same page + try page.clonePartialRowFrom( + &page, + page.getRow(1), + page.getRow(0), + 2, + 8, + ); + + // Read it again + { + const y = 1; + for (0..page.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 8) @intCast(x + 1) else 0; + const rac = page.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + } + { + const rac = page.getRowAndCell(7, y); + try testing.expect(rac.row.hyperlink); + try testing.expect(rac.cell.hyperlink); + } + } + try testing.expectEqual(@as(usize, 2), page.hyperlinkCount()); +} + +test "Page cloneRowFrom partial hyperlink in same page omit" { + var page = try Page.init(.{ .cols = 10, .rows = 10 }); + defer page.deinit(); + + // We need to create a hyperlink. + const hyperlink_id = try page.hyperlink_set.addContext( + page.memory, + .{ .id = .{ .implicit = 0 }, .uri = .{} }, + .{ .page = &page }, + ); + + // Write + { + const y = 0; + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, y); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + + // Hyperlink in a single cell + { + const rac = page.getRowAndCell(7, y); + try page.setHyperlink(rac.row, rac.cell, hyperlink_id); + } + } + try testing.expectEqual(@as(usize, 1), page.hyperlinkCount()); + + // Clone into the same page + try page.clonePartialRowFrom( + &page, + page.getRow(1), + page.getRow(0), + 2, + 6, + ); + + // Read it again + { + const y = 1; + for (0..page.size.cols) |x| { + const expected: u21 = if (x >= 2 and x < 6) @intCast(x + 1) else 0; + const rac = page.getRowAndCell(x, y); + try testing.expectEqual(expected, rac.cell.content.codepoint); + } + { + const rac = page.getRowAndCell(7, y); + try testing.expect(!rac.row.hyperlink); + try testing.expect(!rac.cell.hyperlink); + } + } + try testing.expectEqual(@as(usize, 1), page.hyperlinkCount()); +} + +test "Page moveCells text-only" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + } + + const src = page.getRow(0); + const dst = page.getRow(1); + page.moveCells(src, 0, dst, 0, page.capacity.cols); + + // New rows should have text + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 1); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint, + ); + } + + // Old row should be blank + for (0..page.capacity.cols) |x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual( + @as(u21, 0), + rac.cell.content.codepoint, + ); + } +} + +test "Page moveCells graphemes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + const original_count = page.graphemeCount(); + + const src = page.getRow(0); + const dst = page.getRow(1); + page.moveCells(src, 0, dst, 0, page.size.cols); + try testing.expectEqual(original_count, page.graphemeCount()); + + // New rows should have text + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 1); + try testing.expectEqual( + @as(u21, @intCast(x + 1)), + rac.cell.content.codepoint, + ); + try testing.expectEqualSlices( + u21, + &.{0x0A}, + page.lookupGrapheme(rac.cell).?, + ); + } + + // Old row should be blank + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + try testing.expectEqual( + @as(u21, 0), + rac.cell.content.codepoint, + ); + } +} + +test "Page verifyIntegrity graphemes good" { + // Too slow, and not really necessary because the integrity tests are + // only run in debug builds and unit tests verify they work well enough. + if (std.valgrind.runningOnValgrind() > 0) return error.SkipZigTest; + + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + try page.verifyIntegrity(testing.allocator); +} + +test "Page verifyIntegrity grapheme row not marked" { + // Too slow, and not really necessary because the integrity tests are + // only run in debug builds and unit tests verify they work well enough. + if (std.valgrind.runningOnValgrind() > 0) return error.SkipZigTest; + + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + }; + try page.appendGrapheme(rac.row, rac.cell, 0x0A); + } + + // Make invalid by unmarking the row + page.getRow(0).grapheme = false; + + try testing.expectError( + Page.IntegrityError.UnmarkedGraphemeRow, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity styles good" { + // Too slow, and not really necessary because the integrity tests are + // only run in debug builds and unit tests verify they work well enough. + if (std.valgrind.runningOnValgrind() > 0) return error.SkipZigTest; + + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Upsert a style we'll use + const id = try page.styles.add(page.memory, .{ .flags = .{ + .bold = true, + } }); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = id, + }; + page.styles.use(page.memory, id); + } + + // The original style add would have incremented the + // ref count too, so release it to balance that out. + page.styles.release(page.memory, id); + + try page.verifyIntegrity(testing.allocator); +} + +test "Page verifyIntegrity styles ref count mismatch" { + // Too slow, and not really necessary because the integrity tests are + // only run in debug builds and unit tests verify they work well enough. + if (std.valgrind.runningOnValgrind() > 0) return error.SkipZigTest; + + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Upsert a style we'll use + const id = try page.styles.add(page.memory, .{ .flags = .{ + .bold = true, + } }); + + // Write + for (0..page.size.cols) |x| { + const rac = page.getRowAndCell(x, 0); + rac.row.styled = true; + rac.cell.* = .{ + .content_tag = .codepoint, + .content = .{ .codepoint = @intCast(x + 1) }, + .style_id = id, + }; + page.styles.use(page.memory, id); + } + + // The original style add would have incremented the + // ref count too, so release it to balance that out. + page.styles.release(page.memory, id); + + // Miss a ref + page.styles.release(page.memory, id); + + try testing.expectError( + Page.IntegrityError.MismatchedStyleRef, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity zero rows" { + // Too slow, and not really necessary because the integrity tests are + // only run in debug builds and unit tests verify they work well enough. + if (std.valgrind.runningOnValgrind() > 0) return error.SkipZigTest; + + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + page.size.rows = 0; + try testing.expectError( + Page.IntegrityError.ZeroRowCount, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page verifyIntegrity zero cols" { + // Too slow, and not really necessary because the integrity tests are + // only run in debug builds and unit tests verify they work well enough. + if (std.valgrind.runningOnValgrind() > 0) return error.SkipZigTest; + + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + page.size.cols = 0; + try testing.expectError( + Page.IntegrityError.ZeroColCount, + page.verifyIntegrity(testing.allocator), + ); +} + +test "Page exactRowCapacity empty rows" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + .hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item), + .string_bytes = 512, + }); + defer page.deinit(); + + // Empty page: all capacity fields should be 0 (except cols/rows) + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(10, cap.cols); + try testing.expectEqual(5, cap.rows); + try testing.expectEqual(0, cap.styles); + try testing.expectEqual(0, cap.grapheme_bytes); + try testing.expectEqual(0, cap.hyperlink_bytes); + try testing.expectEqual(0, cap.string_bytes); +} + +test "Page exactRowCapacity styles" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // No styles: capacity should be 0 + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(0, cap.styles); + } + + // Add one style to a cell + const style1_id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true } }); + { + const rac = page.getRowAndCell(0, 0); + rac.row.styled = true; + rac.cell.style_id = style1_id; + } + + // One unique style - capacity accounts for load factor + const cap_one_style = page.exactRowCapacity(0, 5); + { + try testing.expectEqual(StyleSet.capacityForCount(1), cap_one_style.styles); + } + + // Add same style to another cell (duplicate) - capacity unchanged + { + const rac = page.getRowAndCell(1, 0); + rac.cell.style_id = style1_id; + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(cap_one_style.styles, cap.styles); + } + + // Add a different style + const style2_id = try page.styles.add(page.memory, .{ .flags = .{ .italic = true } }); + { + const rac = page.getRowAndCell(2, 0); + rac.cell.style_id = style2_id; + } + + // Two unique styles - capacity accounts for load factor + const cap_two_styles = page.exactRowCapacity(0, 5); + { + try testing.expectEqual(StyleSet.capacityForCount(2), cap_two_styles.styles); + try testing.expect(cap_two_styles.styles > cap_one_style.styles); + } + + // Style outside the row range should not be counted + { + const rac = page.getRowAndCell(0, 7); + rac.row.styled = true; + rac.cell.style_id = try page.styles.add(page.memory, .{ .flags = .{ .underline = .single } }); + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(cap_two_styles.styles, cap.styles); + } + + // Full range includes the new style + { + const cap = page.exactRowCapacity(0, 10); + try testing.expectEqual(StyleSet.capacityForCount(3), cap.styles); + } + + // Verify clone works with exact capacity and produces same result + { + const cap = page.exactRowCapacity(0, 5); + var cloned = try Page.init(cap); + defer cloned.deinit(); + for (0..5) |y| { + const src_row = &page.rows.ptr(page.memory)[y]; + const dst_row = &cloned.rows.ptr(cloned.memory)[y]; + try cloned.cloneRowFrom(&page, dst_row, src_row); + } + const cloned_cap = cloned.exactRowCapacity(0, 5); + try testing.expectEqual(cap, cloned_cap); + } +} + +test "Page exactRowCapacity single style clone" { + // Regression test: verify a single style can be cloned with exact capacity. + // This tests that capacityForCount properly accounts for ID 0 being reserved. + var page = try Page.init(.{ + .cols = 10, + .rows = 2, + .styles = 8, + }); + defer page.deinit(); + + // Add exactly one style to row 0 + const style_id = try page.styles.add(page.memory, .{ .flags = .{ .bold = true } }); + { + const rac = page.getRowAndCell(0, 0); + rac.row.styled = true; + rac.cell.style_id = style_id; + } + + // exactRowCapacity for just row 0 should give capacity for 1 style + const cap = page.exactRowCapacity(0, 1); + try testing.expectEqual(StyleSet.capacityForCount(1), cap.styles); + + // Create a new page with exact capacity and clone + var cloned = try Page.init(cap); + defer cloned.deinit(); + + const src_row = &page.rows.ptr(page.memory)[0]; + const dst_row = &cloned.rows.ptr(cloned.memory)[0]; + + // This must not fail with StyleSetOutOfMemory + try cloned.cloneRowFrom(&page, dst_row, src_row); + + // Verify the style was cloned correctly + const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[0]; + try testing.expect(cloned_cell.style_id != stylepkg.default_id); +} + +test "Page exactRowCapacity styles max single row" { + var page = try Page.init(.{ + .cols = std.math.maxInt(size.CellCountInt), + .rows = 1, + .styles = std.math.maxInt(size.StyleCountInt), + }); + defer page.deinit(); + + // Style our first row + const row = &page.rows.ptr(page.memory)[0]; + row.styled = true; + + // Fill cells with styles until we get OOM, but limit to a reasonable count + // to avoid overflow when computing capacityForCount near maxInt + const cells = row.cells.ptr(page.memory)[0..page.size.cols]; + var count: usize = 0; + const max_count: usize = 1000; // Limit to avoid overflow in capacity calculation + for (cells, 0..) |*cell, i| { + if (count >= max_count) break; + const style_id = page.styles.add(page.memory, .{ + .fg_color = .{ .rgb = .{ + .r = @intCast(i & 0xFF), + .g = @intCast((i >> 8) & 0xFF), + .b = 0, + } }, + }) catch break; + cell.style_id = style_id; + count += 1; + } + + // Verify we added a meaningful number of styles + try testing.expect(count > 0); + + // Capacity should be at least count (adjusted for load factor) + const cap = page.exactRowCapacity(0, 1); + try testing.expectEqual(StyleSet.capacityForCount(count), cap.styles); +} + +test "Page exactRowCapacity grapheme_bytes" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // No graphemes: capacity should be 0 + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(0, cap.grapheme_bytes); + } + + // Add one grapheme (1 codepoint) to a cell - rounds up to grapheme_chunk + { + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init('a'); + try page.appendGrapheme(rac.row, rac.cell, 0x0301); // combining acute accent + } + { + const cap = page.exactRowCapacity(0, 5); + // 1 codepoint = 4 bytes, rounds up to grapheme_chunk (16) + try testing.expectEqual(grapheme_chunk, cap.grapheme_bytes); + } + + // Add another grapheme to a different cell - should sum + { + const rac = page.getRowAndCell(1, 0); + rac.cell.* = .init('e'); + try page.appendGrapheme(rac.row, rac.cell, 0x0300); // combining grave accent + } + { + const cap = page.exactRowCapacity(0, 5); + // 2 graphemes, each 1 codepoint = 2 * grapheme_chunk + try testing.expectEqual(grapheme_chunk * 2, cap.grapheme_bytes); + } + + // Add a larger grapheme (multiple codepoints) that fits in one chunk + { + const rac = page.getRowAndCell(2, 0); + rac.cell.* = .init('o'); + try page.appendGrapheme(rac.row, rac.cell, 0x0301); + try page.appendGrapheme(rac.row, rac.cell, 0x0302); + try page.appendGrapheme(rac.row, rac.cell, 0x0303); + } + { + const cap = page.exactRowCapacity(0, 5); + // First two cells: 2 * grapheme_chunk + // Third cell: 3 codepoints = 12 bytes, rounds up to grapheme_chunk + try testing.expectEqual(grapheme_chunk * 3, cap.grapheme_bytes); + } + + // Grapheme outside the row range should not be counted + { + const rac = page.getRowAndCell(0, 7); + rac.cell.* = .init('x'); + try page.appendGrapheme(rac.row, rac.cell, 0x0304); + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(grapheme_chunk * 3, cap.grapheme_bytes); + } + + // Full range includes the new grapheme + { + const cap = page.exactRowCapacity(0, 10); + try testing.expectEqual(grapheme_chunk * 4, cap.grapheme_bytes); + } + + // Verify clone works with exact capacity and produces same result + { + const cap = page.exactRowCapacity(0, 5); + var cloned = try Page.init(cap); + defer cloned.deinit(); + for (0..5) |y| { + const src_row = &page.rows.ptr(page.memory)[y]; + const dst_row = &cloned.rows.ptr(cloned.memory)[y]; + try cloned.cloneRowFrom(&page, dst_row, src_row); + } + const cloned_cap = cloned.exactRowCapacity(0, 5); + try testing.expectEqual(cap, cloned_cap); + } +} + +test "Page exactRowCapacity grapheme_bytes larger than chunk" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + }); + defer page.deinit(); + + // Add a grapheme larger than one chunk (grapheme_chunk_len = 4 codepoints) + const rac = page.getRowAndCell(0, 0); + rac.cell.* = .init('a'); + + // Add 6 codepoints - requires 2 chunks (6 * 4 = 24 bytes, rounds up to 32) + for (0..6) |i| { + try page.appendGrapheme(rac.row, rac.cell, @intCast(0x0300 + i)); + } + + const cap = page.exactRowCapacity(0, 1); + // 6 codepoints = 24 bytes, alignForward(24, 16) = 32 + try testing.expectEqual(32, cap.grapheme_bytes); + + // Verify clone works with exact capacity and produces same result + var cloned = try Page.init(cap); + defer cloned.deinit(); + const src_row = &page.rows.ptr(page.memory)[0]; + const dst_row = &cloned.rows.ptr(cloned.memory)[0]; + try cloned.cloneRowFrom(&page, dst_row, src_row); + const cloned_cap = cloned.exactRowCapacity(0, 1); + try testing.expectEqual(cap, cloned_cap); +} + +test "Page exactRowCapacity hyperlinks" { + var page = try Page.init(.{ + .cols = 10, + .rows = 10, + .styles = 8, + .hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item), + .string_bytes = 512, + }); + defer page.deinit(); + + // No hyperlinks: capacity should be 0 + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(0, cap.hyperlink_bytes); + try testing.expectEqual(0, cap.string_bytes); + } + + // Add one hyperlink with implicit ID + const uri1 = "https://example.com"; + const id1 = blk: { + const rac = page.getRowAndCell(0, 0); + + // Create and add hyperlink entry + const id = try page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = uri1, + }); + try page.setHyperlink(rac.row, rac.cell, id); + break :blk id; + }; + // 1 hyperlink - capacity accounts for load factor + const cap_one_link = page.exactRowCapacity(0, 5); + { + try testing.expectEqual(hyperlink.Set.capacityForCount(1) * @sizeOf(hyperlink.Set.Item), cap_one_link.hyperlink_bytes); + // URI "https://example.com" = 19 bytes, rounds up to string_chunk (32) + try testing.expectEqual(string_chunk, cap_one_link.string_bytes); + } + + // Add same hyperlink to another cell (duplicate ID) - capacity unchanged + { + const rac = page.getRowAndCell(1, 0); + + // Use the same hyperlink ID for another cell + page.hyperlink_set.use(page.memory, id1); + try page.setHyperlink(rac.row, rac.cell, id1); + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(cap_one_link.hyperlink_bytes, cap.hyperlink_bytes); + try testing.expectEqual(cap_one_link.string_bytes, cap.string_bytes); + } + + // Add a different hyperlink with explicit ID + const uri2 = "https://other.example.org/path"; + const explicit_id = "my-link-id"; + { + const rac = page.getRowAndCell(2, 0); + + const id = try page.insertHyperlink(.{ + .id = .{ .explicit = explicit_id }, + .uri = uri2, + }); + try page.setHyperlink(rac.row, rac.cell, id); + } + // 2 hyperlinks - capacity accounts for load factor + const cap_two_links = page.exactRowCapacity(0, 5); + { + try testing.expectEqual(hyperlink.Set.capacityForCount(2) * @sizeOf(hyperlink.Set.Item), cap_two_links.hyperlink_bytes); + // First URI: 19 bytes -> 32, Second URI: 30 bytes -> 32, Explicit ID: 10 bytes -> 32 + try testing.expectEqual(string_chunk * 3, cap_two_links.string_bytes); + } + + // Hyperlink outside the row range should not be counted + { + const rac = page.getRowAndCell(0, 7); // row 7 is outside range [0, 5) + + const id = try page.insertHyperlink(.{ + .id = .{ .implicit = 99 }, + .uri = "https://outside.example.com", + }); + try page.setHyperlink(rac.row, rac.cell, id); + } + { + const cap = page.exactRowCapacity(0, 5); + try testing.expectEqual(cap_two_links.hyperlink_bytes, cap.hyperlink_bytes); + try testing.expectEqual(cap_two_links.string_bytes, cap.string_bytes); + } + + // Full range includes the new hyperlink + { + const cap = page.exactRowCapacity(0, 10); + try testing.expectEqual(hyperlink.Set.capacityForCount(3) * @sizeOf(hyperlink.Set.Item), cap.hyperlink_bytes); + // Third URI: 27 bytes -> 32 + try testing.expectEqual(string_chunk * 4, cap.string_bytes); + } + + // Verify clone works with exact capacity and produces same result + { + const cap = page.exactRowCapacity(0, 5); + var cloned = try Page.init(cap); + defer cloned.deinit(); + for (0..5) |y| { + const src_row = &page.rows.ptr(page.memory)[y]; + const dst_row = &cloned.rows.ptr(cloned.memory)[y]; + try cloned.cloneRowFrom(&page, dst_row, src_row); + } + const cloned_cap = cloned.exactRowCapacity(0, 5); + try testing.expectEqual(cap, cloned_cap); + } +} + +test "Page exactRowCapacity single hyperlink clone" { + // Regression test: verify a single hyperlink can be cloned with exact capacity. + // This tests that capacityForCount properly accounts for ID 0 being reserved. + var page = try Page.init(.{ + .cols = 10, + .rows = 2, + .styles = 8, + .hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item), + .string_bytes = 512, + }); + defer page.deinit(); + + // Add exactly one hyperlink to row 0 + const uri = "https://example.com"; + const id = blk: { + const rac = page.getRowAndCell(0, 0); + const link_id = try page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = uri, + }); + try page.setHyperlink(rac.row, rac.cell, link_id); + break :blk link_id; + }; + _ = id; + + // exactRowCapacity for just row 0 should give capacity for 1 hyperlink + const cap = page.exactRowCapacity(0, 1); + try testing.expectEqual(hyperlink.Set.capacityForCount(1) * @sizeOf(hyperlink.Set.Item), cap.hyperlink_bytes); + + // Create a new page with exact capacity and clone + var cloned = try Page.init(cap); + defer cloned.deinit(); + + const src_row = &page.rows.ptr(page.memory)[0]; + const dst_row = &cloned.rows.ptr(cloned.memory)[0]; + + // This must not fail with HyperlinkSetOutOfMemory + try cloned.cloneRowFrom(&page, dst_row, src_row); + + // Verify the hyperlink was cloned correctly + const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[0]; + try testing.expect(cloned_cell.hyperlink); +} + +test "Page exactRowCapacity hyperlink map capacity for many cells" { + // A single hyperlink spanning many cells requires hyperlink_map capacity + // based on cell count, not unique hyperlink count. + const cols = 50; + var page = try Page.init(.{ + .cols = cols, + .rows = 2, + .styles = 8, + .hyperlink_bytes = 32 * @sizeOf(hyperlink.Set.Item), + .string_bytes = 512, + }); + defer page.deinit(); + + // Add one hyperlink spanning all 50 columns in row 0 + const uri = "https://example.com"; + const id = blk: { + const rac = page.getRowAndCell(0, 0); + const link_id = try page.insertHyperlink(.{ + .id = .{ .implicit = 1 }, + .uri = uri, + }); + try page.setHyperlink(rac.row, rac.cell, link_id); + break :blk link_id; + }; + + // Apply same hyperlink to remaining cells in row 0 + for (1..cols) |x| { + const rac = page.getRowAndCell(@intCast(x), 0); + page.hyperlink_set.use(page.memory, id); + try page.setHyperlink(rac.row, rac.cell, id); + } + + // exactRowCapacity must account for 50 hyperlink cells, not just 1 unique hyperlink + const cap = page.exactRowCapacity(0, 1); + + // The hyperlink_bytes must be large enough that layout() computes sufficient + // hyperlink_map capacity. With hyperlink_cell_multiplier=16, we need at least + // ceil(50/16) = 4 hyperlink entries worth of bytes for the map. + const min_for_map = std.math.divCeil(usize, cols, hyperlink_cell_multiplier) catch 0; + const min_hyperlink_bytes = min_for_map * @sizeOf(hyperlink.Set.Item); + try testing.expect(cap.hyperlink_bytes >= min_hyperlink_bytes); + + // Create a new page with exact capacity and clone - must not fail + var cloned = try Page.init(cap); + defer cloned.deinit(); + + const src_row = &page.rows.ptr(page.memory)[0]; + const dst_row = &cloned.rows.ptr(cloned.memory)[0]; + + // This must not fail with HyperlinkMapOutOfMemory + try cloned.cloneRowFrom(&page, dst_row, src_row); + + // Verify all hyperlinks were cloned correctly + for (0..cols) |x| { + const cloned_cell = &cloned.rows.ptr(cloned.memory)[0].cells.ptr(cloned.memory)[x]; + try testing.expect(cloned_cell.hyperlink); + } +} |
