From c62c1598413fdf6b72a4b64aafa25e44b71a1915 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jul 2026 08:45:01 -0700 Subject: [PATCH 01/18] terminal: add standalone LZ4 block codec Scrollback compression needs a codec that can be used from libghostty-vt without pulling in libc, and we need to measure it before integrating it with terminal page ownership. This adds an allocation-free raw LZ4 block codec in scalar Zig. Callers provide the input, output, and fixed-size scratch table. The decoder uses an exact-size output contract so page metadata mismatches fail cleanly. Compatibility vectors, boundary cases, random round trips, and fuzz coverage exercise the block format. Also adds a page-compression benchmark that operates on reusable raw page corpora. Compression and decompression have separate modes with setup outside the timed region, plus a ratio report and no-op baseline. Nothing uses compression in the terminal yet; this is the isolated codec and measurement groundwork. --- src/benchmark/PageCompression.zig | 401 +++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/benchmark/main.zig | 1 + src/terminal/compress/lz4.zig | 576 ++++++++++++++++++++++++++++++ src/terminal/main.zig | 1 + 5 files changed, 981 insertions(+) create mode 100644 src/benchmark/PageCompression.zig create mode 100644 src/terminal/compress/lz4.zig diff --git a/src/benchmark/PageCompression.zig b/src/benchmark/PageCompression.zig new file mode 100644 index 000000000..61bbd220d --- /dev/null +++ b/src/benchmark/PageCompression.zig @@ -0,0 +1,401 @@ +//! Benchmarks raw LZ4 compression and decompression on page-sized byte +//! buffers. +//! +//! This benchmark is intentionally independent of terminal page ownership and +//! lifecycle. It treats its input as opaque bytes and calls only the standalone +//! LZ4 block codec. In particular, it does not compress pages owned by a live +//! terminal and is not evidence that compression is enabled in production. +//! +//! ## Input +//! +//! `--data` names a pre-generated raw byte corpus. The corpus is divided into +//! `--page-size` byte chunks, with a final short chunk retained when the file +//! size is not an exact multiple. The default page size is 400 KiB, matching a +//! standard terminal page in ReleaseFast builds on the current target. +//! +//! A raw dump of actual page backing memory is the most representative input: +//! it includes cells, rows, styles, graphemes, hyperlinks, allocator metadata, +//! and unused capacity exactly as the codec would see them. Keep such corpora +//! outside the repository and reuse the same file when comparing branches. +//! Arbitrary files are accepted too, but their compression ratios should not be +//! interpreted as terminal scrollback ratios. +//! +//! ## Modes +//! +//! * `noop` walks the input chunks without invoking the codec. This measures the +//! benchmark loop's minimum overhead. +//! * `compress` compresses every input chunk into a reusable output buffer. +//! * `decompress` prepares compressed blocks during setup, then decompresses +//! every block into a reusable output buffer. +//! * `report` compresses each chunk once and prints raw and encoded sizes. It is +//! for inspecting ratios, not timing comparisons. +//! +//! Dataset loading, output allocation, and preparation of blocks for +//! decompression happen in `setup` and are outside `Benchmark`'s timed region. +//! The `compress` and `decompress` steps perform no allocation. +//! `hyperfine` still measures full process lifetime, so use `--loops` to +//! amortize setup and teardown when comparing small corpora. +//! +//! ## Examples +//! +//! Build benchmarks in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Inspect the compression ratio of a page corpus: +//! +//! ghostty-bench +page-compression --mode=report --data=/tmp/pages.raw +//! +//! Compare compression and decompression with `hyperfine`: +//! +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +page-compression --mode=compress --loops=100 --data=/tmp/pages.raw' \ +//! 'ghostty-bench +page-compression --mode=decompress --loops=100 --data=/tmp/pages.raw' +const PageCompression = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const lz4 = @import("../terminal/compress/lz4.zig"); + +const log = std.log.scoped(.@"page-compression-bench"); + +/// Prevent a malformed or accidentally enormous corpus from consuming +/// unbounded memory during benchmark setup. +const max_data_size = 64 * 1024 * 1024; + +alloc: Allocator, +opts: Options, + +/// Complete contents of the input corpus. Individual pages are slices into +/// this allocation, so it remains alive until teardown. +data: []u8 = &.{}, + +/// Compressed blocks prepared during setup for `decompress` mode. +encoded: std.ArrayList(Encoded) = .empty, + +/// Reused by compression and report modes. Its length is the compression +/// bound of the largest input chunk. +compression_output: []u8 = &.{}, + +/// Reused by decompression mode. Its length is at least one input chunk. +decompression_output: []u8 = &.{}, + +/// Fixed 16 KiB scratch table required by the compressor. +table: lz4.HashTable = undefined, + +pub const Options = struct { + /// Set by the shared CLI parser for string option ownership. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the operation performed inside the timed benchmark step. + mode: Mode = .compress, + + /// Repeat the complete corpus this many times per benchmark step. Increase + /// this when the corpus is too small for stable `hyperfine` measurements. + loops: u32 = 1, + + /// Number of bytes treated as one independent LZ4 block. Real page dumps + /// should use the exact backing-memory size of the pages being measured. + @"page-size": usize = 400 * 1024, + + /// Pre-generated input corpus. `-` reads stdin, although a regular file is + /// recommended so identical bytes can be reused across benchmark runs. + /// When unset, all modes are no-ops. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Walk page boundaries and establish the benchmark loop overhead. + noop, + + /// Compress each raw page into a reusable compression-bound buffer. + compress, + + /// Decompress blocks prepared before the timed region. + decompress, + + /// Print per-page and aggregate encoded sizes. Not a timing benchmark. + report, +}; + +const Encoded = struct { + /// Exact compressed block bytes. + bytes: []u8, + + /// Exact output length expected by the raw block decoder. + raw_len: usize, +}; + +/// Allocate benchmark state. Input data is intentionally loaded later by +/// `setup` so construction is cheap and follows the other benchmarks. +pub fn create( + alloc: Allocator, + opts: Options, +) !*PageCompression { + const ptr = try alloc.create(PageCompression); + ptr.* = .{ + .alloc = alloc, + .opts = opts, + }; + return ptr; +} + +/// Release allocations retained across benchmark steps. +pub fn destroy(self: *PageCompression, alloc: Allocator) void { + self.clearPreparedData(); + alloc.destroy(self); +} + +/// Select one operation for the benchmark harness to time. +pub fn benchmark(self: *PageCompression) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .compress => stepCompress, + .decompress => stepDecompress, + .report => stepReport, + }, + .setupFn = setup, + .teardownFn = teardown, + }); +} + +/// Load and partition the input corpus. For decompression mode this also +/// creates the encoded blocks, keeping compression outside the timed region. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + assert(self.data.len == 0); + assert(self.encoded.items.len == 0); + + self.setupData() catch |err| { + log.warn("failed to prepare page compression benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; +} + +fn setupData(self: *PageCompression) !void { + if (self.opts.loops == 0) return error.InvalidLoops; + if (self.opts.@"page-size" == 0) return error.InvalidPageSize; + + const data_file = try options.dataFile(self.opts.data) orelse return; + defer data_file.close(); + + self.data = try data_file.readToEndAlloc(self.alloc, max_data_size); + errdefer { + self.alloc.free(self.data); + self.data = &.{}; + } + if (self.data.len == 0) return; + if (self.opts.mode == .noop) return; + + const largest_page = @min(self.opts.@"page-size", self.data.len); + self.compression_output = try self.alloc.alloc( + u8, + try lz4.compressBound(largest_page), + ); + errdefer { + self.alloc.free(self.compression_output); + self.compression_output = &.{}; + } + + if (self.opts.mode == .decompress) try self.prepareEncoded(); +} + +/// Precompress every input page and verify one decode before benchmarking. +/// This catches corpus or codec problems before the timer starts. +fn prepareEncoded(self: *PageCompression) !void { + self.decompression_output = try self.alloc.alloc( + u8, + @min(self.opts.@"page-size", self.data.len), + ); + errdefer { + self.alloc.free(self.decompression_output); + self.decompression_output = &.{}; + } + + var it = self.pages(); + while (it.next()) |page| { + const encoded_len = try lz4.compress( + page, + self.compression_output, + &self.table, + ); + const encoded = try self.alloc.dupe( + u8, + self.compression_output[0..encoded_len], + ); + self.encoded.append(self.alloc, .{ + .bytes = encoded, + .raw_len = page.len, + }) catch |err| { + self.alloc.free(encoded); + return err; + }; + } + + var page_it = self.pages(); + for (self.encoded.items) |block| { + const page = page_it.next().?; + const output = self.decompression_output[0..block.raw_len]; + _ = try lz4.decompress(block.bytes, output); + if (!std.mem.eql(u8, page, output)) return error.RoundTripMismatch; + } +} + +/// Release everything created by setup. This is shared by teardown and +/// destroy so errors and direct unit-test use remain leak-free. +fn clearPreparedData(self: *PageCompression) void { + for (self.encoded.items) |block| self.alloc.free(block.bytes); + self.encoded.deinit(self.alloc); + self.encoded = .empty; + + if (self.compression_output.len > 0) + self.alloc.free(self.compression_output); + self.compression_output = &.{}; + + if (self.decompression_output.len > 0) + self.alloc.free(self.decompression_output); + self.decompression_output = &.{}; + + if (self.data.len > 0) self.alloc.free(self.data); + self.data = &.{}; +} + +fn teardown(ptr: *anyopaque) void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + self.clearPreparedData(); +} + +/// Baseline mode: traverse exactly the same page boundaries as compression +/// without invoking the codec. +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + var it = self.pages(); + while (it.next()) |page| std.mem.doNotOptimizeAway(page); + } +} + +/// Compress all pages into one reusable output buffer. Only the returned +/// encoded length is consumed because retaining output pages would measure +/// allocation and ownership rather than codec throughput. +fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + var it = self.pages(); + while (it.next()) |page| { + const encoded_len = lz4.compress( + page, + self.compression_output, + &self.table, + ) catch |err| { + log.warn("page compression failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(encoded_len); + } + } +} + +/// Decompress blocks prepared by setup. The output allocation is reused so +/// this measures only decoding and the required memory writes. +fn stepDecompress(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + for (0..self.opts.loops) |_| { + for (self.encoded.items) |block| { + const output = self.decompression_output[0..block.raw_len]; + _ = lz4.decompress(block.bytes, output) catch |err| { + log.warn("page decompression failed err={}", .{err}); + return error.BenchmarkFailed; + }; + std.mem.doNotOptimizeAway(output); + } + } +} + +/// Print size information for evaluating compression ratio. This shares the +/// input and codec paths with compression mode but deliberately makes no +/// timing claims. +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + if (self.data.len == 0) return; + + var page_index: usize = 0; + var raw_total: usize = 0; + var encoded_total: usize = 0; + var it = self.pages(); + while (it.next()) |page| : (page_index += 1) { + const encoded_len = lz4.compress( + page, + self.compression_output, + &self.table, + ) catch |err| { + log.warn("page compression report failed err={}", .{err}); + return error.BenchmarkFailed; + }; + raw_total += page.len; + encoded_total += encoded_len; + std.debug.print( + "page-compression page={d} raw={d} encoded={d} ratio={d:.2}%\n", + .{ page_index, page.len, encoded_len, percentage(encoded_len, page.len) }, + ); + } + + std.debug.print( + "page-compression total pages={d} raw={d} encoded={d} ratio={d:.2}% " ++ + "workspace={d} output_bound={d}\n", + .{ + page_index, + raw_total, + encoded_total, + percentage(encoded_total, raw_total), + @sizeOf(lz4.HashTable), + self.compression_output.len, + }, + ); +} + +/// Iterate fixed-size page chunks without allocating an index table. +fn pages(self: *const PageCompression) PageIterator { + return .{ + .data = self.data, + .page_size = self.opts.@"page-size", + }; +} + +const PageIterator = struct { + data: []const u8, + page_size: usize, + offset: usize = 0, + + fn next(self: *PageIterator) ?[]const u8 { + if (self.offset >= self.data.len) return null; + const len = @min(self.page_size, self.data.len - self.offset); + const end = self.offset + len; + defer self.offset = end; + return self.data[self.offset..end]; + } +}; + +fn percentage(part: usize, whole: usize) f64 { + if (whole == 0) return 0; + return @as(f64, @floatFromInt(part)) * 100 / + @as(f64, @floatFromInt(whole)); +} + +test PageCompression { + const testing = std.testing; + const impl: *PageCompression = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 13f070774..0d2c2398e 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -8,6 +8,7 @@ const cli = @import("../cli.zig"); pub const Action = enum { @"codepoint-width", @"grapheme-break", + @"page-compression", @"screen-clone", @"terminal-parser", @"terminal-stream", @@ -25,6 +26,7 @@ pub const Action = enum { pub fn Struct(comptime action: Action) type { return switch (action) { .@"screen-clone" => @import("ScreenClone.zig"), + .@"page-compression" => @import("PageCompression.zig"), .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 5673044f2..12d58cc95 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -7,6 +7,7 @@ pub const GraphemeBreak = @import("GraphemeBreak.zig"); pub const ScreenClone = @import("ScreenClone.zig"); pub const TerminalParser = @import("TerminalParser.zig"); pub const IsSymbol = @import("IsSymbol.zig"); +pub const PageCompression = @import("PageCompression.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/compress/lz4.zig b/src/terminal/compress/lz4.zig new file mode 100644 index 000000000..219e76f02 --- /dev/null +++ b/src/terminal/compress/lz4.zig @@ -0,0 +1,576 @@ +//! An allocation-free implementation of the raw LZ4 block format. +//! +//! LZ4 has two relevant layers: the block format describes the compressed +//! bytes, while the frame format adds headers, sizes, checksums, and support +//! for a stream of blocks. Terminal pages already have their own ownership +//! and metadata, so this implements only blocks. In particular, an encoded +//! block does not contain its decompressed size. The caller must store that +//! separately and provide an exactly sized buffer when decoding. +//! +//! A block is a series of sequences. Each non-final sequence has this shape: +//! +//! token | literal length extensions | literals | offset | match length extensions +//! +//! The token's high nibble contains the literal length and its low nibble +//! contains the match length minus four. A nibble value of 15 means that the +//! length continues in extension bytes at the corresponding point in the +//! sequence. Each extension byte adds to the length; a value of 255 means +//! another byte follows. The literal bytes are copied directly. The two-byte +//! little-endian offset then points backwards in the already decompressed +//! output to the match bytes. +//! +//! The last sequence is special: it contains literals only and ends directly +//! after them. The reference format also requires the last five input bytes to +//! be literals and the final match to begin at least twelve bytes before the +//! end of the input. The compressor observes these restrictions so its output +//! can be consumed by optimized LZ4 decoders which copy in larger units. +//! +//! Compression uses the standard fast LZ4 strategy: hash each four-byte input +//! sequence, remember only its most recent position, and test that one position +//! as a match candidate. This favors compression speed and a small, fixed +//! workspace over finding the best possible match. The implementation is +//! scalar Zig and allocates nothing; all input, output, and scratch memory is +//! supplied by the caller. +//! +//! Format reference: +//! https://github.com/lz4/lz4/blob/dev/doc/lz4_Block_format.md +const std = @import("std"); + +/// Maximum input accepted by the reference LZ4 block API. Keeping the same +/// limit means `compressBound` fits in the integer sizes used by LZ4 callers +/// and gives us the same compatibility boundary as other implementations. +pub const max_input_size: usize = 0x7E000000; + +/// Every LZ4 match represents at least four bytes. The token stores the number +/// of bytes beyond this minimum rather than the full match length. +const min_match = 4; + +/// Number of bytes at the end of a conforming block which must remain literals. +const last_literals = 5; + +/// A match may not begin in the final 12 bytes. This leaves enough room for the +/// minimum match and the required five trailing literals. +const match_find_limit = 12; + +/// We retain one input position for each 12-bit hash. LZ4 refers to this as +/// memory usage 14 because the 4096 entries are four bytes each (16 KiB). +const hash_log = 12; + +/// Multiplicative hash used by the reference LZ4 fast compressor. The high +/// `hash_log` bits provide the table index. +const hash_multiplier: u32 = 2_654_435_761; + +/// Scratch memory used while compressing one block. Each entry stores an input +/// position plus one; zero therefore means that the hash has not been seen. +/// The table is reset by every call to `compress` and can be reused afterwards. +pub const HashTable = [1 << hash_log]u32; + +/// Errors which can occur while encoding a block. +pub const CompressError = error{ + /// The input exceeds the maximum size supported by the block compressor. + InputTooLarge, + + /// The provided output buffer cannot hold the encoded block. + OutputTooSmall, +}; + +/// Errors which can occur while decoding a block. +pub const DecompressError = error{ + /// The encoded block ended in the middle of a sequence. + TruncatedInput, + + /// A match offset was zero or pointed before the produced output. + InvalidOffset, + + /// A sequence would write beyond the provided output buffer. + OutputTooSmall, + + /// The block ended before filling the exact-size output buffer. + OutputSizeMismatch, +}; + +/// Return the maximum number of bytes needed to encode `input_len` bytes. +/// +/// Incompressible input is represented as one literal run. Every 255 literal +/// bytes can require one extension byte. The additional 16-byte margin covers +/// the token and the format's fixed overhead. Callers can allocate this amount +/// once and reuse it for any block no larger than `input_len`. +pub fn compressBound(input_len: usize) CompressError!usize { + if (input_len > max_input_size) return error.InputTooLarge; + return input_len + input_len / 255 + 16; +} + +/// Compress `input` into a raw LZ4 block in `output`. +/// +/// Returns the initialized length of `output`. The input and output buffers +/// must not overlap. `table` is scratch space and does not need to be +/// initialized by the caller; it is reset before use. +pub fn compress( + input: []const u8, + output: []u8, + table: *HashTable, +) CompressError!usize { + if (input.len > max_input_size) return error.InputTooLarge; + + // Zero is reserved as "no previous position". Actual positions are stored + // plus one so that a match at input offset zero remains representable. + @memset(table, 0); + + // `ip` is the current input position, `anchor` is the first literal not yet + // emitted, and `op` is the next output position. A successful match emits + // input[anchor..ip] as literals followed by the match, then moves both input + // positions to the end of that match. + var op: usize = 0; + var anchor: usize = 0; + var ip: usize = 0; + + // LZ4's format leaves the final five input bytes as literals and starts + // the final match at least twelve bytes before the end. This is not + // required by our safe decoder, but makes blocks compatible with fast + // decoders that rely on the standard format restrictions. + const search_end = if (input.len >= match_find_limit) + input.len - match_find_limit + else + 0; + + while (input.len >= match_find_limit and ip <= search_end) { + // Hash the next four bytes and replace the table entry immediately. + // Hash collisions are expected, so equality is checked below before + // accepting the saved position as a match. + const sequence = readU32(input, ip); + const hash = hashSequence(sequence); + const previous_plus_one = table[hash]; + table[hash] = @intCast(ip + 1); + + if (previous_plus_one == 0) { + ip += 1; + continue; + } + + var match_pos: usize = previous_plus_one - 1; + + // Offsets are encoded as u16 and zero is invalid. Since match_pos is + // always earlier than ip here, checking the distance also rules out a + // value that cannot be represented in the block. + if (ip - match_pos > std.math.maxInt(u16) or + readU32(input, match_pos) != sequence) + { + ip += 1; + continue; + } + + // Pull the match backwards into the current literal run. This is a + // cheap improvement that is particularly helpful around aligned cell + // records without requiring a hash chain. + while (ip > anchor and match_pos > 0 and + input[ip - 1] == input[match_pos - 1]) + { + ip -= 1; + match_pos -= 1; + } + + // We already compared the first four bytes. Continue byte-by-byte up + // to the point where the required last five literals begin. + var match_end = ip + min_match; + var candidate_end = match_pos + min_match; + const match_end_limit = input.len - last_literals; + while (match_end < match_end_limit and + input[match_end] == input[candidate_end]) + { + match_end += 1; + candidate_end += 1; + } + + try emitSequence( + output, + &op, + input[anchor..ip], + @intCast(ip - match_pos), + match_end - ip, + ); + + // The main loop jumps over the matched bytes rather than hashing every + // position within them. Seed one position near the end so an adjacent + // repeated record can still refer back into this match. The next loop + // iteration will then seed `match_end` normally. + if (match_end >= 2 and match_end - 2 + min_match <= input.len) { + const seed = match_end - 2; + table[hashSequence(readU32(input, seed))] = @intCast(seed + 1); + } + + ip = match_end; + anchor = ip; + } + + // Whatever remains after the last match is the terminal literal-only + // sequence. For short inputs this is also the only sequence in the block. + try emitLastLiterals(output, &op, input[anchor..]); + return op; +} + +/// Decompress a raw LZ4 block into an exact-size output buffer. +/// +/// Returns `output.len` on success. Both consuming all input and filling all +/// output are required. Raw LZ4 blocks do not carry their decoded size, so this +/// exact-size contract validates the size metadata maintained by the caller. +/// The input and output buffers must not overlap. +pub fn decompress(input: []const u8, output: []u8) DecompressError!usize { + // `ip` and `op` always identify the next unread input byte and the next + // unwritten output byte respectively. + var ip: usize = 0; + var op: usize = 0; + + while (true) { + // A normal block ends after the literal bytes of its final sequence. + // This also accepts the empty block produced by our compressor, which + // consists of a zero token and no literals. + if (ip == input.len) { + if (op != output.len) return error.OutputSizeMismatch; + return op; + } + + const token = input[ip]; + ip += 1; + + // The high nibble and any extension bytes describe the literal run. + // Bounds are checked before slicing so malformed blocks never cause a + // partial read or write. + const literal_len = try decodeLength(input, &ip, token >> 4); + if (literal_len > input.len - ip) return error.TruncatedInput; + if (literal_len > output.len - op) return error.OutputTooSmall; + + @memcpy(output[op..][0..literal_len], input[ip..][0..literal_len]); + ip += literal_len; + op += literal_len; + + // Ending immediately after the literals marks the final sequence. Any + // non-final sequence must continue with an offset and match length. + if (ip == input.len) { + if (op != output.len) return error.OutputSizeMismatch; + return op; + } + + if (input.len - ip < 2) return error.TruncatedInput; + const offset = std.mem.readInt(u16, input[ip..][0..2], .little); + ip += 2; + if (offset == 0 or offset > op) return error.InvalidOffset; + + // The token stores the match length minus the four-byte minimum. As + // with literals, a low nibble of 15 is extended by following bytes. + const encoded_match_len = try decodeLength(input, &ip, token & 0x0F); + const match_len = std.math.add( + usize, + encoded_match_len, + min_match, + ) catch return error.OutputTooSmall; + if (match_len > output.len - op) return error.OutputTooSmall; + + // Match copies are allowed to overlap. For an offset smaller than the + // match length, bytes written early in this loop become the source for + // later bytes. This is how a short pattern such as one space can expand + // into an arbitrarily long run. + const match_pos = op - offset; + for (0..match_len) |i| output[op + i] = output[match_pos + i]; + op += match_len; + } +} + +/// Emit one non-final sequence. +/// +/// A sequence starts with a token, followed by optional literal length bytes, +/// the literals themselves, the two-byte offset, and optional match length +/// bytes. This function computes the complete size first so `OutputTooSmall` +/// is reported without partially writing a sequence. +fn emitSequence( + output: []u8, + op: *usize, + literals: []const u8, + offset: u16, + match_len: usize, +) CompressError!void { + std.debug.assert(match_len >= min_match); + std.debug.assert(offset > 0); + + const encoded_match_len = match_len - min_match; + + // One byte is always needed for the token and two for the offset. Each + // length may additionally need extension bytes after its token nibble. + const required = 1 + + encodedLengthBytes(literals.len) + literals.len + + 2 + encodedLengthBytes(encoded_match_len); + if (required > output.len - op.*) return error.OutputTooSmall; + + const token_pos = op.*; + op.* += 1; + + // Lengths below 15 fit directly in their nibble. Larger values put 15 in + // the nibble and encode the remainder immediately after the token. + output[token_pos] = (@as(u8, @intCast(@min(literals.len, 15))) << 4) | + @as(u8, @intCast(@min(encoded_match_len, 15))); + + // Literal length extensions precede the literals they describe. + if (literals.len >= 15) writeLength(output, op, literals.len - 15); + @memcpy(output[op.*..][0..literals.len], literals); + op.* += literals.len; + + // Match length extensions follow the offset because this is where the + // decoder expects them in an LZ4 sequence. + std.mem.writeInt(u16, output[op.*..][0..2], offset, .little); + op.* += 2; + if (encoded_match_len >= 15) + writeLength(output, op, encoded_match_len - 15); +} + +/// Emit the literal-only sequence which terminates every block. +/// +/// There is no offset or match length after these bytes. As with +/// `emitSequence`, capacity is checked before modifying the output. +fn emitLastLiterals( + output: []u8, + op: *usize, + literals: []const u8, +) CompressError!void { + const required = 1 + encodedLengthBytes(literals.len) + literals.len; + if (required > output.len - op.*) return error.OutputTooSmall; + + output[op.*] = @as(u8, @intCast(@min(literals.len, 15))) << 4; + op.* += 1; + if (literals.len >= 15) writeLength(output, op, literals.len - 15); + @memcpy(output[op.*..][0..literals.len], literals); + op.* += literals.len; +} + +/// Return the number of extension bytes needed when a length is represented by +/// a token nibble plus zero or more bytes. An extended length always ends with +/// a byte below 255, so an exact multiple of 255 requires a final zero byte. +fn encodedLengthBytes(encoded_len: usize) usize { + if (encoded_len < 15) return 0; + return (encoded_len - 15) / 255 + 1; +} + +/// Write the portion of a length which did not fit in the token nibble. +/// +/// Each 255 byte means "add 255 and continue". The final byte is always less +/// than 255 and may be zero. +fn writeLength(output: []u8, op: *usize, length_: usize) void { + var length = length_; + while (length >= 255) { + output[op.*] = 255; + op.* += 1; + length -= 255; + } + output[op.*] = @intCast(length); + op.* += 1; +} + +/// Decode a length from its token nibble and any following extension bytes. +/// `ip` is advanced past every consumed extension byte. +fn decodeLength( + input: []const u8, + ip: *usize, + nibble: u8, +) DecompressError!usize { + var length: usize = nibble; + if (nibble != 15) return length; + + while (true) { + if (ip.* >= input.len) return error.TruncatedInput; + const value = input[ip.*]; + ip.* += 1; + length = std.math.add(usize, length, value) catch + return error.TruncatedInput; + if (value != 255) return length; + } +} + +/// Read the four-byte sequence used for match finding. Callers only use this +/// where at least four input bytes remain. +inline fn readU32(input: []const u8, pos: usize) u32 { + return std.mem.readInt(u32, input[pos..][0..4], .little); +} + +/// Map a four-byte input sequence to its scratch-table slot. +inline fn hashSequence(sequence: u32) usize { + return @intCast((sequence *% hash_multiplier) >> (32 - hash_log)); +} + +/// Shared round-trip assertion used by the corpus-style tests below. +fn expectRoundTrip(input: []const u8) !void { + const testing = std.testing; + const bound = try compressBound(input.len); + const encoded = try testing.allocator.alloc(u8, bound); + defer testing.allocator.free(encoded); + const decoded = try testing.allocator.alloc(u8, input.len); + defer testing.allocator.free(decoded); + + var table: HashTable = undefined; + const encoded_len = try compress(input, encoded, &table); + try testing.expectEqual(input.len, try decompress( + encoded[0..encoded_len], + decoded, + )); + try testing.expectEqualSlices(u8, input, decoded); +} + +test "compressBound" { + const testing = std.testing; + try testing.expectEqual(@as(usize, 16), try compressBound(0)); + try testing.expectEqual(@as(usize, 272), try compressBound(255)); + try testing.expectError(error.InputTooLarge, compressBound(max_input_size + 1)); +} + +test "literal-only compatibility vectors" { + const testing = std.testing; + + var empty: [0]u8 = .{}; + try testing.expectEqual(@as(usize, 0), try decompress(&.{0}, &empty)); + + var hello: [5]u8 = undefined; + try testing.expectEqual(@as(usize, 5), try decompress( + &.{ 0x50, 'h', 'e', 'l', 'l', 'o' }, + &hello, + )); + try testing.expectEqualStrings("hello", &hello); + + var fifteen: [15]u8 = undefined; + var encoded: [17]u8 = undefined; + encoded[0] = 0xF0; + encoded[1] = 0; + @memset(encoded[2..], 'x'); + _ = try decompress(&encoded, &fifteen); + try testing.expect(std.mem.allEqual(u8, &fifteen, 'x')); +} + +test "overlapping match compatibility vector" { + const testing = std.testing; + // One literal 'a', followed by a four-byte match at distance one. + var output: [5]u8 = undefined; + try testing.expectEqual(@as(usize, 5), try decompress( + &.{ 0x10, 'a', 0x01, 0x00 }, + &output, + )); + try testing.expectEqualStrings("aaaaa", &output); +} + +test "extended overlapping match compatibility vector" { + const testing = std.testing; + // One literal followed by a 274-byte match. The match extension is + // encoded as 255 + 0 after the low token nibble's initial 15 bytes. + var output: [275]u8 = undefined; + try testing.expectEqual(@as(usize, output.len), try decompress( + &.{ 0x1F, 'a', 0x01, 0x00, 0xFF, 0x00 }, + &output, + )); + try testing.expect(std.mem.allEqual(u8, &output, 'a')); +} + +test "maximum match offset compatibility vector" { + const testing = std.testing; + const literal_len = std.math.maxInt(u16); + const extension_len = (literal_len - 15) / 255 + 1; + const encoded = try testing.allocator.alloc( + u8, + 1 + extension_len + literal_len + 2, + ); + defer testing.allocator.free(encoded); + const output = try testing.allocator.alloc(u8, literal_len + min_match); + defer testing.allocator.free(output); + + var op: usize = 0; + encoded[op] = 0xF0; + op += 1; + writeLength(encoded, &op, literal_len - 15); + for (encoded[op..][0..literal_len], 0..) |*byte, i| + byte.* = @truncate(i); + op += literal_len; + std.mem.writeInt(u16, encoded[op..][0..2], std.math.maxInt(u16), .little); + op += 2; + + try testing.expectEqual(encoded.len, op); + try testing.expectEqual(output.len, try decompress(encoded, output)); + try testing.expectEqualSlices(u8, encoded[1 + extension_len ..][0..4], output[literal_len..]); +} + +test "round trips boundary-sized inputs" { + const testing = std.testing; + const lengths = [_]usize{ + 0, 1, 3, 4, 5, 12, 15, 16, 19, + 20, 254, 255, 256, 269, 270, 271, 510, 511, + 512, 65_535, 65_536, 65_537, + }; + + for (lengths) |len| { + const buf = try testing.allocator.alloc(u8, len); + defer testing.allocator.free(buf); + for (buf, 0..) |*byte, i| byte.* = @truncate(i *% 31); + try expectRoundTrip(buf); + } +} + +test "round trips compressible page-sized inputs" { + const testing = std.testing; + const page_len = 400 * 1024; + + const zeros = try testing.allocator.alloc(u8, page_len); + defer testing.allocator.free(zeros); + @memset(zeros, 0); + try expectRoundTrip(zeros); + + const structured = try testing.allocator.alloc(u8, page_len); + defer testing.allocator.free(structured); + @memset(structured, 0); + for (0..page_len / 8) |i| { + structured[i * 8] = @truncate(' ' + i % 95); + structured[i * 8 + 4] = @truncate((i / 80) % 16); + } + try expectRoundTrip(structured); +} + +test "round trips deterministic random inputs" { + const testing = std.testing; + var prng = std.Random.DefaultPrng.init(0x4C5A_3401); + const random = prng.random(); + + for (0..256) |_| { + const len = random.uintLessThan(usize, 32 * 1024); + const input = try testing.allocator.alloc(u8, len); + defer testing.allocator.free(input); + random.bytes(input); + try expectRoundTrip(input); + } +} + +test "compress reports short output" { + const testing = std.testing; + const input = "a terminal page needs enough output space"; + var table: HashTable = undefined; + var output: [4]u8 = undefined; + try testing.expectError( + error.OutputTooSmall, + compress(input, &output, &table), + ); +} + +test "decompress rejects malformed blocks" { + const testing = std.testing; + var output: [32]u8 = undefined; + + try testing.expectError(error.TruncatedInput, decompress(&.{0xF0}, &output)); + try testing.expectError(error.TruncatedInput, decompress(&.{ 0x10, 'a', 1 }, output[0..5])); + try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 0, 0 }, output[0..5])); + try testing.expectError(error.InvalidOffset, decompress(&.{ 0x10, 'a', 2, 0 }, output[0..5])); + try testing.expectError(error.OutputTooSmall, decompress( + &.{ 0x10, 'a', 1, 0 }, + output[0..4], + )); + try testing.expectError(error.OutputSizeMismatch, decompress(&.{0}, output[0..1])); +} + +test "fuzz decompressor safety" { + return std.testing.fuzz({}, fuzzDecompress, .{}); +} + +fn fuzzDecompress(_: void, input: []const u8) !void { + var output: [4096]u8 = undefined; + _ = decompress(input, &output) catch {}; +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 53491a009..3e12c6165 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -83,6 +83,7 @@ test { // Internals _ = @import("bitmap_allocator.zig"); + _ = @import("compress/lz4.zig"); _ = @import("hash_map.zig"); _ = @import("ref_counted_set.zig"); _ = @import("size.zig"); From ebc3ffd222f65765261cd30cfc2273643133ef89 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jul 2026 10:24:47 -0700 Subject: [PATCH 02/18] terminal: add compressed page representation The standalone LZ4 codec had no representation for terminal page ownership or metadata, so PageList integration would otherwise need to reconstruct every Page field independently. Add compress.Page, which embeds the complete terminal Page while retaining its original virtual mapping and owns only an exact-sized encoded block. Compression is kept only when the encoded state is strictly smaller, and scratch output is capped at that profitability boundary so a future PageList caller can borrow a standard pool item. Extend the page-compression benchmark with a store mode that measures the encoded copy, allocation, bounded retention, and eviction path. Nothing uses compression from PageList yet; this remains isolated groundwork. --- src/benchmark/PageCompression.zig | 125 ++++++++++- src/terminal/compress.zig | 15 ++ src/terminal/compress/Page.zig | 337 ++++++++++++++++++++++++++++++ src/terminal/main.zig | 2 +- 4 files changed, 474 insertions(+), 5 deletions(-) create mode 100644 src/terminal/compress.zig create mode 100644 src/terminal/compress/Page.zig diff --git a/src/benchmark/PageCompression.zig b/src/benchmark/PageCompression.zig index 61bbd220d..04dc7ef32 100644 --- a/src/benchmark/PageCompression.zig +++ b/src/benchmark/PageCompression.zig @@ -25,6 +25,9 @@ //! * `noop` walks the input chunks without invoking the codec. This measures the //! benchmark loop's minimum overhead. //! * `compress` compresses every input chunk into a reusable output buffer. +//! * `store` additionally copies each useful result into an exact-sized +//! allocation. It retains the most recent `--retained-pages` blocks so that +//! allocation, eviction, and allocator reuse resemble bounded scrollback. //! * `decompress` prepares compressed blocks during setup, then decompresses //! every block into a reusable output buffer. //! * `report` compresses each chunk once and prints raw and encoded sizes. It is @@ -32,7 +35,9 @@ //! //! Dataset loading, output allocation, and preparation of blocks for //! decompression happen in `setup` and are outside `Benchmark`'s timed region. -//! The `compress` and `decompress` steps perform no allocation. +//! The `compress` and `decompress` steps perform no allocation. `store` +//! deliberately performs encoded-block allocations and frees inside the timed +//! step; only its reusable workspace and ring metadata are prepared in setup. //! `hyperfine` still measures full process lifetime, so use `--loops` to //! amortize setup and teardown when comparing small corpora. //! @@ -46,11 +51,11 @@ //! //! ghostty-bench +page-compression --mode=report --data=/tmp/pages.raw //! -//! Compare compression and decompression with `hyperfine`: +//! Measure the cost of exact encoded storage relative to the codec alone: //! //! hyperfine --warmup 3 \ //! 'ghostty-bench +page-compression --mode=compress --loops=100 --data=/tmp/pages.raw' \ -//! 'ghostty-bench +page-compression --mode=decompress --loops=100 --data=/tmp/pages.raw' +//! 'ghostty-bench +page-compression --mode=store --loops=100 --data=/tmp/pages.raw' const PageCompression = @This(); const std = @import("std"); @@ -58,7 +63,9 @@ const assert = std.debug.assert; const Allocator = std.mem.Allocator; const Benchmark = @import("Benchmark.zig"); const options = @import("options.zig"); -const lz4 = @import("../terminal/compress/lz4.zig"); +const compress = @import("../terminal/compress.zig"); +const CompressedPage = compress.Page; +const lz4 = compress.lz4; const log = std.log.scoped(.@"page-compression-bench"); @@ -76,6 +83,11 @@ data: []u8 = &.{}, /// Compressed blocks prepared during setup for `decompress` mode. encoded: std.ArrayList(Encoded) = .empty, +/// Exact encoded allocations retained by `store` mode. Null entries have not +/// been reached yet; once full, `stored_next` identifies the oldest block. +stored: []?[]u8 = &.{}, +stored_next: usize = 0, + /// Reused by compression and report modes. Its length is the compression /// bound of the largest input chunk. compression_output: []u8 = &.{}, @@ -101,6 +113,11 @@ pub const Options = struct { /// should use the exact backing-memory size of the pages being measured. @"page-size": usize = 400 * 1024, + /// Number of exact encoded allocations retained by `store` mode before + /// the oldest allocation is freed and replaced. The default approximates + /// a 10 MB scrollback composed of standard 400 KiB terminal pages. + @"retained-pages": usize = 25, + /// Pre-generated input corpus. `-` reads stdin, although a regular file is /// recommended so identical bytes can be reused across benchmark runs. /// When unset, all modes are no-ops. @@ -119,6 +136,9 @@ pub const Mode = enum { /// Compress each raw page into a reusable compression-bound buffer. compress, + /// Compress and retain exact-sized encoded blocks in a bounded ring. + store, + /// Decompress blocks prepared before the timed region. decompress, @@ -160,6 +180,7 @@ pub fn benchmark(self: *PageCompression) Benchmark { .stepFn = switch (self.opts.mode) { .noop => stepNoop, .compress => stepCompress, + .store => stepStore, .decompress => stepDecompress, .report => stepReport, }, @@ -184,6 +205,8 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { fn setupData(self: *PageCompression) !void { if (self.opts.loops == 0) return error.InvalidLoops; if (self.opts.@"page-size" == 0) return error.InvalidPageSize; + if (self.opts.mode == .store and self.opts.@"retained-pages" == 0) + return error.InvalidRetainedPages; const data_file = try options.dataFile(self.opts.data) orelse return; defer data_file.close(); @@ -206,9 +229,18 @@ fn setupData(self: *PageCompression) !void { self.compression_output = &.{}; } + if (self.opts.mode == .store) try self.prepareStored(); if (self.opts.mode == .decompress) try self.prepareEncoded(); } +/// Allocate only the ring metadata for `store` mode. The encoded blocks which +/// populate it are intentionally allocated by the timed benchmark step. +fn prepareStored(self: *PageCompression) !void { + self.stored = try self.alloc.alloc(?[]u8, self.opts.@"retained-pages"); + @memset(self.stored, null); + self.stored_next = 0; +} + /// Precompress every input page and verify one decode before benchmarking. /// This catches corpus or codec problems before the timer starts. fn prepareEncoded(self: *PageCompression) !void { @@ -257,6 +289,11 @@ fn clearPreparedData(self: *PageCompression) void { self.encoded.deinit(self.alloc); self.encoded = .empty; + for (self.stored) |block| if (block) |bytes| self.alloc.free(bytes); + if (self.stored.len > 0) self.alloc.free(self.stored); + self.stored = &.{}; + self.stored_next = 0; + if (self.compression_output.len > 0) self.alloc.free(self.compression_output); self.compression_output = &.{}; @@ -305,6 +342,56 @@ fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { } } +/// Compress pages and retain their exact encoded allocations in a bounded +/// ring. This models the part of `compress.Page.init` which differs from the +/// allocation-free codec benchmark: copying the result, allocating its +/// persistent storage, and freeing an old block when scrollback is full. +fn stepStore(ptr: *anyopaque) Benchmark.Error!void { + const self: *PageCompression = @ptrCast(@alignCast(ptr)); + if (self.data.len == 0) return; + assert(self.stored.len > 0); + + for (0..self.opts.loops) |_| { + var it = self.pages(); + while (it.next()) |page| { + const output_len = CompressedPage.requiredScratch(page.len) catch |err| { + log.warn("failed to size stored page output err={}", .{err}); + return error.BenchmarkFailed; + }; + if (output_len == 0) continue; + + const encoded_len = lz4.compress( + page, + self.compression_output[0..output_len], + &self.table, + ) catch |err| switch (err) { + // This is the same profitability outcome as + // compress.Page.init: a block which exceeds the useful output + // limit remains resident and consumes no encoded allocation. + error.OutputTooSmall => continue, + error.InputTooLarge => { + log.warn("stored page compression failed err={}", .{err}); + return error.BenchmarkFailed; + }, + }; + + const encoded = self.alloc.dupe( + u8, + self.compression_output[0..encoded_len], + ) catch |err| { + log.warn("stored page allocation failed err={}", .{err}); + return error.BenchmarkFailed; + }; + + const slot = &self.stored[self.stored_next]; + if (slot.*) |previous| self.alloc.free(previous); + slot.* = encoded; + self.stored_next = (self.stored_next + 1) % self.stored.len; + std.mem.doNotOptimizeAway(encoded); + } + } +} + /// Decompress blocks prepared by setup. The output allocation is reused so /// this measures only decoding and the required memory writes. fn stepDecompress(ptr: *anyopaque) Benchmark.Error!void { @@ -399,3 +486,33 @@ test PageCompression { const bench = impl.benchmark(); _ = try bench.run(.once); } + +test "PageCompression store retains exact encoded allocations" { + const testing = std.testing; + const page_size = 1024; + const impl: *PageCompression = try .create(testing.allocator, .{ + .mode = .store, + .@"page-size" = page_size, + .@"retained-pages" = 2, + }); + defer impl.destroy(testing.allocator); + + impl.data = try testing.allocator.alloc(u8, 3 * page_size); + @memset(impl.data, 0); + impl.compression_output = try testing.allocator.alloc( + u8, + try lz4.compressBound(page_size), + ); + try impl.prepareStored(); + + try stepStore(impl); + try testing.expectEqual(@as(usize, 1), impl.stored_next); + for (impl.stored) |block| { + const encoded = block.?; + try testing.expect(encoded.len < page_size); + + var decoded: [page_size]u8 = undefined; + _ = try lz4.decompress(encoded, &decoded); + try testing.expect(std.mem.allEqual(u8, &decoded, 0)); + } +} diff --git a/src/terminal/compress.zig b/src/terminal/compress.zig new file mode 100644 index 000000000..3b670ed75 --- /dev/null +++ b/src/terminal/compress.zig @@ -0,0 +1,15 @@ +//! Compression primitives used by the terminal. +//! +//! This namespace contains only the representation and codecs. Policy about +//! which pages to compress, when to decommit their resident memory, and when +//! to restore them belongs to `PageList`. + +/// The raw LZ4 block codec used for terminal page memory. +pub const lz4 = @import("compress/lz4.zig"); + +/// A compressed terminal page which retains its original virtual mapping. +pub const Page = @import("compress/Page.zig"); + +test { + @import("std").testing.refAllDecls(@This()); +} diff --git a/src/terminal/compress/Page.zig b/src/terminal/compress/Page.zig new file mode 100644 index 000000000..6736b950a --- /dev/null +++ b/src/terminal/compress/Page.zig @@ -0,0 +1,337 @@ +//! A compressed terminal page which retains its resident virtual mapping. +//! +//! Terminal pages have two kinds of state: the large `Page.memory` allocation +//! and a comparatively small `Page` value containing offsets, dimensions, +//! dirty state, and allocator metadata. Not all of the latter state lives in +//! the backing memory, so preserving only the memory bytes is insufficient. +//! We preserve the complete `Page` value instead. This follows the same model +//! as `Page.cloneBuf`: page internals use offsets, so a shallow page copy remains +//! valid when its memory contents are restored at the same address. +//! +//! The resident memory is deliberately not freed by this type. A future +//! `PageList` integration will keep the virtual range allocated while asking +//! the operating system to discard its physical pages. Keeping the range has +//! two useful properties: the embedded page never contains a dangling pointer, +//! and restoring the page does not require a fallible allocation. +//! +//! The intended state transition is: +//! +//! 1. Create this value while the source page is resident. +//! 2. Ask the OS to decommit the source page's memory. +//! 3. Replace the PageList node's resident state with this value. +//! 4. To restore, recommit the retained range and call `restore`. +//! 5. After committing the resident state, call `deinit` to free the encoded +//! bytes. +//! +//! If decommit is unavailable or fails, the caller should deinitialize the +//! compressed candidate and leave the source page resident. This type does not +//! perform any virtual-memory operations itself. +//! +//! The planned native implementation uses `MADV_DONTNEED` on Linux and pairs +//! `MADV_FREE_REUSABLE` with `MADV_FREE_REUSE` on Darwin. Targets without a +//! reliable retained-mapping decommit operation should leave page compression +//! disabled rather than free and later reallocate the resident memory. +const Page = @This(); + +const std = @import("std"); +const assert = std.debug.assert; +const Allocator = std.mem.Allocator; +const TerminalPage = @import("../page.zig").Page; +const lz4 = @import("lz4.zig"); + +/// Complete page metadata together with the retained resident mapping. +/// +/// The bytes in `page.memory` may have been discarded by the OS while this +/// value is in compressed state. They must not be read until `restore` has +/// successfully decoded the page. +page: TerminalPage, + +/// Exact raw LZ4 block for `page.memory`. +encoded: []u8, + +/// Return the largest scratch buffer that can produce a useful compressed +/// representation for `raw_len` bytes. +/// +/// This is deliberately smaller than the general LZ4 compression bound. The +/// compressed representation includes an additional slice compared to a +/// resident terminal page, so an encoded block which fills more than this +/// buffer cannot reduce resident memory. Limiting the output here lets a +/// future PageList caller borrow a standard page-pool item as scratch instead +/// of retaining a larger, compression-bound allocation. +/// +/// Callers are expected to reuse the scratch memory when practical. The +/// scratch buffer and hash table are never retained by this type. +pub fn requiredScratch(raw_len: usize) lz4.CompressError!usize { + // Validate the codec's input limit before doing arithmetic based on the + // raw size. The bound itself is intentionally not returned; see above. + _ = try lz4.compressBound(raw_len); + + const representation_overhead = @sizeOf(Page) - @sizeOf(TerminalPage); + if (raw_len <= representation_overhead) return 0; + + // The savings comparison is strict, so reserve one fewer byte than the + // break-even encoded size. + return raw_len - representation_overhead - 1; +} + +/// Create a compressed representation of `source`. +/// +/// `source` is not modified and continues to own its backing allocation. The +/// scratch buffer must be at least `requiredScratch(source.memory.len)` bytes. +/// It must not overlap the source memory. On success, the returned value aliases +/// the source's resident mapping and owns only its exact-sized `encoded` +/// allocation. +/// +/// Returns null if retaining the compressed representation would not use less +/// memory than the resident representation. An `OutputTooSmall` result from +/// the codec also means the encoding crossed that break-even point and is +/// therefore returned as null. This comparison includes the in-memory size of +/// both representation structs but does not include allocator metadata or the +/// retained virtual address range. +pub fn init( + alloc: Allocator, + source: *const TerminalPage, + scratch: []u8, + table: *lz4.HashTable, +) (Allocator.Error || lz4.CompressError)!?Page { + const required = try requiredScratch(source.memory.len); + if (scratch.len < required) return error.OutputTooSmall; + if (required == 0) return null; + + const encoded_len = lz4.compress( + source.memory, + scratch[0..required], + table, + ) catch |err| switch (err) { + // The scratch limit is the largest representation worth retaining. + // Running out of room therefore means compression cannot save memory, + // rather than that the caller failed to provide the documented size. + error.OutputTooSmall => return null, + error.InputTooLarge => return err, + }; + + // requiredScratch already accounts for the representation structs. The + // retained virtual range contributes no resident bytes after PageList + // decommits it. + assert(@sizeOf(Page) + encoded_len < + @sizeOf(TerminalPage) + source.memory.len); + + return .{ + .page = source.*, + .encoded = try alloc.dupe(u8, scratch[0..encoded_len]), + }; +} + +/// Restore the embedded terminal page into its retained resident mapping. +/// +/// The caller must recommit `page.memory` before calling this on platforms +/// which require an explicit recommit operation. The compressed value remains +/// valid whether decoding succeeds or fails, allowing the caller to retry or +/// discard it. On success the returned page aliases the same resident mapping; +/// it does not own a new allocation. +pub fn restore(self: *const Page) lz4.DecompressError!TerminalPage { + const result = self.page; + _ = try lz4.decompress(self.encoded, result.memory); + return result; +} + +/// Free the encoded block. +/// +/// This intentionally does not free `page.memory`. The PageList node which +/// supplied the source page continues to own that pool or heap allocation. +pub fn deinit(self: *Page, alloc: Allocator) void { + alloc.free(self.encoded); + self.* = undefined; +} + +test "compressed Page retained mapping round trip" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ + .cols = 12, + .rows = 9, + .styles = 8, + .grapheme_bytes = 128, + .string_bytes = 128, + }); + defer resident.deinit(); + resident.size = .{ .cols = 10, .rows = 7 }; + resident.dirty = true; + + // Put state in both the backing memory and the Page value. In particular, + // the style and hyperlink sets retain live counters outside Page.memory. + const rac = resident.getRowAndCell(2, 3); + rac.cell.* = .init('A'); + try resident.appendGrapheme(rac.row, rac.cell, 0x0301); + + const style_id = try resident.styles.add(resident.memory, .{ .flags = .{ + .bold = true, + } }); + rac.cell.style_id = style_id; + rac.row.styled = true; + + const hyperlink_id = try resident.insertHyperlink(.{ + .id = .{ .explicit = "compressed-page" }, + .uri = "https://ghostty.org/docs", + }); + try resident.setHyperlink(rac.row, rac.cell, hyperlink_id); + try resident.verifyIntegrity(testing.allocator); + + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + const memory_ptr = resident.memory.ptr; + const memory_len = resident.memory.len; + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var compressed = (try Page.init( + testing.allocator, + &resident, + scratch, + &table, + )).?; + defer compressed.deinit(testing.allocator); + + try testing.expectEqual(memory_ptr, compressed.page.memory.ptr); + try testing.expectEqual(memory_len, compressed.page.memory.len); + try testing.expect(compressed.encoded.len < resident.memory.len); + + // Virtual-memory operations belong to PageList, so clearing the contents + // models a successful decommit: none of the resident bytes remain. + @memset(resident.memory, 0); + + const restored = try compressed.restore(); + try testing.expectEqual(memory_ptr, restored.memory.ptr); + try testing.expectEqual(memory_len, restored.memory.len); + try testing.expectEqualSlices(u8, expected, restored.memory); + try testing.expectEqual(resident.size, restored.size); + try testing.expect(restored.dirty); + try restored.verifyIntegrity(testing.allocator); + + const restored_rac = restored.getRowAndCell(2, 3); + try testing.expectEqualSlices( + u21, + &.{0x0301}, + restored.lookupGrapheme(restored_rac.cell).?, + ); + try testing.expect(restored.styles.get( + restored.memory, + restored_rac.cell.style_id, + ).flags.bold); + + const restored_hyperlink_id = restored.lookupHyperlink(restored_rac.cell).?; + const restored_hyperlink = restored.hyperlink_set.get( + restored.memory, + restored_hyperlink_id, + ); + try testing.expectEqualStrings( + "https://ghostty.org/docs", + restored_hyperlink.uri.slice(restored.memory), + ); +} + +test "compressed Page requires the maximum useful scratch" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ .cols = 4, .rows = 4 }); + defer resident.deinit(); + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + + const required = try requiredScratch(resident.memory.len); + try testing.expectEqual( + resident.memory.len - (@sizeOf(Page) - @sizeOf(TerminalPage)) - 1, + required, + ); + try testing.expect(required < try lz4.compressBound(resident.memory.len)); + + const scratch = try testing.allocator.alloc(u8, required - 1); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + + try testing.expectError(error.OutputTooSmall, Page.init( + testing.allocator, + &resident, + scratch, + &table, + )); + try testing.expectEqualSlices(u8, expected, resident.memory); +} + +test "compressed Page rejects a representation without savings" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ + .cols = 4, + .rows = 4, + .styles = 0, + .grapheme_bytes = 0, + .string_bytes = 0, + .hyperlink_bytes = 0, + }); + defer resident.deinit(); + + var prng = std.Random.DefaultPrng.init(0x4C5A_3402); + prng.random().bytes(resident.memory); + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var failing = testing.FailingAllocator.init(testing.allocator, .{ + .fail_index = 0, + }); + + try testing.expect((try Page.init( + failing.allocator(), + &resident, + scratch, + &table, + )) == null); + try testing.expect(!failing.has_induced_failure); +} + +test "compressed Page can retry after malformed encoded data" { + const testing = std.testing; + + var resident = try TerminalPage.init(.{ .cols = 8, .rows = 8 }); + defer resident.deinit(); + const expected = try testing.allocator.dupe(u8, resident.memory); + defer testing.allocator.free(expected); + + const scratch = try testing.allocator.alloc( + u8, + try requiredScratch(resident.memory.len), + ); + defer testing.allocator.free(scratch); + var table: lz4.HashTable = undefined; + var compressed = (try Page.init( + testing.allocator, + &resident, + scratch, + &table, + )).?; + defer compressed.deinit(testing.allocator); + + const full_encoded = compressed.encoded; + const first_byte = full_encoded[0]; + defer { + compressed.encoded = full_encoded; + compressed.encoded[0] = first_byte; + } + compressed.encoded = full_encoded[0..1]; + compressed.encoded[0] = 0xF0; + try testing.expectError(error.TruncatedInput, compressed.restore()); + + compressed.encoded = full_encoded; + compressed.encoded[0] = first_byte; + @memset(resident.memory, 0); + const restored = try compressed.restore(); + try testing.expectEqualSlices(u8, expected, restored.memory); +} diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 3e12c6165..12a43083a 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -83,7 +83,7 @@ test { // Internals _ = @import("bitmap_allocator.zig"); - _ = @import("compress/lz4.zig"); + _ = @import("compress.zig"); _ = @import("hash_map.zig"); _ = @import("ref_counted_set.zig"); _ = @import("size.zig"); From f6fd4cb0878a04a96b67fc46be674f19f5883a13 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jul 2026 10:44:02 -0700 Subject: [PATCH 03/18] terminal: generalize page memory reclamation PageList's virtual-memory helpers were tied to pool items even though the underlying decommit and recommit operations also apply to retained page mappings. Move the helpers into terminal/mem.zig and express their different failure contracts as generic modes. Zero mode preserves the existing pool invariant and fallbacks, while strict mode only succeeds when the operating system accepts reclamation and avoids touching memory that will be restored. PageList now uses the shared zero mode for its page pool. The strict path is tested in isolation and remains unused, so this does not enable page compression yet. --- src/terminal/PageList.zig | 129 +--------------------------- src/terminal/main.zig | 1 + src/terminal/mem.zig | 171 ++++++++++++++++++++++++++++++++++++++ 3 files changed, 176 insertions(+), 125 deletions(-) create mode 100644 src/terminal/mem.zig diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 338aad4c0..001df75a6 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -14,6 +14,7 @@ const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinked const color = @import("color.zig"); const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); +const terminal_mem = @import("mem.zig"); const point = @import("point.zig"); const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); @@ -481,7 +482,7 @@ fn initPages( const page_buf = if (pooled) buf: { try tw.check(.page_buf_std); const buf = try pool.pages.create(); - recommitPoolItem(buf); + terminal_mem.recommit(buf); break :buf buf; } else buf: { try tw.check(.page_buf_non_std); @@ -3487,7 +3488,7 @@ inline fn createPageExt( // dispenses. Otherwise, we use the heap allocator to allocate. const page_buf = if (pooled) buf: { const buf = try pool.pages.create(); - recommitPoolItem(buf); + terminal_mem.recommit(buf); break :buf buf; } else try page_alloc.alignedAlloc( u8, @@ -3565,7 +3566,7 @@ fn destroyNodeExt( // supported) so it can be reused. const item: *align(std.heap.page_size_min) [std_size]u8 = @ptrCast(@alignCast(page.memory.ptr)); - decommitPoolItem(item, page.memory.len); + _ = terminal_mem.decommit(.zero, item, page.memory.len); pool.pages.destroy(item); }, @@ -3578,100 +3579,6 @@ fn destroyNodeExt( pool.nodes.destroy(node); } -/// Zero a pool item buffer that is about to be returned to the pool -/// free list and, where supported, tell the OS it can reclaim the -/// backing memory while the buffer sits unused. Without the decommit, -/// a pool that shrinks (scrollback clears, pruning churn, resets) -/// retains its high-water RSS forever, since MemoryPool free lists -/// never return memory to the OS. -/// -/// dirty_len is the length of the page that lived in this item, which -/// may be smaller than the item; bytes beyond it are already zero. -/// -/// The invariant required for page reuse (see createPageExt): after -/// this call the entire item reads back as zeroes. The pool writes its -/// free list node into the first pointer-size bytes afterwards, which -/// is safe because page initialization always overwrites them (see the -/// comptime assert in Page). -fn decommitPoolItem( - item: *align(std.heap.page_size_min) [std_size]u8, - dirty_len: usize, -) void { - // In test builds the buffer comes from std.testing.allocator, not - // the OS page allocator, so madvise is neither safe nor meaningful. - if (comptime builtin.is_test) { - @memset(item[0..dirty_len], 0); - return; - } - - // MADV_DONTNEED on a private anonymous mapping immediately - // reclaims the pages and guarantees they read back zero-filled - // on the next access, so we can skip the memset entirely. - // - // We use MADV_DONTNEED rather than MADV_FREE deliberately: - // MADV_FREE doesn't reduce RSS until memory pressure (invisible - // to users watching memory usage) and doesn't guarantee zeroes - // on the next read. MADV_DONTNEED's synchronous TLB-shootdown - // cost only matters at allocator-level call frequency; page - // destroys here are rare (clears, reflow, capacity changes), and - // the hottest reuse path (grow's prune-reuse) doesn't go through - // this function at all. - if (comptime builtin.os.tag == .linux) { - if (std.posix.madvise( - item, - std_size, - std.posix.MADV.DONTNEED, - )) |_| return else |err| { - log.warn("madvise(DONTNEED) failed err={}", .{err}); - // Fall through to the memset below. - } - } - - // On Darwin we zero in place and then mark the memory as - // reusable, which removes it from the process footprint until - // it is touched again. Contents of reusable memory are either - // preserved (our zeroes) or reclaimed and zero-filled on the - // next access, so the invariant holds either way. The reuse - // side calls MADV_FREE_REUSE to fix up footprint accounting - // (see recommitPoolItem). - if (comptime builtin.os.tag.isDarwin()) { - @memset(item[0..dirty_len], 0); - std.posix.madvise( - item, - std_size, - std.posix.MADV.FREE_REUSABLE, - ) catch { - // Best-effort: plain MADV_FREE reclaims under memory - // pressure only and needs no reuse pairing. - std.posix.madvise( - item, - std_size, - std.posix.MADV.FREE, - ) catch {}; - }; - return; - } - - @memset(item[0..dirty_len], 0); -} - -/// The counterpart to decommitPoolItem for buffers handed back out by -/// the pool. Only Darwin requires this: MADV_FREE_REUSE re-accounts -/// previously reusable memory to the process footprint. Without it the -/// memory still works (touching reusable pages revives them) but -/// footprint reporting can undercount. Harmless on buffers that were -/// never marked reusable (fresh or preheated items). -fn recommitPoolItem(item: *align(std.heap.page_size_min) [std_size]u8) void { - if (comptime builtin.is_test) return; - if (comptime builtin.os.tag.isDarwin()) { - std.posix.madvise( - item, - std_size, - std.posix.MADV.FREE_REUSE, - ) catch {}; - } -} - /// Fast-path function to erase exactly 1 row. Erasing means that the row /// is completely REMOVED, not just cleared. All rows following the removed /// row will be shifted up by 1 to fill the empty space. @@ -14444,34 +14351,6 @@ test "PageList compact oversized page" { } } -test "PageList decommitPoolItem zeroes the dirty region" { - const testing = std.testing; - const alloc = testing.allocator; - - // Note: in test builds decommitPoolItem always takes the memset - // path; the madvise-based paths rely on kernel zero-fill semantics - // that unit tests cannot exercise (see decommitPoolItem). - const buf = try alloc.alignedAlloc( - u8, - .fromByteUnits(std.heap.page_size_min), - std_size, - ); - defer alloc.free(buf); - const item: *align(std.heap.page_size_min) [std_size]u8 = @ptrCast(buf.ptr); - - // Fully dirty item. - @memset(item, 0xAA); - decommitPoolItem(item, std_size); - try testing.expect(std.mem.allEqual(u8, item, 0)); - - // Partially dirty item: an item that hosted a page smaller than - // the item is only dirty up to the page length; the rest is zero - // by invariant and must remain zero. - @memset(item[0..1024], 0xAA); - decommitPoolItem(item, 1024); - try testing.expect(std.mem.allEqual(u8, item, 0)); -} - test "PageList destroyed pool page reuse is zeroed" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 12a43083a..0a2e7434f 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -85,6 +85,7 @@ test { _ = @import("bitmap_allocator.zig"); _ = @import("compress.zig"); _ = @import("hash_map.zig"); + _ = @import("mem.zig"); _ = @import("ref_counted_set.zig"); _ = @import("size.zig"); } diff --git a/src/terminal/mem.zig b/src/terminal/mem.zig new file mode 100644 index 000000000..940985766 --- /dev/null +++ b/src/terminal/mem.zig @@ -0,0 +1,171 @@ +//! Virtual-memory operations shared by terminal page owners. +//! +//! Terminal pages use page-aligned, page-multiple mappings. This module can +//! discard the physical pages behind one of those mappings without releasing +//! its virtual address range, then prepare the same range for reuse. It does +//! not allocate memory or decide which terminal pages should be discarded. +const std = @import("std"); +const builtin = @import("builtin"); +const assert = @import("../quirks.zig").inlineAssert; + +const log = std.log.scoped(.terminal_mem); + +/// What guarantee decommit must provide when the OS cannot discard a mapping. +pub const DecommitMode = enum { + /// The dirty prefix must read as zero after this call, even when physical + /// reclamation is unavailable. Bytes after the prefix must already be zero. + zero, + + /// Physical-memory reclamation is required. Do not touch the mapping when + /// reclamation is unsupported or fails; report the failure to the caller. + strict, +}; + +/// Discard physical pages while retaining a mapping's virtual address range. +/// +/// The complete mapping must be page-aligned and a multiple of the minimum +/// system page size. `dirty_len` identifies the prefix whose contents may be +/// nonzero. Strict mode requires the complete mapping to be dirty because a +/// successful discard invalidates all of its contents. +/// +/// The return value reports whether the OS accepted the reclamation request. +/// Test builds return true after simulating reclamation by zeroing dirty bytes. +/// In zero mode, the requested bytes are guaranteed to be zero regardless of +/// the return value. +pub fn decommit( + comptime mode: DecommitMode, + memory: []align(std.heap.page_size_min) u8, + dirty_len: usize, +) bool { + assert(memory.len > 0); + assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0); + assert(memory.len % std.heap.page_size_min == 0); + assert(dirty_len <= memory.len); + if (comptime mode == .strict) assert(dirty_len == memory.len); + + // Testing allocator ranges may share an OS mapping with unrelated memory, + // so madvise is not safe. Zeroing models the only content guarantee callers + // have after a successful discard. + if (comptime builtin.is_test) { + @memset(memory[0..dirty_len], 0); + return true; + } + + // DONTNEED immediately reclaims private anonymous pages on Linux and + // faults them back as zero-filled pages. We deliberately avoid MADV_FREE: + // it does not reduce RSS until memory pressure and does not guarantee that + // the next read is zero. + if (comptime builtin.os.tag == .linux) { + if (std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.DONTNEED, + )) |_| return true else |err| { + log.warn("madvise(DONTNEED) failed err={}", .{err}); + if (comptime mode == .strict) return false; + // Zero mode falls through to the memset below. + } + } + + // FREE_REUSABLE removes the range from the Darwin process footprint while + // retaining its mapping. Zero mode clears its dirty prefix first because + // the kernel may preserve the contents. Strict mode avoids that write + // because its caller will replace the entire mapping after recommit. + if (comptime builtin.os.tag.isDarwin()) { + if (comptime mode == .zero) @memset(memory[0..dirty_len], 0); + + if (std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.FREE_REUSABLE, + )) |_| return true else |err| { + switch (mode) { + .strict => { + log.warn("madvise(FREE_REUSABLE) failed err={}", .{err}); + return false; + }, + + .zero => { + // Plain FREE can still reclaim the already-zero mapping + // under pressure and does not require a reuse pairing. + std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.FREE, + ) catch {}; + return false; + }, + } + } + } + + if (comptime mode == .zero) @memset(memory[0..dirty_len], 0); + return false; +} + +/// Prepare a mapping previously passed to decommit for reuse. +/// +/// Linux and test builds need no explicit operation. Darwin pairs +/// FREE_REUSABLE with FREE_REUSE so pages touched by the caller are accounted +/// to the process again. Failure does not invalidate the retained mapping, so +/// reuse can continue after logging the accounting failure. +pub fn recommit(memory: []align(std.heap.page_size_min) u8) void { + assert(memory.len > 0); + assert(@intFromPtr(memory.ptr) % std.heap.page_size_min == 0); + assert(memory.len % std.heap.page_size_min == 0); + + if (comptime builtin.is_test) return; + if (comptime builtin.os.tag.isDarwin()) { + std.posix.madvise( + memory.ptr, + memory.len, + std.posix.MADV.FREE_REUSE, + ) catch |err| { + log.warn("madvise(FREE_REUSE) failed err={}", .{err}); + }; + } +} + +test "decommit with zero fallback clears the dirty prefix" { + const testing = std.testing; + const memory_len = 2 * std.heap.page_size_min; + const memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + memory_len, + ); + defer testing.allocator.free(memory); + + @memset(memory, 0xAA); + _ = decommit(.zero, memory, memory.len); + try testing.expect(std.mem.allEqual(u8, memory, 0)); + + // The tail is already zero by contract, so a partially dirty mapping only + // needs its dirty prefix cleared. + @memset(memory[0..1024], 0xAA); + _ = decommit(.zero, memory, 1024); + try testing.expect(std.mem.allEqual(u8, memory, 0)); +} + +test "strict decommit retains the mapping for recommit" { + const testing = std.testing; + const memory_len = 2 * std.heap.page_size_min; + const memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + memory_len, + ); + defer testing.allocator.free(memory); + @memset(memory, 0xAA); + + const original_ptr = memory.ptr; + const original_len = memory.len; + try testing.expect(decommit(.strict, memory, memory.len)); + try testing.expectEqual(original_ptr, memory.ptr); + try testing.expectEqual(original_len, memory.len); + try testing.expect(std.mem.allEqual(u8, memory, 0)); + + recommit(memory); + @memset(memory, 0xBB); + try testing.expect(std.mem.allEqual(u8, memory, 0xBB)); +} From 421fe8dabe585aee5131e3960e492d29bd373043 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jul 2026 11:45:46 -0700 Subject: [PATCH 04/18] terminal: integrate compressed pages into PageList PageList nodes previously exposed Page directly, so introducing a compressed representation would require every consumer to understand its state and ownership. Add resident and compressed node states behind a page access boundary. Content access transparently recommits and restores retained mappings, while metadata traversal stays compressed and lifecycle paths can discard encoded contents without decoding. Compression borrows pool memory for standard scratch and uses temporary aligned storage for oversized pages. Migrate terminal, rendering, search, formatting, and C API consumers to the new boundary. Hot grow, scroll, and print paths reuse resolved pages, with an explicit resident-only accessor where live cursor pointers prove that the page cannot be compressed. The compression entry point remains private with no production callers, so normal terminal behavior and scrollback accounting are unchanged. ReleaseFast terminal-stream comparisons across bulk output, scrolling, redraw, and erase workloads remain within 2% of the parent revision. --- src/Surface.zig | 2 +- src/font/shaper/coretext.zig | 2 +- src/font/shaper/harfbuzz.zig | 2 +- src/inspector/widgets/pagelist.zig | 15 +- src/terminal/PageList.zig | 1503 +++++++++++++++--------- src/terminal/Screen.zig | 358 +++--- src/terminal/Selection.zig | 6 +- src/terminal/Terminal.zig | 268 ++--- src/terminal/c/grid_ref.zig | 17 +- src/terminal/compress/Page.zig | 33 +- src/terminal/formatter.zig | 146 +-- src/terminal/render.zig | 8 +- src/terminal/search/active.zig | 6 +- src/terminal/search/pagelist.zig | 18 +- src/terminal/search/sliding_window.zig | 38 +- src/terminal/search/viewport.zig | 8 +- 16 files changed, 1453 insertions(+), 977 deletions(-) diff --git a/src/Surface.zig b/src/Surface.zig index c56c9791c..2c4edc497 100644 --- a/src/Surface.zig +++ b/src/Surface.zig @@ -4438,7 +4438,7 @@ fn openUrl( /// if there is no hyperlink. fn osc8URI(self: *Surface, pin: terminal.Pin) ?[]const u8 { _ = self; - const page = &pin.node.data; + const page = pin.node.page(); const cell = pin.rowAndCell().cell; const link_id = page.lookupHyperlink(cell) orelse return null; const entry = page.hyperlink_set.get(page.memory, link_id); diff --git a/src/font/shaper/coretext.zig b/src/font/shaper/coretext.zig index 3f69af6d2..8a9374bd6 100644 --- a/src/font/shaper/coretext.zig +++ b/src/font/shaper/coretext.zig @@ -1333,7 +1333,7 @@ test "shape emoji width long" { var t = try terminal.Terminal.init(alloc, .{ .cols = 30, .rows = 3 }); defer t.deinit(alloc); - var page = t.screens.active.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.page(); var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ diff --git a/src/font/shaper/harfbuzz.zig b/src/font/shaper/harfbuzz.zig index a400ecaff..996aabff0 100644 --- a/src/font/shaper/harfbuzz.zig +++ b/src/font/shaper/harfbuzz.zig @@ -811,7 +811,7 @@ test "shape emoji width long" { ); defer t.deinit(alloc); - var page = t.screens.active.pages.pages.first.?.data; + var page = t.screens.active.pages.pages.first.?.page(); var row = page.getRow(1); const cell = &row.cells.ptr(page.memory)[0]; cell.* = .{ diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig index bedbfb599..651882046 100644 --- a/src/inspector/widgets/pagelist.zig +++ b/src/inspector/widgets/pagelist.zig @@ -58,7 +58,7 @@ pub const Inspector = struct { var index: usize = pages.totalPages(); var node = pages.pages.last; while (node) |page_node| : (node = page_node.prev) { - const page = &page_node.data; + const page = page_node.page(); row_offset -= page.size.rows; index -= 1; @@ -549,8 +549,9 @@ pub const CellChooser = struct { if (cell.cell.style_id != stylepkg.default_id) { cimgui.c.ImGui_SeparatorText("Style"); - const style = cell.node.data.styles.get( - cell.node.data.memory, + const page = cell.node.page(); + const style = page.styles.get( + page.memory, cell.cell.style_id, ).*; widgets.style.table(style, null); @@ -585,7 +586,7 @@ fn hyperlinkTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = &cell.node.data; + const page = cell.node.page(); const link_id = page.lookupHyperlink(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -640,7 +641,7 @@ fn graphemeTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = &cell.node.data; + const page = cell.node.page(); const cps = page.lookupGrapheme(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -747,7 +748,7 @@ pub const CellInfo = struct { _ = cimgui.c.ImGui_TableSetColumnIndex(2); if (cimgui.c.ImGui_BeginListBox("##cell_grapheme", .{ .x = 0, .y = 0 })) { defer cimgui.c.ImGui_EndListBox(); - if (cell.node.data.lookupGrapheme(cell.cell)) |cps| { + if (cell.node.page().lookupGrapheme(cell.cell)) |cps| { var buf: [96]u8 = undefined; for (cps) |cp| { const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?"; @@ -845,7 +846,7 @@ pub const CellInfo = struct { widgets.helpMarker("OSC8 hyperlink ID associated with this cell."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - const link_id = cell.node.data.lookupHyperlink(cell.cell) orelse 0; + const link_id = cell.node.page().lookupHyperlink(cell.cell) orelse 0; cimgui.c.ImGui_Text("id=%d", link_id); } } diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 001df75a6..7844bcc0f 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -12,6 +12,7 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); +const compress = @import("compress.zig"); const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); const terminal_mem = @import("mem.zig"); @@ -45,14 +46,14 @@ pub const List = DoublyLinkedList(Node); const Node = struct { prev: ?*Node = null, next: ?*Node = null, - data: Page, + data: Data, serial: u64, - /// How the backing memory of `data` was allocated. Pool-owned + /// How the backing memory of the embedded Page was allocated. Pool-owned /// memory is always a full standard-size item from the memory /// pool, regardless of the page layout size. Heap-owned memory /// is allocated directly with the page allocator and is exactly - /// `data.memory.len` bytes. + /// `Page.memory.len` bytes. /// /// This must never be inferred from the memory length: heap-owned /// pages can be smaller than the standard size (e.g. compacted @@ -62,7 +63,127 @@ const Node = struct { /// is forced to make an explicit decision. owned: Owned, + /// The physical representation of the page contents. + /// + /// Both states retain the same `Page.memory` virtual mapping and the same + /// `Page` metadata. A resident page can access the mapping directly since + /// it is, well, resident. + /// + /// A compressed page owns an encoded copy while the physical memory + /// behind the raw mapping is discarded. HOWEVER, we retain the + /// virtual allocation so that restoration is infallible. + const Data = union(enum) { + resident: Page, + compressed: compress.Page, + }; + const Owned = enum { pool, heap }; + + /// Return the terminal page stored in this node. + /// + /// WARNING: This will DECOMPRESS compressed pages! Only use this if + /// you need access to the underlying memory. If you only need access to + /// metadata (row, col counts etc) then use the other metadata functions. + pub inline fn page(self: *Node) *Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => self.restore(.preserve), + }; + } + + /// Return a page which the caller knows is already resident. + /// + /// This avoids the representation check in hot paths which already hold + /// live pointers into the page mapping. Such pointers are only valid while + /// the page is resident. Prefer `page` unless the caller can establish + /// that invariant independently. + pub inline fn pageAssumeResident(self: *Node) *Page { + return &self.data.resident; + } + + /// Return the number of populated rows without accessing page memory. + pub inline fn rows(self: *const Node) size.CellCountInt { + return self.metadata().size.rows; + } + + /// Return the current column count without accessing page memory. + pub inline fn cols(self: *const Node) size.CellCountInt { + return self.metadata().size.cols; + } + + /// Return the page capacity without accessing page memory. + pub inline fn capacity(self: *const Node) Capacity { + return self.metadata().capacity; + } + + /// Return the embedded Page metadata without restoring its memory. + inline fn metadata(self: *const Node) *const Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => |*page_| &page_.page, + }; + } + + const RestoreMode = enum { + /// Decode the compressed representation back into the raw mapping. + preserve, + + /// Discard the compressed representation without decoding it. The + /// caller must not read the page contents before overwriting them. + discard, + }; + + /// Restore this node to the resident representation. + /// + /// Preserve mode reconstructs the page contents and is used by `page`. + /// Discard mode skips decoding for callers which will overwrite or destroy + /// the page. Both modes recommit the retained mapping, free the encoded + /// allocation, and leave the node in a valid resident state. + noinline fn restore(self: *Node, comptime mode: RestoreMode) *Page { + const compressed = switch (self.data) { + .resident => |*page_| return page_, + .compressed => |*page_| page_, + }; + + // Decommit only discarded the physical pages. Recommit prepares the + // still-reserved mapping for decoding or reuse by the caller. + terminal_mem.recommit(compressed.page.memory); + + const restored = switch (mode) { + .preserve => compressed.restore() catch |err| { + // Compressed nodes retain the immutable encoding and exact + // output mapping used to create them. Any decode error is an + // internal codec bug or memory corruption. Keep this switch + // exhaustive so new decoder errors require classification. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + log.err("failed to restore compressed page err={}", .{err}); + @panic("failed to restore compressed terminal page"); + }, + + .discard => compressed.page, + }; + + // Remove our compressed data + compressed.deinit(); + + // We're now a resident, non-compressed node. + self.data = .{ .resident = restored }; + return &self.data.resident; + } + + inline fn isCompressed(self: *const Node) bool { + return switch (self.data) { + .resident => false, + .compressed => true, + }; + } }; /// The memory pool we get page nodes from. @@ -155,9 +276,9 @@ page_serial: u64, /// for quick comparisons to find invalid pages in references. page_serial_min: u64, -/// Byte size of the total amount of allocated pages. Note this does -/// not include the total allocated amount in the pool which may be more -/// than this due to preheating. +/// Byte size of the raw backing mappings owned by active page nodes. This is +/// logical scrollback accounting and does not change while a mapping is +/// decommitted. It excludes encoded storage and unused preheated pool items. page_size: usize, /// Maximum size of the page allocation in bytes. This only includes pages @@ -468,7 +589,7 @@ fn initPages( while (it) |node| : (it = node.next) { switch (node.owned) { .pool => {}, - .heap => page_alloc.free(node.data.memory), + .heap => page_alloc.free(node.page().memory), } } } @@ -508,12 +629,12 @@ fn initPages( // Initialize the first set of pages to contain our viewport so that // the top of the first page is always the active area. node.* = .{ - .data = .initBuf(.init(page_buf), layout), + .data = .{ .resident = .initBuf(.init(page_buf), layout) }, .serial = serial.*, .owned = if (pooled) .pool else .heap, }; - node.data.size.rows = @min(rem, node.data.capacity.rows); - rem -= node.data.size.rows; + node.page().size.rows = @min(rem, node.capacity().rows); + rem -= node.rows(); // Add the page to the list page_list.append(node); @@ -574,7 +695,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { { var node_ = self.pages.first; while (node_) |node| { - actual_total += node.data.size.rows; + actual_total += node.rows(); node_ = node.next; // While doing this traversal, verify no node has a serial @@ -609,7 +730,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void { var offset: usize = 0; var node = self.pages.last; while (node) |n| : (node = n.prev) { - offset += n.data.size.rows; + offset += n.rows(); if (n == self.viewport_pin.node) { offset -= self.viewport_pin.y; break :offset self.total_rows - offset; @@ -658,9 +779,10 @@ pub fn deinit(self: *PageList) void { const page_alloc = self.pool.pages.arena.child_allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { + const page = node.restore(.discard); switch (node.owned) { .pool => {}, - .heap => page_alloc.free(node.data.memory), + .heap => page_alloc.free(page.memory), } } @@ -704,9 +826,10 @@ pub fn reset(self: *PageList) void { const page_alloc = self.pool.pages.arena.child_allocator; var it = self.pages.first; while (it) |node| : (it = node.next) { + const page = node.restore(.discard); switch (node.owned) { .pool => {}, - .heap => page_alloc.free(node.data.memory), + .heap => page_alloc.free(page.memory), } } } @@ -848,7 +971,7 @@ pub fn clone( while (page_it) |node| : (page_it = node.next) { switch (node.owned) { .pool => {}, - .heap => page_alloc.free(node.data.memory), + .heap => page_alloc.free(node.page().memory), } } } @@ -862,25 +985,27 @@ pub fn clone( // we don't know if the source page has a standard size. const node = try createPageExt( &pool, - .{ .cap = chunk.node.data.capacity }, + .{ .cap = chunk.node.capacity() }, &page_serial, &page_size, ); - assert(node.data.capacity.rows >= chunk.end - chunk.start); - defer node.data.assertIntegrity(); - node.data.size.rows = chunk.end - chunk.start; - node.data.size.cols = chunk.node.data.size.cols; - try node.data.cloneFrom( - &chunk.node.data, + const dst_page = node.page(); + const src_page = chunk.node.page(); + assert(node.capacity().rows >= chunk.end - chunk.start); + defer dst_page.assertIntegrity(); + dst_page.size.rows = chunk.end - chunk.start; + dst_page.size.cols = chunk.node.cols(); + try dst_page.cloneFrom( + src_page, chunk.start, chunk.end, ); - node.data.dirty = chunk.node.data.dirty; + dst_page.dirty = src_page.dirty; page_list.append(node); - total_rows += node.data.size.rows; + total_rows += node.rows(); // Remap our tracked pins by changing the page and // offsetting the Y position based on the chunk start. @@ -933,8 +1058,9 @@ pub fn clone( // now we rarely clone less than the active area and if we do // the area is by definition very small. const last = result.pages.last.?; - const row = &last.data.rows.ptr(last.data.memory)[last.data.size.rows - 1]; - last.data.clearCells(row, 0, result.cols); + const page = last.page(); + const row = &page.rows.ptr(page.memory)[last.rows() - 1]; + page.clearCells(row, 0, result.cols); } // Update our total rows to be our row size. @@ -1107,7 +1233,7 @@ fn resizeCols( // Create the first node that contains our reflow. const first_rewritten_node = node: { - const page = &self.pages.first.?.data; + const page = self.pages.first.?.page(); const cap = page.capacity.adjust( .{ .cols = cols }, ) catch |err| err: { @@ -1125,7 +1251,7 @@ fn resizeCols( }; const node = try self.createPage(.{ .cap = cap }); - node.data.size.rows = 1; + node.page().size.rows = 1; break :node node; }; @@ -1166,7 +1292,7 @@ fn resizeCols( // Once we're done reflowing a page, destroy it immediately. // This frees memory and makes it more likely in memory // constrained environments that the next reflow will work. - if (row.y == row.node.data.size.rows - 1) { + if (row.y == row.node.rows() - 1) { self.destroyNode(row.node); } } @@ -1182,7 +1308,7 @@ fn resizeCols( var node_it = self.pages.first; var total: usize = 0; while (node_it) |node| : (node_it = node.next) { - total += node.data.size.rows; + total += node.rows(); if (total >= self.rows) break; } else { for (total..self.rows) |_| _ = try self.grow(); @@ -1255,7 +1381,7 @@ const ReflowCursor = struct { total_rows: usize, fn init(node: *List.Node) ReflowCursor { - const page = &node.data; + const page = node.page(); const rows = page.rows.ptr(page.memory); return .{ .x = 0, @@ -1268,7 +1394,7 @@ const ReflowCursor = struct { .new_rows = 0, // Initially whatever size our input node is. - .total_rows = node.data.size.rows, + .total_rows = node.rows(), }; } @@ -1279,7 +1405,7 @@ const ReflowCursor = struct { row: Pin, cursor_pin: ?*Pin, ) Allocator.Error!void { - const src_page: *Page = &row.node.data; + const src_page: *Page = row.node.page(); const src_row = row.rowAndCell().row; const src_y = row.y; const cells = src_row.cells.ptr(src_page.memory)[0..src_page.size.cols]; @@ -1302,7 +1428,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y) continue; if (cursor_pin != null and p == cursor_pin.?) continue; @@ -1325,7 +1451,7 @@ const ReflowCursor = struct { // If the cursor is after blanks on the right, those cells are still // before the next write and must reflow with it. if (cursor_pin) |p| { - if (&p.node.data == src_page and p.y == src_y) { + if (p.node.page() == src_page and p.y == src_y) { cols_len = @max(cols_len, p.x + 1); } } @@ -1376,7 +1502,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y or p.x != x) continue; @@ -1401,7 +1527,7 @@ const ReflowCursor = struct { // since we won't process that cell in the loop. const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != src_page or + if (p.node.page() != src_page or p.y != src_y or p.x != x + 1) continue; @@ -1885,7 +2011,7 @@ const ReflowCursor = struct { { const pin_keys = list.tracked_pins.keys(); for (pin_keys) |p| { - if (&p.node.data != old_page or + if (p.node.page() != old_page or p.y != old_page.size.rows - 1) continue; p.node = self.node; @@ -1988,7 +2114,7 @@ const ReflowCursor = struct { const node = try list.createPage(.{ .cap = cap }); errdefer comptime unreachable; - node.data.size.rows = 1; + node.page().size.rows = 1; list.pages.insertAfter(self.node, node); self.* = .init(node); @@ -2088,7 +2214,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { .lt => { var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); defer page.assertIntegrity(); const rows = page.rows.ptr(page.memory); for (0..page.size.rows) |i| { @@ -2190,7 +2316,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { var count: usize = 0; var page = self.pages.first; while (page) |p| : (page = p.next) { - count += p.data.size.rows; + count += p.rows(); if (count >= rows) break; } else { assert(count < rows); @@ -2221,7 +2347,7 @@ fn resizeWithoutReflowGrowCols( chunk: PageIterator.Chunk, ) Allocator.Error!void { assert(cols > self.cols); - const page = &chunk.node.data; + const page = chunk.node.page(); // Update our col count const old_cols = self.cols; @@ -2295,7 +2421,7 @@ fn resizeWithoutReflowGrowCols( // This can fail for many reasons (can't fit styles/graphemes, etc.) so // if it fails then we give up and drop back into creating new pages. if (prev) |prev_node| prev: { - const prev_page = &prev_node.data; + const prev_page = prev_node.page(); // We only want scenarios where we have excess capacity. if (prev_page.size.rows >= prev_page.capacity.rows) break :prev; @@ -2340,7 +2466,7 @@ fn resizeWithoutReflowGrowCols( // If we have an error, we clear the rows we just added to our prev page. const prev_copied = copied; errdefer if (prev_copied > 0) { - const prev_page = &prev.?.data; + const prev_page = prev.?.page(); const prev_size = prev_page.size.rows - prev_copied; const prev_rows = prev_page.rows.ptr(prev_page.memory)[prev_size..prev_page.size.rows]; for (prev_rows) |*row| prev_page.clearCells( @@ -2366,7 +2492,8 @@ fn resizeWithoutReflowGrowCols( // to split pages. while (copied < page.size.rows) { const new_node = try self.createPage(.{ .cap = cap }); - defer new_node.data.assertIntegrity(); + const new_page = new_node.page(); + defer new_page.assertIntegrity(); // The length we can copy into the new page is at most the number // of rows in our cap. But if we can finish our source page we use that. @@ -2375,10 +2502,10 @@ fn resizeWithoutReflowGrowCols( // Perform the copy const y_start = copied; const src_rows = page.rows.ptr(page.memory)[y_start .. copied + len]; - const dst_rows = new_node.data.rows.ptr(new_node.data.memory)[0..len]; + const dst_rows = new_page.rows.ptr(new_page.memory)[0..len]; for (dst_rows, src_rows) |*dst_row, *src_row| { - new_node.data.size.rows += 1; - if (new_node.data.cloneRowFrom( + new_page.size.rows += 1; + if (new_page.cloneRowFrom( page, dst_row, src_row, @@ -2396,7 +2523,7 @@ fn resizeWithoutReflowGrowCols( // We can actually safely handle this though by exiting // this loop early and cutting our copy short. - new_node.data.size.rows -= 1; + new_page.size.rows -= 1; break; } } @@ -2437,12 +2564,13 @@ fn trailingBlankLines( // Go through our pages backwards since we're counting trailing blanks. var it = self.pages.last; - while (it) |page| : (it = page.prev) { - const len = page.data.size.rows; - const rows = page.data.rows.ptr(page.data.memory)[0..len]; + while (it) |node| : (it = node.prev) { + const page = node.page(); + const len = node.rows(); + const rows = page.rows.ptr(page.memory)[0..len]; for (0..len) |i| { const rev_i = len - i - 1; - const cells = rows[rev_i].cells.ptr(page.data.memory)[0..page.data.size.cols]; + const cells = rows[rev_i].cells.ptr(page.memory)[0..node.cols()]; // If the row has any text then we're done. if (pagepkg.Cell.hasTextAny(cells)) return count; @@ -2489,11 +2617,11 @@ fn trimTrailingBlankRows( // No text, we can trim this row. Because it has // no text we can also be sure it has no styling // so we don't need to worry about memory. - row_pin.node.data.size.rows -= 1; - if (row_pin.node.data.size.rows == 0) { + row_pin.node.page().size.rows -= 1; + if (row_pin.node.page().size.rows == 0) { self.erasePage(row_pin.node); } else { - row_pin.node.data.assertIntegrity(); + row_pin.node.page().assertIntegrity(); } trimmed += 1; @@ -2606,7 +2734,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { var node_it = self.pages.first; var rem: usize = n; while (node_it) |node| : (node_it = node.next) { - if (rem < node.data.size.rows) { + if (rem < node.rows()) { self.viewport_pin.* = .{ .node = node, .y = std.math.cast(size.CellCountInt, rem) orelse { @@ -2617,17 +2745,17 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { break :row; } - rem -= node.data.size.rows; + rem -= node.rows(); } } else { // Iterate backwards from the last node. var node_it = self.pages.last; var rem: usize = self.total_rows - n; while (node_it) |node| : (node_it = node.prev) { - if (rem <= node.data.size.rows) { + if (rem <= node.rows()) { self.viewport_pin.* = .{ .node = node, - .y = std.math.cast(size.CellCountInt, node.data.size.rows - rem) orelse { + .y = std.math.cast(size.CellCountInt, node.rows() - rem) orelse { self.viewport = .active; break :row; }, @@ -2635,7 +2763,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { break :row; } - rem -= node.data.size.rows; + rem -= node.rows(); } } @@ -2807,11 +2935,12 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void { var page = self.pages.last.?; var n: usize = 0; while (true) { - const rows: [*]Row = page.data.rows.ptr(page.data.memory); - for (0..page.data.size.rows) |i| { - const rev_i = page.data.size.rows - i - 1; + const current_page = page.page(); + const rows: [*]Row = current_page.rows.ptr(current_page.memory); + for (0..page.rows()) |i| { + const rev_i = page.rows() - i - 1; const row = rows[rev_i]; - const cells = row.cells.ptr(page.data.memory)[0..self.cols]; + const cells = row.cells.ptr(current_page.memory)[0..self.cols]; for (cells) |cell| { if (!cell.isEmpty()) break :non_empty self.rows - n; } @@ -2845,7 +2974,7 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void { /// the uncompacted page. pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { defer self.assertIntegrity(); - const page: *Page = &node.data; + const page: *Page = node.page(); // We should never have empty rows in our pagelist anyways... assert(page.size.rows > 0); @@ -2868,7 +2997,7 @@ pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node { .exact_size = true, }); errdefer self.destroyNode(new_node); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); new_page.size = page.size; new_page.dirty = page.dirty; new_page.cloneFrom( @@ -2929,7 +3058,7 @@ pub fn split( // when we modify it later it updates our p here. Copying the node // fixes this. const original_node = p.node; - const page: *Page = &original_node.data; + const page: *Page = original_node.page(); // A page that is already 1 row can't be split. In the future we can // theoretically maybe split by soft-wrapping multiple pages but that @@ -2951,12 +3080,12 @@ pub fn split( // Determine how many rows we're copying const y_start = p.y; const y_end = page.size.rows; - target.data.size.rows = y_end - y_start; - assert(target.data.size.rows <= target.data.capacity.rows); + target.page().size.rows = y_end - y_start; + assert(target.rows() <= target.capacity().rows); // Copy our old data. This should NOT fail because we have the // capacity of the old page which already fits the data we requested. - target.data.cloneFrom(page, y_start, y_end) catch |err| { + target.page().cloneFrom(page, y_start, y_end) catch |err| { log.err( "error cloning rows for split err={}", .{err}, @@ -2974,7 +3103,7 @@ pub fn split( // Move any tracked pins from the copied rows for (self.tracked_pins.keys()) |tracked| { - if (&tracked.node.data != page or + if (tracked.node.page() != page or tracked.y < p.y) continue; tracked.node = target; @@ -3092,9 +3221,9 @@ fn viewportRowOffset(self: *PageList) usize { var offset: usize = 0; var node = self.pages.last; while (node) |n| : (node = n.prev) { - offset += n.data.size.rows; + offset += n.rows(); if (n == self.viewport_pin.node) { - assert(n.data.size.rows > self.viewport_pin.y); + assert(n.rows() > self.viewport_pin.y); offset -= self.viewport_pin.y; break :offset self.total_rows - offset; } @@ -3170,10 +3299,11 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { defer self.assertIntegrity(); const last = self.pages.last.?; - if (last.data.capacity.rows > last.data.size.rows) { + if (last.capacity().rows > last.rows()) { // Fast path: we have capacity in the last page. - last.data.size.rows += 1; - last.data.assertIntegrity(); + const page = last.page(); + page.size.rows += 1; + page.assertIntegrity(); // Increase our total rows by one self.total_rows += 1; @@ -3203,7 +3333,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { assert(first != last); // Decrease our total row count from the pruned page - self.total_rows -= first.data.size.rows; + self.total_rows -= first.rows(); // If our total row count is now less than our required // rows then we can't prune. The "+ 1" is because we'll add one @@ -3211,7 +3341,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { if (self.total_rows + 1 < self.rows) { self.pages.prepend(first); assert(self.pages.first == first); - self.total_rows += first.data.size.rows; + self.total_rows += first.rows(); break :prune; } @@ -3220,14 +3350,14 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { if (self.viewport_pin_row_offset) |*v| { // If our offset is less than the number of rows in the // pruned page, then we are now at the top. - if (v.* < first.data.size.rows) { + if (v.* < first.rows()) { self.viewport = .top; break :viewport; } // Otherwise, our viewport pin is below what we pruned // so we just decrement our offset. - v.* -= first.data.size.rows; + v.* -= first.rows(); } } @@ -3257,13 +3387,14 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { } // Reset our memory - const buf = first.data.memory; + const buf = first.restore(.discard).memory; @memset(buf, 0); assert(buf.len <= std_size); // Initialize our new page and reinsert it as the last - first.data = .initBuf(.init(buf), Page.layout(cap)); - first.data.size.rows = 1; + first.data = .{ .resident = .initBuf(.init(buf), Page.layout(cap)) }; + const page = first.page(); + page.size.rows = 1; self.pages.insertAfter(last, first); self.total_rows += 1; @@ -3278,7 +3409,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { // In this case we do NOT need to update page_size because // we're reusing an existing page so nothing has changed. - first.data.assertIntegrity(); + page.assertIntegrity(); return first; } @@ -3287,11 +3418,12 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { // we don't errdefer this because we've added it to the linked // list and its fine to have dangling unused pages. self.pages.append(next_node); - next_node.data.size.rows = 1; + const page = next_node.page(); + page.size.rows = 1; // We should never be more than our max size here because we've // verified the case above. - next_node.data.assertIntegrity(); + page.assertIntegrity(); // Record the increased row count self.total_rows += 1; @@ -3338,7 +3470,7 @@ pub fn increaseCapacity( adjustment: ?IncreaseCapacity, ) IncreaseCapacityError!*List.Node { defer self.assertIntegrity(); - const page: *Page = &node.data; + const page: *Page = node.page(); // Apply our adjustment var cap = page.capacity; @@ -3396,7 +3528,7 @@ pub fn increaseCapacity( // Create our new page and clone the old page into it. const new_node = try self.createPage(.{ .cap = cap }); errdefer self.destroyNode(new_node); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); assert(new_page.capacity.rows >= page.capacity.rows); assert(new_page.capacity.cols >= page.capacity.cols); new_page.size.rows = page.size.rows; @@ -3514,11 +3646,11 @@ inline fn createPageExt( @memset(page_buf, 0); page.* = .{ - .data = .initBuf(.init(page_buf), layout), + .data = .{ .resident = .initBuf(.init(page_buf), layout) }, .serial = serial.*, .owned = if (pooled) .pool else .heap, }; - page.data.size.rows = 0; + page.page().size.rows = 0; serial.* += 1; if (total_size) |v| { @@ -3531,6 +3663,130 @@ inline fn createPageExt( return page; } +/// Temporary output memory used while creating a compressed page. +/// +/// Standard-sized output borrows a page-pool item so repeated compression can +/// reuse the same virtual mapping. Oversized pages use a temporary allocation +/// from the page allocator and release it immediately after compression. +const CompressionScratch = union(enum) { + pooled: *align(std.heap.page_size_min) [std_size]u8, + allocated: []align(std.heap.page_size_min) u8, + + fn init( + pool: *MemoryPool, + required: usize, + raw_len: usize, + ) Allocator.Error!CompressionScratch { + assert(required <= raw_len); + + if (required <= std_size) { + const memory = try pool.pages.create(); + terminal_mem.recommit(memory); + return .{ .pooled = memory }; + } + + const page_alloc = pool.pages.arena.child_allocator; + return .{ .allocated = try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + raw_len, + ) }; + } + + fn bytes(self: *CompressionScratch) []u8 { + return switch (self.*) { + .pooled => |memory| memory, + .allocated => |memory| memory, + }; + } + + fn deinit(self: *CompressionScratch, pool: *MemoryPool) void { + switch (self.*) { + .pooled => |memory| { + _ = terminal_mem.decommit(.zero, memory, memory.len); + pool.pages.destroy(memory); + }, + .allocated => |memory| { + const page_alloc = pool.pages.arena.child_allocator; + page_alloc.free(memory); + }, + } + } +}; + +/// Attempt to compress one resident page while retaining its raw mapping. +/// +/// Compression is opportunistic: every failure leaves the page resident and +/// usable. This function is intentionally private and has no production +/// callers yet; policy for choosing cold pages belongs to a later change. +fn compressPage(self: *PageList, node: *List.Node) bool { + // Recompression requires first restoring the raw page and is a policy + // decision, so this primitive only accepts resident nodes. + if (node.isCompressed()) return false; + + const page = node.page(); + + // The scratch size is capped just below the representation's break-even + // point. Codec limits and pages too small to cover the compressed-state + // overhead simply make this page ineligible for compression. + const required = compress.Page.requiredScratch(page.memory.len) catch |err| + switch (err) { + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + if (required == 0) return false; + + // Build the compressed candidate without changing the node. This scope is + // intentional: its defer releases the borrowed or temporary scratch before + // we attempt to discard the source mapping below. Only the candidate's + // exact-sized encoded allocation survives the scope. + const candidate = candidate: { + var scratch = CompressionScratch.init( + &self.pool, + required, + page.memory.len, + ) catch |err| switch (err) { + error.OutOfMemory => return false, + }; + defer scratch.deinit(&self.pool); + + var table: compress.lz4.HashTable = undefined; + break :candidate compress.Page.init( + self.pool.alloc, + page, + scratch.bytes()[0..required], + &table, + ) catch |err| switch (err) { + error.OutOfMemory, + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + }; + + // Null means compression crossed the break-even point. The node and its + // resident mapping are still untouched in this case. + var compressed = candidate orelse return false; + + // Strict decommit is the final fallible step. It either discards the whole + // raw mapping or leaves it untouched, so failure can safely free the + // candidate and preserve the resident node exactly as it was. + if (!terminal_mem.decommit( + .strict, + compressed.page.memory, + compressed.page.memory.len, + )) { + compressed.deinit(); + return false; + } + + // Publish the new state only after both the encoded allocation and the + // retained-mapping decommit have succeeded. + node.data = .{ .compressed = compressed }; + return true; +} + /// Destroy the memory of the given node in the PageList linked list /// and return it to the pool. The node is assumed to already be removed /// from the linked list. @@ -3547,7 +3803,7 @@ fn destroyNodeExt( node: *List.Node, total_size: ?*usize, ) void { - const page: *Page = &node.data; + const page = node.restore(.discard); // Update our accounting for page size. This must mirror what was // added at creation time: a pool-owned page always accounts for a @@ -3594,12 +3850,13 @@ pub fn eraseRow( const pn = self.pin(pt).?; var node = pn.node; - var rows = node.data.rows.ptr(node.data.memory.ptr); + var page = node.page(); + var rows = page.rows.ptr(page.memory.ptr); // In order to move the following rows up we rotate the rows array by 1. // The rotate operation turns e.g. [ 0 1 2 3 ] in to [ 1 2 3 0 ], which // works perfectly to move all of our elements where they belong. - fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); + fastmem.rotateOnce(Row, rows[pn.y..node.rows()]); // We adjust the tracked pins in this page, moving up any that were below // the removed row. @@ -3617,12 +3874,13 @@ pub fn eraseRow( // // Technically we only need to mark rows from the erased row to the end // of the page as dirty, but that's slower and this is a hot function. - node.data.dirty = true; + page.dirty = true; // We iterate through all of the following pages in order to move their // rows up by 1 as well. while (node.next) |next| { - const next_rows = next.data.rows.ptr(next.data.memory.ptr); + const next_page = next.page(); + const next_rows = next_page.rows.ptr(next_page.memory.ptr); // We take the top row of the page and clone it in to the bottom // row of the previous page, which gets rid of the top row that was @@ -3639,19 +3897,20 @@ pub fn eraseRow( // 5 5 5 | 6 // 6 6 6 | 7 // 7 7 7 <' 4 - try node.data.cloneRowFrom( - &next.data, - &rows[node.data.size.rows - 1], + try page.cloneRowFrom( + next_page, + &rows[node.rows() - 1], &next_rows[0], ); node = next; + page = next_page; rows = next_rows; - fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); + fastmem.rotateOnce(Row, rows[0..node.rows()]); // Mark the whole page as dirty. - node.data.dirty = true; + page.dirty = true; // Our tracked pins for this page need to be updated. // If the pin is in row 0 that means the corresponding row has @@ -3661,7 +3920,7 @@ pub fn eraseRow( if (p.node != node) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3669,7 +3928,7 @@ pub fn eraseRow( } // Clear the final row which was rotated from the top of the page. - node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols); + page.clearCells(&rows[node.rows() - 1], 0, node.cols()); } /// A variant of eraseRow that shifts only a bounded number of following @@ -3693,20 +3952,21 @@ pub fn eraseRowBounded( const pn = self.pin(pt).?; var node: *List.Node = pn.node; - var rows = node.data.rows.ptr(node.data.memory.ptr); + var page = node.page(); + var rows = page.rows.ptr(page.memory.ptr); // If the row limit is less than the remaining rows before the end of the // page, then we clear the row, rotate it to the end of the boundary limit // and update our pins. - if (node.data.size.rows - pn.y > limit) { - node.data.clearCells(&rows[pn.y], 0, node.data.size.cols); + if (node.rows() - pn.y > limit) { + page.clearCells(&rows[pn.y], 0, node.cols()); fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]); // Mark the whole page as dirty. // // Technically we only need to mark from the erased row to the // limit but this is a hot function, so we want to minimize work. - node.data.dirty = true; + page.dirty = true; // If our viewport is a pin and our pin is within the erased // region we need to maybe shift our cache up. We do this here instead @@ -3741,18 +4001,18 @@ pub fn eraseRowBounded( return; } - fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]); + fastmem.rotateOnce(Row, rows[pn.y..node.rows()]); // Mark the whole page as dirty. // // Technically we only need to mark rows from the erased row to the end // of the page as dirty, but that's slower and this is a hot function. - node.data.dirty = true; + page.dirty = true; // We need to keep track of how many rows we've shifted so that we can // determine at what point we need to do a partial shift on subsequent // pages. - var shifted: usize = node.data.size.rows - pn.y; + var shifted: usize = node.rows() - pn.y; // Update tracked pins. { @@ -3781,15 +4041,17 @@ pub fn eraseRowBounded( } while (node.next) |next| { - const next_rows = next.data.rows.ptr(next.data.memory.ptr); + const next_page = next.page(); + const next_rows = next_page.rows.ptr(next_page.memory.ptr); - try node.data.cloneRowFrom( - &next.data, - &rows[node.data.size.rows - 1], + try page.cloneRowFrom( + next_page, + &rows[node.rows() - 1], &next_rows[0], ); node = next; + page = next_page; rows = next_rows; // We check to see if this page contains enough rows to satisfy the @@ -3798,15 +4060,15 @@ pub fn eraseRowBounded( // // The logic here is very similar to the one before the loop. const shifted_limit = limit - shifted; - if (node.data.size.rows > shifted_limit) { - node.data.clearCells(&rows[0], 0, node.data.size.cols); + if (node.rows() > shifted_limit) { + page.clearCells(&rows[0], 0, node.cols()); fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]); // Mark the whole page as dirty. // // Technically we only need to mark from the erased row to the // limit but this is a hot function, so we want to minimize work. - node.data.dirty = true; + page.dirty = true; // See the other places we do something similar in this function // for a detailed explanation. @@ -3825,7 +4087,7 @@ pub fn eraseRowBounded( if (p.node != node or p.y > shifted_limit) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3834,13 +4096,13 @@ pub fn eraseRowBounded( return; } - fastmem.rotateOnce(Row, rows[0..node.data.size.rows]); + fastmem.rotateOnce(Row, rows[0..node.rows()]); // Mark the whole page as dirty. - node.data.dirty = true; + page.dirty = true; // Account for the rows shifted in this node. - shifted += node.data.size.rows; + shifted += node.rows(); // See the other places we do something similar in this function // for a detailed explanation. @@ -3858,7 +4120,7 @@ pub fn eraseRowBounded( if (p.node != node) continue; if (p.y == 0) { p.node = node.prev.?; - p.y = p.node.data.size.rows - 1; + p.y = p.node.rows() - 1; continue; } p.y -= 1; @@ -3867,7 +4129,7 @@ pub fn eraseRowBounded( // We reached the end of the page list before the limit, so we clear // the final row since it was rotated down from the top of this page. - node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols); + page.clearCells(&rows[node.rows() - 1], 0, node.cols()); } /// Erase all history rows, optionally up to a bottom-left bound. @@ -3922,27 +4184,28 @@ fn eraseRows( // page so to handle this we reinit this page, set it to zero // size which will let us grow our active area back. if (chunk.node.next == null and chunk.node.prev == null) { - const page = &chunk.node.data; + const page = chunk.node.page(); erased += page.size.rows; page.reinit(); page.size.rows = 0; break; } - erased += chunk.node.data.size.rows; + erased += chunk.node.rows(); self.erasePage(chunk.node); continue; } // We are modifying our chunk so make sure it is in a good state. - defer chunk.node.data.assertIntegrity(); + const page = chunk.node.page(); + defer page.assertIntegrity(); // The chunk is not a full page so we need to move the rows. // This is a cheap operation because we're just moving cell offsets, // not the actual cell contents. assert(chunk.start == 0); - const rows = chunk.node.data.rows.ptr(chunk.node.data.memory); - const scroll_amount = chunk.node.data.size.rows - chunk.end; + const rows = page.rows.ptr(page.memory); + const scroll_amount = chunk.node.rows() - chunk.end; for (0..scroll_amount) |i| { const src: *Row = &rows[i + chunk.end]; const dst: *Row = &rows[i]; @@ -3956,12 +4219,12 @@ fn eraseRows( // Clear our remaining cells that we didn't shift or swapped // in case we grow back into them. - for (scroll_amount..chunk.node.data.size.rows) |i| { + for (scroll_amount..chunk.node.rows()) |i| { const row: *Row = &rows[i]; - chunk.node.data.clearCells( + page.clearCells( row, 0, - chunk.node.data.size.cols, + chunk.node.cols(), ); } @@ -3979,7 +4242,7 @@ fn eraseRows( } // Our new size is the amount we scrolled - chunk.node.data.size.rows = @intCast(scroll_amount); + page.size.rows = @intCast(scroll_amount); erased += chunk.end; } @@ -4109,8 +4372,8 @@ pub fn pinIsValid(self: *const PageList, p: Pin) bool { var it = self.pages.first; while (it) |node| : (it = node.next) { if (node != p.node) continue; - return p.y < node.data.size.rows and - p.x < node.data.size.cols; + return p.y < node.rows() and + p.x < node.cols(); } return false; @@ -4164,7 +4427,7 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point if (tl.y > p.y) return null; coord.y = p.y - tl.y; } else { - coord.y += tl.node.data.size.rows - tl.y; + coord.y += tl.node.rows() - tl.y; var node_ = tl.node.next; while (node_) |node| : (node_ = node.next) { if (node == p.node) { @@ -4172,7 +4435,7 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point break; } - coord.y += node.data.size.rows; + coord.y += node.rows(); } else { // We never saw our node, meaning we're outside the range. return null; @@ -4194,7 +4457,7 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point /// Warning: this is slow and should not be used in performance critical paths pub fn getCell(self: *const PageList, pt: point.Point) ?Cell { const pt_pin = self.pin(pt) orelse return null; - const rac = pt_pin.node.data.getRowAndCell(pt_pin.x, pt_pin.y); + const rac = pt_pin.node.page().getRowAndCell(pt_pin.x, pt_pin.y); return .{ .node = pt_pin.node, .row = rac.row, @@ -4237,13 +4500,13 @@ pub fn diagram( var it = self.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| : (page_index += 1) { - cols = chunk.node.data.size.cols; + cols = chunk.node.cols(); // Whether we've just skipped some number of rows and drawn // an ellipsis row (this is reset when a row is not skipped). var skipped = false; - for (0..chunk.node.data.size.rows) |y| { + for (0..chunk.node.rows()) |y| { // Active header if (!active and chunk.node == active_pin.node and @@ -4268,8 +4531,8 @@ pub fn diagram( // Row contents { - const row = chunk.node.data.getRow(y); - const cells = chunk.node.data.getCells(row)[0..cols]; + const row = chunk.node.page().getRow(y); + const cells = chunk.node.page().getCells(row)[0..cols]; var row_has_content = false; @@ -4318,7 +4581,7 @@ pub fn diagram( } try writer.print("{u}", .{cell.codepoint()}); if (cell.hasGrapheme()) { - const grapheme = chunk.node.data.lookupGrapheme(cell).?; + const grapheme = chunk.node.page().lookupGrapheme(cell).?; for (grapheme) |cp| { try writer.print("{u}", .{cp}); } @@ -4446,7 +4709,7 @@ pub fn highlightSemanticContent( // two prompts here. if (it.next()) |next| next: { var prev = next.up(1) orelse break :next; - prev.x = prev.node.data.size.cols - 1; + prev.x = prev.node.cols() - 1; break :end prev; } @@ -4737,7 +5000,7 @@ pub const CellIterator = struct { switch (self.row_it.page_it.direction) { .right_down => { - if (cell.x + 1 < cell.node.data.size.cols) { + if (cell.x + 1 < cell.node.cols()) { // We still have cells in this row, increase x. var copy = cell; copy.x += 1; @@ -4758,7 +5021,7 @@ pub const CellIterator = struct { // We need to move to the previous row and last col if (self.row_it.next()) |next_cell| { var copy = next_cell; - copy.x = next_cell.node.data.size.cols - 1; + copy.x = next_cell.node.cols() - 1; self.cell = copy; } else { self.cell = null; @@ -4891,13 +5154,13 @@ pub const PageIterator = struct { break :none .{ .node = row.node, .start = row.y, - .end = row.node.data.size.rows, + .end = row.node.rows(), }; }, .count => |*limit| count: { assert(limit.* > 0); // should be handled already - const len = @min(row.node.data.size.rows - row.y, limit.*); + const len = @min(row.node.rows() - row.y, limit.*); if (len > limit.*) { self.row = row.down(len); limit.* -= len; @@ -4924,7 +5187,7 @@ pub const PageIterator = struct { break :row .{ .node = row.node, .start = row.y, - .end = row.node.data.size.rows, + .end = row.node.rows(), }; } @@ -4953,7 +5216,7 @@ pub const PageIterator = struct { const next_page = row.node.prev orelse break :next null; break :next .{ .node = next_page, - .y = next_page.data.size.rows - 1, + .y = next_page.rows() - 1, }; }; @@ -4989,7 +5252,7 @@ pub const PageIterator = struct { const next_page = row.node.prev orelse break :next null; break :next .{ .node = next_page, - .y = next_page.data.size.rows - 1, + .y = next_page.rows() - 1, }; }; @@ -5023,13 +5286,14 @@ pub const PageIterator = struct { end: size.CellCountInt, pub fn rows(self: Chunk) []Row { - const rows_ptr = self.node.data.rows.ptr(self.node.data.memory); + const page = self.node.page(); + const rows_ptr = page.rows.ptr(page.memory); return rows_ptr[self.start..self.end]; } /// Returns true if this chunk represents every row in the page. pub fn fullPage(self: Chunk) bool { - return self.start == 0 and self.end == self.node.data.size.rows; + return self.start == 0 and self.end == self.node.rows(); } /// Returns true if this chunk overlaps with the given other chunk @@ -5101,12 +5365,12 @@ pub fn getTopLeft(self: *const PageList, tag: point.Tag) Pin { var rem = self.rows; var it = self.pages.last; while (it) |node| : (it = node.prev) { - if (rem <= node.data.size.rows) break :active .{ + if (rem <= node.rows()) break :active .{ .node = node, - .y = node.data.size.rows - rem, + .y = node.rows() - rem, }; - rem -= node.data.size.rows; + rem -= node.rows(); } unreachable; // assertion: we always have enough rows for active @@ -5123,22 +5387,22 @@ pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin { const node = self.pages.last.?; break :last .{ .node = node, - .y = node.data.size.rows - 1, - .x = node.data.size.cols - 1, + .y = node.rows() - 1, + .x = node.cols() - 1, }; }, .viewport => viewport: { var br = self.getTopLeft(.viewport); br = br.down(self.rows - 1).?; - br.x = br.node.data.size.cols - 1; + br.x = br.node.cols() - 1; break :viewport br; }, .history => active: { var br = self.getTopLeft(.active); br = br.up(1) orelse return null; - br.x = br.node.data.size.cols - 1; + br.x = br.node.cols() - 1; break :active br; }, }; @@ -5153,7 +5417,7 @@ fn totalRows(self: *const PageList) usize { var rows: usize = 0; var node_ = self.pages.first; while (node_) |node| { - rows += node.data.size.rows; + rows += node.rows(); node_ = node.next; } @@ -5184,8 +5448,9 @@ fn growRows(self: *PageList, n: usize) Allocator.Error!void { pub fn clearDirty(self: *PageList) void { var page = self.pages.first; while (page) |p| : (page = p.next) { - p.data.dirty = false; - for (p.data.rows.ptr(p.data.memory)[0..p.data.size.rows]) |*row| { + const current_page = p.page(); + current_page.dirty = false; + for (current_page.rows.ptr(current_page.memory)[0..p.rows()]) |*row| { row.dirty = false; } } @@ -5232,7 +5497,7 @@ pub const Pin = struct { row: *pagepkg.Row, cell: *pagepkg.Cell, } { - const rac = self.node.data.getRowAndCell(self.x, self.y); + const rac = self.node.page().getRowAndCell(self.x, self.y); return .{ .row = rac.row, .cell = rac.cell }; } @@ -5242,8 +5507,9 @@ pub const Pin = struct { /// what subset of the cells are returned. The "left/right" subsets are /// inclusive of the x coordinate of the pin. pub inline fn cells(self: Pin, subset: CellSubset) []pagepkg.Cell { - const rac = self.rowAndCell(); - const all = self.node.data.getCells(rac.row); + const page = self.node.page(); + const rac = page.getRowAndCell(self.x, self.y); + const all = page.getCells(rac.row); return switch (subset) { .all => all, .left => all[0 .. self.x + 1], @@ -5254,21 +5520,23 @@ pub const Pin = struct { /// Returns the grapheme codepoints for the given cell. These are only /// the EXTRA codepoints and not the first codepoint. pub inline fn grapheme(self: Pin, cell: *const pagepkg.Cell) ?[]u21 { - return self.node.data.lookupGrapheme(cell); + return self.node.page().lookupGrapheme(cell); } /// Returns the style for the given cell in this pin. pub inline fn style(self: Pin, cell: *const pagepkg.Cell) stylepkg.Style { if (cell.style_id == stylepkg.default_id) return .{}; - return self.node.data.styles.get( - self.node.data.memory, + const page = self.node.page(); + return page.styles.get( + page.memory, cell.style_id, ).*; } /// Check if this pin is dirty. pub inline fn isDirty(self: Pin) bool { - return self.node.data.dirty or self.rowAndCell().row.dirty; + const page = self.node.page(); + return page.dirty or page.getRowAndCell(self.x, self.y).row.dirty; } /// Mark this pin location as dirty. @@ -5449,7 +5717,7 @@ pub const Pin = struct { /// Move the pin right n columns. n must fit within the size. pub inline fn right(self: Pin, n: usize) Pin { - assert(self.x + n < self.node.data.size.cols); + assert(self.x + n < self.node.cols()); var result = self; result.x +|= std.math.cast(size.CellCountInt, n) orelse std.math.maxInt(size.CellCountInt); @@ -5466,7 +5734,7 @@ pub const Pin = struct { /// Move the pin right n columns, stopping at the end of the row. pub inline fn rightClamp(self: Pin, n: size.CellCountInt) Pin { var result = self; - result.x = @min(self.x +| n, self.node.data.size.cols - 1); + result.x = @min(self.x +| n, self.node.cols() - 1); return result; } @@ -5478,7 +5746,7 @@ pub const Pin = struct { pub fn leftWrap(self: Pin, n: usize) ?Pin { // NOTE: This assumes that all pages have the same width, which may // be violated under certain circumstances by incomplete reflow. - const cols = self.node.data.size.cols; + const cols = self.node.cols(); const remaining_in_row = self.x; if (n <= remaining_in_row) return self.left(n); @@ -5505,7 +5773,7 @@ pub const Pin = struct { pub fn rightWrap(self: Pin, n: usize) ?Pin { // NOTE: This assumes that all pages have the same width, which may // be violated under certain circumstances by incomplete reflow. - const cols = self.node.data.size.cols; + const cols = self.node.cols(); const remaining_in_row = cols - self.x - 1; if (n <= remaining_in_row) return self.right(n); @@ -5552,7 +5820,7 @@ pub const Pin = struct { }, } { // Index fits within this page - const rows = self.node.data.size.rows - (self.y + 1); + const rows = self.node.rows() - (self.y + 1); if (n <= rows) return .{ .offset = .{ .node = self.node, .y = std.math.cast(size.CellCountInt, self.y + n) orelse @@ -5567,18 +5835,18 @@ pub const Pin = struct { node = node.next orelse return .{ .overflow = .{ .end = .{ .node = node, - .y = node.data.size.rows - 1, + .y = node.rows() - 1, .x = self.x, }, .remaining = n_left, } }; - if (n_left <= node.data.size.rows) return .{ .offset = .{ + if (n_left <= node.rows()) return .{ .offset = .{ .node = node, .y = std.math.cast(size.CellCountInt, n_left - 1) orelse std.math.maxInt(size.CellCountInt), .x = self.x, } }; - n_left -= node.data.size.rows; + n_left -= node.rows(); } } @@ -5607,13 +5875,13 @@ pub const Pin = struct { .end = .{ .node = node, .y = 0, .x = self.x }, .remaining = n_left, } }; - if (n_left <= node.data.size.rows) return .{ .offset = .{ + if (n_left <= node.rows()) return .{ .offset = .{ .node = node, - .y = std.math.cast(size.CellCountInt, node.data.size.rows - n_left) orelse + .y = std.math.cast(size.CellCountInt, node.rows() - n_left) orelse std.math.maxInt(size.CellCountInt), .x = self.x, } }; - n_left -= node.data.size.rows; + n_left -= node.rows(); } } }; @@ -5630,7 +5898,7 @@ pub const Cell = struct { /// This is not very performant this is primarily used for assertions /// and testing. pub fn isDirty(self: Cell) bool { - return self.node.data.dirty or self.row.dirty; + return self.node.page().dirty or self.row.dirty; } /// Get the cell style. @@ -5638,8 +5906,9 @@ pub const Cell = struct { /// Not meant for non-test usage since this is inefficient. pub fn style(self: Cell) stylepkg.Style { if (self.cell.style_id == stylepkg.default_id) return .{}; - return self.node.data.styles.get( - self.node.data.memory, + const page = self.node.page(); + return page.styles.get( + page.memory, self.cell.style_id, ).*; } @@ -5654,7 +5923,7 @@ pub const Cell = struct { var y: size.CellCountInt = self.row_idx; var node_ = self.node; while (node_.prev) |node| { - y += node.data.size.rows; + y += node.rows(); node_ = node; } @@ -5665,6 +5934,176 @@ pub const Cell = struct { } }; +test "PageList compression restores through page access" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const page = node.page(); + page.dirty = true; + page.getRowAndCell(3, 2).cell.* = .init('X'); + + const expected = try alloc.dupe(u8, page.memory); + defer alloc.free(expected); + const memory_ptr = page.memory.ptr; + const memory_len = page.memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 24), node.rows()); + try testing.expectEqual(@as(size.CellCountInt, 80), node.cols()); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + try testing.expectEqual(page_size, s.page_size); + + // Pin access restores the page without changing its retained mapping. + const page_pin: Pin = .{ .node = node, .x = 3, .y = 2 }; + try testing.expectEqual( + @as(u21, 'X'), + page_pin.rowAndCell().cell.content.codepoint, + ); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(node.page().dirty); + + // Recompressing exercises reuse of the page-pool scratch item. Page + // iterator chunks also restore before exposing row memory. + try testing.expect(s.compressPage(node)); + var page_it = (Pin{ .node = node }).pageIterator(.right_down, null); + const chunk = page_it.next().?; + try testing.expectEqual(node.rows(), chunk.rows().len); + try testing.expect(!node.isCompressed()); + try testing.expectEqualSlices(u8, expected, node.page().memory); + + // Read-only PageList operations restore through the same boundary. + try testing.expect(s.compressPage(node)); + var cloned = try s.clone(alloc, .{ + .top = .{ .screen = .{} }, + }); + defer cloned.deinit(); + try testing.expect(!node.isCompressed()); + try testing.expectEqual( + @as(u21, 'X'), + cloned.pages.first.?.page().getRowAndCell(3, 2).cell.content.codepoint, + ); +} + +test "PageList compression uses temporary scratch for oversized pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + var node = s.pages.first.?; + while (node.page().memory.len <= std_size) { + node = try s.increaseCapacity(node, .grapheme_bytes); + } + + const expected = try alloc.dupe(u8, node.page().memory); + defer alloc.free(expected); + const memory_ptr = node.page().memory.ptr; + const memory_len = node.page().memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); +} + +test "PageList compression leaves incompressible pages resident" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const original = try alloc.dupe(u8, node.page().memory); + defer alloc.free(original); + defer @memcpy(node.page().memory, original); + + var prng = std.Random.DefaultPrng.init(0x5041_4745_4C49_5354); + prng.random().bytes(node.page().memory); + const page_size = s.page_size; + + try testing.expect(!s.compressPage(node)); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList reset discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.reset(); + try testing.expect(!s.pages.first.?.isCompressed()); + try testing.expectEqual(@as(usize, 1), s.totalPages()); +} + +test "PageList deinit discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.deinit(); +} + +test "PageList prune reuses malformed compressed page memory" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + // Allocate the second page so the first one can be pruned and reused. + while (s.pages.first == s.pages.last) _ = try s.grow(); + const first = s.pages.first.?; + try testing.expect(s.compressPage(first)); + @memset(first.data.compressed.encoded, 0xFF); + + var reused = false; + const growth_limit = @as(usize, s.pages.last.?.capacity().rows) + 1; + for (0..growth_limit) |_| { + if (try s.grow()) |new_node| { + if (new_node == first) { + reused = true; + break; + } + } + } + + try testing.expect(reused); + try testing.expectEqual(first, s.pages.last.?); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 1), first.rows()); + first.page().assertIntegrity(); +} + test "PageList" { const testing = std.testing; const alloc = testing.allocator; @@ -5805,7 +6244,7 @@ test "PageList init more than max cols" { // We expect a single, non-standard page try testing.expect(s.pages.first != null); - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // Initial total rows should be our row count try testing.expectEqual(s.rows, s.total_rows); @@ -5889,17 +6328,17 @@ test "PageList pointFromPin active from prior page" { var s = try init(alloc, 80, 24, null); defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); { try testing.expectEqual(point.Point{ @@ -5932,17 +6371,17 @@ test "PageList pointFromPin traverse pages" { defer s.deinit(); // Grow so we take up at least 2 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 2) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); { const pages = s.totalPages(); @@ -6032,8 +6471,8 @@ test "PageList grow allows exceeding max size for active area" { { var it = s.pages.first; while (it) |page| : (it = page.next) { - page.data.size.rows = 1; - page.data.capacity.rows = 1; + page.page().size.rows = 1; + page.page().capacity.rows = 1; } // Avoid integrity check failures @@ -6062,7 +6501,7 @@ test "PageList grow prune required with a single page" { // This is important because it triggers a scenario where our calculated // minSize() which is supposed to accommodate 2 pages is no longer true. while (true) { - const layout = Page.layout(s.pages.first.?.data.capacity); + const layout = Page.layout(s.pages.first.?.capacity()); if (layout.total_size > std_size) break; _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); } @@ -6075,7 +6514,7 @@ test "PageList grow prune required with a single page" { // page. const rem = rem: { const page = s.pages.first.?; - break :rem page.data.capacity.rows - page.data.size.rows; + break :rem page.capacity().rows - page.rows(); }; for (0..rem) |_| try testing.expect(try s.grow() == null); @@ -6965,7 +7404,7 @@ test "PageList: jump zero prompts" { defer s.deinit(); try s.growRows(3); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -6993,7 +7432,7 @@ test "Screen: jump back one prompt" { defer s.deinit(); try s.growRows(3); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -7111,7 +7550,7 @@ test "PageList grow fit in capacity" { defer s.deinit(); // So we know we're using capacity to grow - const last = &s.pages.last.?.data; + const last = s.pages.last.?.page(); try testing.expect(last.size.rows < last.capacity.rows); // Grow @@ -7134,7 +7573,7 @@ test "PageList grow allocate" { // Grow to capacity const last_node = s.pages.last.?; - const last = &s.pages.last.?.data; + const last = s.pages.last.?.page(); for (0..last.capacity.rows - last.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7163,14 +7602,14 @@ test "PageList grow prune scrollback" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } // Grow and allocate one more page. Then fill that page up. const page2_node = (try s.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7232,14 +7671,14 @@ test "PageList grow prune scrollback with viewport pin not in pruned page" { // Grow to capacity of first page const page1_node = s.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } // Grow and allocate second page, then fill it up const page2_node = (try s.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try s.grow() == null); } @@ -7290,7 +7729,7 @@ test "PageList eraseRows invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7330,7 +7769,7 @@ test "PageList eraseRow invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7369,7 +7808,7 @@ test "PageList eraseRowBounded invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7409,7 +7848,7 @@ test "PageList eraseRowBounded multi-page invalidates viewport offset cache" { defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7450,7 +7889,7 @@ test "PageList eraseRowBounded full page shift invalidates viewport offset cache defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 4) |_| { @@ -7493,7 +7932,7 @@ test "PageList eraseRowBounded exhausts pages invalidates viewport offset cache" defer s.deinit(); // Grow so we take up several pages worth of history - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); { var cur_page = s.pages.last.?; for (0..page.capacity.rows * 3) |_| { @@ -7537,11 +7976,11 @@ test "PageList increaseCapacity to increase styles" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_styles_cap = s.pages.first.?.data.capacity.styles; + const original_styles_cap = s.pages.first.?.capacity().styles; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write all our data so we can assert its the same after for (0..s.rows) |y| { @@ -7560,7 +7999,7 @@ test "PageList increaseCapacity to increase styles" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Verify capacity doubled try testing.expectEqual( @@ -7588,11 +8027,11 @@ test "PageList increaseCapacity to increase graphemes" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.grapheme_bytes; + const original_cap = s.pages.first.?.capacity().grapheme_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7609,7 +8048,7 @@ test "PageList increaseCapacity to increase graphemes" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.grapheme_bytes); @@ -7632,11 +8071,11 @@ test "PageList increaseCapacity to increase hyperlinks" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.hyperlink_bytes; + const original_cap = s.pages.first.?.capacity().hyperlink_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7653,7 +8092,7 @@ test "PageList increaseCapacity to increase hyperlinks" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.hyperlink_bytes); @@ -7676,11 +8115,11 @@ test "PageList increaseCapacity to increase string_bytes" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); - const original_cap = s.pages.first.?.data.capacity.string_bytes; + const original_cap = s.pages.first.?.capacity().string_bytes; { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { @@ -7697,7 +8136,7 @@ test "PageList increaseCapacity to increase string_bytes" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(original_cap * 2, page.capacity.string_bytes); @@ -7752,7 +8191,7 @@ test "PageList increaseCapacity returns OutOfSpace at max capacity" { ) catch |err| { // Before OutOfSpace, we should have reached maxInt try testing.expectEqual(error.OutOfSpace, err); - try testing.expectEqual(max_styles, s.pages.first.?.data.capacity.styles); + try testing.expectEqual(max_styles, s.pages.first.?.capacity().styles); break; }; } @@ -7770,7 +8209,7 @@ test "PageList increaseCapacity after col shrink" { try testing.expectEqual(5, s.cols); { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expectEqual(5, page.size.cols); try testing.expect(page.capacity.cols >= 10); } @@ -7779,7 +8218,7 @@ test "PageList increaseCapacity after col shrink" { _ = try s.increaseCapacity(s.pages.first.?, .styles); { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // size.cols should still be 5, not reverted to capacity.cols try testing.expectEqual(5, page.size.cols); try testing.expectEqual(5, s.cols); @@ -7795,19 +8234,19 @@ test "PageList increaseCapacity multi-page" { // Grow to create a second page const page1_node = s.pages.last.?; - page1_node.data.pauseIntegrityChecks(true); - for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + page1_node.page().pauseIntegrityChecks(true); + for (0..page1_node.capacity().rows - page1_node.rows()) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Now we have two pages try testing.expect(s.pages.first != s.pages.last); const page2_node = s.pages.last.?; - const page1_styles_cap = s.pages.first.?.data.capacity.styles; - const page2_styles_cap = page2_node.data.capacity.styles; + const page1_styles_cap = s.pages.first.?.capacity().styles; + const page2_styles_cap = page2_node.capacity().styles; // Increase capacity on the first page only _ = try s.increaseCapacity(s.pages.first.?, .styles); @@ -7815,13 +8254,13 @@ test "PageList increaseCapacity multi-page" { // First page capacity should be doubled try testing.expectEqual( page1_styles_cap * 2, - s.pages.first.?.data.capacity.styles, + s.pages.first.?.capacity().styles, ); // Second page should be unchanged try testing.expectEqual( page2_styles_cap, - s.pages.last.?.data.capacity.styles, + s.pages.last.?.capacity().styles, ); } @@ -7833,7 +8272,7 @@ test "PageList increaseCapacity preserves dirty flag" { defer s.deinit(); // Set page dirty flag and mark some rows as dirty - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.dirty = true; const rows = page.rows.ptr(page.memory); @@ -7846,10 +8285,10 @@ test "PageList increaseCapacity preserves dirty flag" { const new_node = try s.increaseCapacity(s.pages.first.?, .styles); // The page dirty flag should be preserved - try testing.expect(new_node.data.dirty); + try testing.expect(new_node.page().dirty); // Row dirty flags should be preserved - const new_rows = new_node.data.rows.ptr(new_node.data.memory); + const new_rows = new_node.page().rows.ptr(new_node.page().memory); try testing.expect(new_rows[0].dirty); try testing.expect(!new_rows[1].dirty); try testing.expect(new_rows[2].dirty); @@ -7888,12 +8327,12 @@ test "PageList pageIterator two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7901,9 +8340,9 @@ test "PageList pageIterator two pages" { { const chunk = it.next().?; try testing.expect(chunk.node == s.pages.first.?); - const start = chunk.node.data.size.rows - s.rows + 1; + const start = chunk.node.rows() - s.rows + 1; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(chunk.node.data.size.rows, chunk.end); + try testing.expectEqual(chunk.node.rows(), chunk.end); } { const chunk = it.next().?; @@ -7924,12 +8363,12 @@ test "PageList pageIterator history two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7977,12 +8416,12 @@ test "PageList pageIterator reverse two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -7999,9 +8438,9 @@ test "PageList pageIterator reverse two pages" { { const chunk = it.next().?; try testing.expect(chunk.node == s.pages.first.?); - const start = chunk.node.data.size.rows - s.rows + 1; + const start = chunk.node.rows() - s.rows + 1; try testing.expectEqual(start, chunk.start); - try testing.expectEqual(chunk.node.data.size.rows, chunk.end); + try testing.expectEqual(chunk.node.rows(), chunk.end); count += chunk.end - chunk.start; } try testing.expect(it.next() == null); @@ -8017,12 +8456,12 @@ test "PageList pageIterator reverse history two pages" { // Grow to capacity const page1_node = s.pages.last.?; - const page1 = page1_node.data; - page1_node.data.pauseIntegrityChecks(true); + const page1 = page1_node.page(); + page1_node.page().pauseIntegrityChecks(true); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try s.grow() == null); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); try testing.expect(try s.grow() != null); // Iterate the active area @@ -8045,7 +8484,7 @@ test "PageList cellIterator" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -8095,7 +8534,7 @@ test "PageList cellIterator reverse" { var s = try init(alloc, 2, 2, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -8145,7 +8584,7 @@ test "PageList promptIterator left_up" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Normal prompt { const rac = page.getRowAndCell(0, 3); @@ -8202,7 +8641,7 @@ test "PageList promptIterator right_down" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Normal prompt { const rac = page.getRowAndCell(0, 3); @@ -8259,7 +8698,7 @@ test "PageList promptIterator right_down continuation at start" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback) { @@ -8302,7 +8741,7 @@ test "PageList promptIterator right_down with prompt before continuation" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 2, continuation on rows 3-4 // Starting iteration from row 3 should still find the prompt at row 2 @@ -8341,7 +8780,7 @@ test "PageList promptIterator right_down limit inclusive" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8373,7 +8812,7 @@ test "PageList promptIterator left_up limit inclusive" { var s = try init(alloc, 2, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8406,7 +8845,7 @@ test "PageList highlightSemanticContent prompt" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8460,7 +8899,7 @@ test "PageList highlightSemanticContent prompt with output" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8525,7 +8964,7 @@ test "PageList highlightSemanticContent prompt multiline" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt starts on row 5 { @@ -8581,7 +9020,7 @@ test "PageList highlightSemanticContent prompt only" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt content (no input) { @@ -8625,7 +9064,7 @@ test "PageList highlightSemanticContent prompt to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -8673,7 +9112,7 @@ test "PageList highlightSemanticContent input basic" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8728,7 +9167,7 @@ test "PageList highlightSemanticContent input with output" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8793,7 +9232,7 @@ test "PageList highlightSemanticContent input multiline with continuation" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -8870,7 +9309,7 @@ test "PageList highlightSemanticContent input no input returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt, then immediately output { @@ -8918,7 +9357,7 @@ test "PageList highlightSemanticContent input to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -8966,7 +9405,7 @@ test "PageList highlightSemanticContent input prompt only returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt content, no input or output { @@ -9013,7 +9452,7 @@ test "PageList highlightSemanticContent output basic" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -9084,7 +9523,7 @@ test "PageList highlightSemanticContent output multiline" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -9175,7 +9614,7 @@ test "PageList highlightSemanticContent output stops at next prompt" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 { @@ -9260,7 +9699,7 @@ test "PageList highlightSemanticContent output to end of screen" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Single prompt on row 15, no following prompt { @@ -9332,7 +9771,7 @@ test "PageList highlightSemanticContent output no output returns null" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 with only prompt and input, no output { @@ -9392,7 +9831,7 @@ test "PageList highlightSemanticContent output skips empty cells" { var s = try init(alloc, 10, 20, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Prompt on row 5 - only fills first 3 cells, rest are empty with default .output { @@ -9471,17 +9910,17 @@ test "PageList erase" { try testing.expectEqual(@as(usize, 1), s.totalPages()); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); try testing.expectEqual(@as(usize, 6), s.totalPages()); // Our total rows should be large @@ -9505,17 +9944,17 @@ test "PageList erase reaccounts page size" { const start_size = s.page_size; // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); try testing.expect(s.page_size > start_size); // Erase the entire history, we should be back to just our active set. @@ -9531,17 +9970,17 @@ test "PageList erase row with tracked pin resets to top-left" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Our total rows should be large try testing.expect(s.total_rows > s.rows); @@ -9610,17 +10049,17 @@ test "PageList erase resets viewport to active if moves within active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); @@ -9639,17 +10078,17 @@ test "PageList erase resets viewport if inside erased page but not active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) }); @@ -9668,17 +10107,17 @@ test "PageList erase resets viewport to active if top is inside active" { defer s.deinit(); // Grow so we take up at least 5 pages. - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); var cur_page = s.pages.last.?; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); for (0..page.capacity.rows * 5) |_| { if (try s.grow()) |new_page| { - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); cur_page = new_page; - cur_page.data.pauseIntegrityChecks(true); + cur_page.page().pauseIntegrityChecks(true); } } - cur_page.data.pauseIntegrityChecks(false); + cur_page.page().pauseIntegrityChecks(false); // Move our viewport to the top s.scroll(.{ .top = {} }); @@ -9708,7 +10147,7 @@ test "PageList erase a one-row active" { try testing.expectEqual(@as(usize, 1), s.totalPages()); // Write our letter - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { const rac = page.getRowAndCell(0, y); rac.cell.* = .{ @@ -9831,13 +10270,13 @@ test "PageList eraseRowBounded full rows two pages" { // Grow to two pages so our active area straddles { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); page.pauseIntegrityChecks(true); for (0..page.capacity.rows - page.size.rows) |_| _ = try s.grow(); page.pauseIntegrityChecks(false); try s.growRows(5); try testing.expectEqual(@as(usize, 2), s.totalPages()); - try testing.expectEqual(@as(usize, 5), s.pages.last.?.data.size.rows); + try testing.expectEqual(@as(usize, 5), s.pages.last.?.rows()); } // Pins @@ -9852,11 +10291,11 @@ test "PageList eraseRowBounded full rows two pages" { { try testing.expectEqual(s.pages.last.?.prev.?, p_first.node); - try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 1), p_first.y); + try testing.expectEqual(@as(usize, p_first.node.rows() - 1), p_first.y); try testing.expectEqual(@as(usize, 0), p_first.x); try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node); - try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y); + try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y); try testing.expectEqual(@as(usize, 0), p_first_out.x); try testing.expectEqual(s.pages.last.?, p_in.node); @@ -9879,12 +10318,12 @@ test "PageList eraseRowBounded full rows two pages" { // In page in first page is shifted try testing.expectEqual(s.pages.last.?.prev.?, p_first.node); - try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 2), p_first.y); + try testing.expectEqual(@as(usize, p_first.node.rows() - 2), p_first.y); try testing.expectEqual(@as(usize, 0), p_first.x); // Out page in first page should not be shifted try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node); - try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y); + try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y); try testing.expectEqual(@as(usize, 0), p_first_out.x); // In page is shifted @@ -9958,7 +10397,7 @@ test "PageList clone partial trimmed left reclaims styles" { // Style the rows we're trimming { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; const style_id = try page.styles.add(page.memory, style); @@ -9990,7 +10429,7 @@ test "PageList clone partial trimmed left reclaims styles" { { try testing.expect(s2.pages.first == s2.pages.last); - const page = &s2.pages.first.?.data; + const page = s2.pages.first.?.page(); try testing.expectEqual(0, page.styles.count()); } } @@ -10188,7 +10627,7 @@ test "PageList resize (no reflow) less rows" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10222,7 +10661,7 @@ test "PageList resize (no reflow) one rows" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10256,7 +10695,7 @@ test "PageList resize (no reflow) less rows cursor on bottom" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10308,7 +10747,7 @@ test "PageList resize (no reflow) less rows cursor in scrollback" { // This is required for our writing below to work try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write into all rows so we don't get trim behavior for (0..s.rows) |y| { @@ -10359,7 +10798,7 @@ test "PageList resize (no reflow) less rows trims blank lines" { var s = try init(alloc, 10, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10418,7 +10857,7 @@ test "PageList resize (no reflow) less rows trims blank lines cursor in blank li var s = try init(alloc, 10, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10461,7 +10900,7 @@ test "PageList resize (no reflow) less rows trims blank lines erases pages" { var s = try init(alloc, 100, 5, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Resize to take up two pages { @@ -10494,7 +10933,7 @@ test "PageList resize (no reflow) more rows extends blank lines" { var s = try init(alloc, 10, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write codepoint into first line { @@ -10574,7 +11013,7 @@ test "PageList resize (no reflow) less cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10598,7 +11037,7 @@ test "PageList resize (no reflow) less cols pin in trimmed cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } @@ -10616,7 +11055,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { defer s.deinit(); // Add a grapheme. - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(9, 0); rac.cell.* = .{ @@ -10634,7 +11073,7 @@ test "PageList resize (no reflow) less cols clears graphemes" { var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - try testing.expectEqual(@as(usize, 0), chunk.node.data.graphemeCount()); + try testing.expectEqual(@as(usize, 0), chunk.node.page().graphemeCount()); } } @@ -10653,7 +11092,7 @@ test "PageList resize (no reflow) more cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } } @@ -10666,7 +11105,7 @@ test "PageList resize (no reflow) more cols with spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -10709,7 +11148,7 @@ test "PageList resize (no reflow) more cols with spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -10749,7 +11188,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" { // Place a spacer_head at the last column (col 4) on two rows // to simulate a wide character that didn't fit at the right edge. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Row 0: 'x' at col 0..3, spacer_head at col 4, wrap = true { @@ -10788,7 +11227,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" { // Verify the old spacer_head positions are now narrow. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(4, 0); try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide); @@ -10844,14 +11283,14 @@ test "PageList resize (no reflow) more cols forces less rows per page" { var it = s.pages.first; while (it) |page| : (it = page.next) { if (page == s.pages.last.?) break; - try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + try testing.expectEqual(page.capacity().rows, page.rows()); } } // Now we need to resize again to a col size that further shrinks // our last capacity. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); try testing.expect(page.size.rows == page.capacity.rows); const new_cols = new_cols: { var new_cols = page.size.cols + 50; @@ -10874,7 +11313,7 @@ test "PageList resize (no reflow) more cols forces less rows per page" { var it = s.pages.first; while (it) |page| : (it = page.next) { if (page == s.pages.last.?) break; - try testing.expectEqual(page.data.capacity.rows, page.data.size.rows); + try testing.expectEqual(page.capacity().rows, page.rows()); } } } @@ -10898,7 +11337,7 @@ test "PageList resize (no reflow) less cols then more cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10918,7 +11357,7 @@ test "PageList resize (no reflow) less rows and cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -10968,7 +11407,7 @@ test "PageList resize less rows and cols cursor near top pushed to scrollback" { var it = s.rowIterator(.right_down, .{ .active = .{} }, null); while (it.next()) |p| { const rac = p.rowAndCell(); - const cells = p.node.data.getCells(rac.row); + const cells = p.node.page().getCells(rac.row); for (cells, 0..) |*cell, x| cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = @intCast('A' + (x % 26)) }, @@ -11022,7 +11461,7 @@ test "PageList resize (no reflow) more rows and less cols" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); } } @@ -11062,7 +11501,7 @@ test "PageList resize (no reflow) empty screen" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); } } @@ -11081,7 +11520,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { var s = try init(alloc, cap.cols, cap.rows, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -11101,7 +11540,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, cap2.cols), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } @@ -11117,7 +11556,7 @@ test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom" // Grow to 5 total rows, simulating 3 active + 2 scrollback try s.growRows(2); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.totalRows()) |y| { const rac = page.getRowAndCell(0, y); rac.cell.* = .{ @@ -11187,7 +11626,7 @@ test "PageList resize reflow more cols no wrapped rows" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -11206,7 +11645,7 @@ test "PageList resize reflow more cols no wrapped rows" { var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |offset| { const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 10), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); } @@ -11219,7 +11658,7 @@ test "PageList resize reflow more cols wrapped rows" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11257,7 +11696,7 @@ test "PageList resize reflow more cols wrapped rows" { // First row should be unwrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 4), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -11273,7 +11712,7 @@ test "PageList resize reflow invalidates viewport offset cache" { defer s.deinit(); try s.growRows(20); - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11335,7 +11774,7 @@ test "PageList resize reflow more cols creates multiple pages" { // Wrap every other row so every line is wrapped for reflow { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { if (y % 2 == 0) { const rac = page.getRowAndCell(0, y); @@ -11367,8 +11806,8 @@ test "PageList resize reflow more cols creates multiple pages" { count += 1; // All pages should have the new capacity - try testing.expectEqual(newcap.cols, page.data.capacity.cols); - try testing.expectEqual(newcap.rows, page.data.capacity.rows); + try testing.expectEqual(newcap.cols, page.capacity().cols); + try testing.expectEqual(newcap.rows, page.capacity().rows); } // We should have more than one page, meaning we created at least @@ -11388,7 +11827,7 @@ test "PageList resize reflow more cols wrap across page boundary" { // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11402,7 +11841,7 @@ test "PageList resize reflow more cols wrap across page boundary" { // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11417,7 +11856,7 @@ test "PageList resize reflow more cols wrap across page boundary" { } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11519,7 +11958,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11533,7 +11972,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11548,7 +11987,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11605,7 +12044,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon // Grow to the capacity of the first page. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -11619,7 +12058,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon // At this point, we have some rows on the first page, and some on the second. // We can now wrap across the boundary condition. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const y = page.size.rows - 1; { const rac = page.getRowAndCell(0, y); @@ -11634,7 +12073,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon } } { - const page2 = &s.pages.last.?.data; + const page2 = s.pages.last.?.page(); const y = 0; { const rac = page2.getRowAndCell(0, y); @@ -11781,7 +12220,7 @@ test "PageList resize reflow more cols cursor in wrapped row" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11832,7 +12271,7 @@ test "PageList resize reflow more cols cursor in not wrapped row" { var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11883,7 +12322,7 @@ test "PageList resize reflow more cols cursor in wrapped row that isn't unwrappe var s = try init(alloc, 2, 4, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { { const rac = page.getRowAndCell(0, 0); @@ -11949,7 +12388,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; } @@ -11961,7 +12400,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 1); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -11978,7 +12417,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -12012,7 +12451,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Almost hit string alloc cap in bottom right of first page. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const id = try page.insertHyperlink(.{ .id = .{ .implicit = 0 }, .uri = "a" ** (pagepkg.string_bytes_default - 1), @@ -12036,7 +12475,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase" // Almost hit string alloc cap in top left of second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const id = try page.insertHyperlink(.{ .id = .{ .implicit = 1 }, .uri = "a" ** (pagepkg.string_bytes_default - 1), @@ -12072,7 +12511,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -12106,7 +12545,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Almost hit grapheme alloc cap in bottom right of first page. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(page.size.cols - 1, page.size.rows - 1); rac.row.wrap = true; rac.cell.* = .{ @@ -12139,7 +12578,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase" // Almost hit grapheme alloc cap in top left of second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.wrap = true; rac.cell.* = .{ @@ -12184,7 +12623,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Grow to the capacity of the first page and add // one more row so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -12202,7 +12641,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Give each cell in the final row of the first page a unique style. // Mark the final row as wrapped. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.cols) |x| { const id = page.styles.add( page.memory, @@ -12229,7 +12668,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" { // Do the same for the first row of the second page. // Mark the first row as a wrap continuation. { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); for (0..s.cols) |x| { const id = page.styles.add( page.memory, @@ -12265,7 +12704,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12309,7 +12748,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12338,7 +12777,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows" defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12398,7 +12837,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows" { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12442,7 +12881,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12485,7 +12924,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12523,7 +12962,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 1); rac.row.semantic_prompt = .prompt; @@ -12566,7 +13005,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.semantic_prompt = .prompt; } @@ -12578,7 +13017,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -12592,7 +13031,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); rac.row.semantic_prompt = .prompt; } @@ -12604,7 +13043,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const rac = page.getRowAndCell(0, 0); try testing.expect(rac.row.semantic_prompt == .prompt); } @@ -12617,7 +13056,7 @@ test "PageList resize reflow less cols no wrapped rows" { var s = try init(alloc, 10, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { const end = 4; assert(end < s.cols); @@ -12641,7 +13080,7 @@ test "PageList resize reflow less cols no wrapped rows" { var offset_copy = offset; offset_copy.x = @intCast(x); const rac = offset_copy.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(usize, 5), cells.len); try testing.expectEqual(@as(u21, @intCast(x)), cells[x].content.codepoint); } @@ -12655,7 +13094,7 @@ test "PageList resize reflow less cols wrapped rows" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12685,7 +13124,7 @@ test "PageList resize reflow less cols wrapped rows" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12693,7 +13132,7 @@ test "PageList resize reflow less cols wrapped rows" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -12702,7 +13141,7 @@ test "PageList resize reflow less cols wrapped rows" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12710,7 +13149,7 @@ test "PageList resize reflow less cols wrapped rows" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -12725,7 +13164,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12755,13 +13194,13 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { } try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); var it = s.rowIterator(.right_down, .{ .screen = .{} }, null); { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12769,7 +13208,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); @@ -12783,7 +13222,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -12791,7 +13230,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expect(rac.row.grapheme); try testing.expectEqual(@as(usize, 2), cells.len); @@ -12810,7 +13249,7 @@ test "PageList resize reflow less cols cursor in wrapped row" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12845,7 +13284,7 @@ test "PageList resize reflow less cols wraps spacer head" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12903,7 +13342,7 @@ test "PageList resize reflow less cols wraps spacer head" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -12940,7 +13379,7 @@ test "PageList resize reflow less cols cursor goes to scrollback" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -12971,7 +13410,7 @@ test "PageList resize reflow less cols cursor in unchanged row" { var s = try init(alloc, 4, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -13005,7 +13444,7 @@ test "PageList resize reflow less cols cursor in blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -13039,7 +13478,7 @@ test "PageList resize reflow less cols cursor in final blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -13073,7 +13512,7 @@ test "PageList resize reflow less cols cursor in wrapped blank cell" { var s = try init(alloc, 6, 2, null); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -13107,7 +13546,7 @@ test "PageList resize reflow less cols blank lines" { var s = try init(alloc, 4, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..1) |y| { for (0..4) |x| { const rac = page.getRowAndCell(x, y); @@ -13128,7 +13567,7 @@ test "PageList resize reflow less cols blank lines" { // First row should be wrapped const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -13136,7 +13575,7 @@ test "PageList resize reflow less cols blank lines" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -13150,7 +13589,7 @@ test "PageList resize reflow less cols blank lines between" { var s = try init(alloc, 4, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { for (0..4) |x| { const rac = page.getRowAndCell(x, 0); @@ -13184,7 +13623,7 @@ test "PageList resize reflow less cols blank lines between" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); @@ -13192,7 +13631,7 @@ test "PageList resize reflow less cols blank lines between" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint); @@ -13206,7 +13645,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" { var s = try init(alloc, 5, 3, 0); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); rac.cell.* = .{ @@ -13231,7 +13670,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint); @@ -13239,13 +13678,13 @@ test "PageList resize reflow less cols blank lines between no scrollback" { { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint); } { const offset = it.next().?; const rac = offset.rowAndCell(); - const cells = offset.node.data.getCells(rac.row); + const cells = offset.node.page().getCells(rac.row); try testing.expect(!rac.row.wrap); try testing.expectEqual(@as(usize, 2), cells.len); try testing.expectEqual(@as(u21, 'C'), cells[0].content.codepoint); @@ -13259,7 +13698,7 @@ test "PageList resize reflow less cols cursor not on last line preserves locatio var s = try init(alloc, 5, 5, 1); defer s.deinit(); try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); for (0..s.rows) |y| { for (0..2) |x| { const rac = page.getRowAndCell(x, y); @@ -13304,7 +13743,7 @@ test "PageList resize reflow less cols copy style" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Create a style const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; @@ -13338,8 +13777,8 @@ test "PageList resize reflow less cols copy style" { const style_id = rac.cell.style_id; try testing.expect(style_id != 0); - const style = offset.node.data.styles.get( - offset.node.data.memory, + const style = offset.node.page().styles.get( + offset.node.page().memory, style_id, ); try testing.expect(style.flags.bold); @@ -13358,7 +13797,7 @@ test "PageList resize reflow less cols to eliminate a wide char" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13385,7 +13824,7 @@ test "PageList resize reflow less cols to eliminate a wide char" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13403,7 +13842,7 @@ test "PageList resize reflow less cols to wrap a wide char" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13437,7 +13876,7 @@ test "PageList resize reflow less cols to wrap a wide char" { { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13471,7 +13910,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // We want to make the screen look like this: // @@ -13530,7 +13969,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); { const rac = page.getRowAndCell(0, 0); @@ -13592,7 +14031,7 @@ test "PageList resize reflow less cols copy kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (0..s.cols - 1) |x| { @@ -13633,7 +14072,7 @@ test "PageList resize reflow more cols clears kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (0..s.cols - 1) |x| { @@ -13676,7 +14115,7 @@ test "PageList resize reflow wrap moves kitty placeholder" { defer s.deinit(); { try testing.expect(s.pages.first == s.pages.last); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write unicode placeholders for (2..s.cols - 1) |x| { @@ -13832,7 +14271,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // during reflow. Since each source page can only hold this many graphemes, // we create two source pages with graphemes that will merge into one // destination page. - const grapheme_capacity = s.pages.first.?.data.graphemeCapacity(); + const grapheme_capacity = s.pages.first.?.page().graphemeCapacity(); // Use slightly more than half the capacity per page, so combined they // exceed the capacity of a single destination page. const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4; @@ -13840,7 +14279,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Grow to the capacity of the first page and add more rows // so that we have two pages total. { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); page.pauseIntegrityChecks(true); for (page.size.rows..page.capacity.rows) |_| { _ = try s.grow(); @@ -13862,7 +14301,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Add graphemes to the end of the first page (last rows) { - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); const start_row = page.size.rows - graphemes_per_page; for (0..graphemes_per_page) |i| { const y = start_row + i; @@ -13877,7 +14316,7 @@ test "PageList resize reflow grapheme map capacity exceeded" { // Add graphemes to the beginning of the second page { - const page = &s.pages.last.?.data; + const page = s.pages.last.?.page(); const count = @min(graphemes_per_page, page.size.rows); for (0..count) |y| { const rac = page.getRowAndCell(0, y); @@ -13920,7 +14359,7 @@ test "PageList resize grow cols with unwrap fixes viewport pin" { // Fill all rows with wrapped content (pairs that unwrap when cols increase) var it = s.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); for (chunk.start..chunk.end) |y| { const rac = page.getRowAndCell(0, y); if (y % 2 == 0) { @@ -13964,16 +14403,16 @@ test "PageList grow reuses non-standard page without leak" { defer s.deinit(); // Increase the first page capacity to make it non-standard (larger than std_size). - while (s.pages.first.?.data.memory.len <= std_size) { + while (s.pages.first.?.page().memory.len <= std_size) { _ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes); } // The first page should now have non-standard memory size. - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // First, fill up the first page's capacity const first_page = s.pages.first.?; - while (first_page.data.size.rows < first_page.data.capacity.rows) { + while (first_page.rows() < first_page.capacity().rows) { _ = try s.grow(); } @@ -13983,23 +14422,23 @@ test "PageList grow reuses non-standard page without leak" { // Continue growing until we exceed max_size AND the last page is full while (s.page_size + PagePool.item_size <= s.maxSize() or - s.pages.last.?.data.size.rows < s.pages.last.?.data.capacity.rows) + s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { _ = try s.grow(); } // The first page should still be non-standard - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); // Verify we have enough rows for active area (so prune path isn't skipped) try testing.expect(s.totalRows() >= s.rows); // Verify last page is full (so grow will need to allocate/reuse) - try testing.expect(s.pages.last.?.data.size.rows == s.pages.last.?.data.capacity.rows); + try testing.expect(s.pages.last.?.page().size.rows == s.pages.last.?.capacity().rows); // Remember the first page memory pointer before the reuse attempt const first_page_ptr = s.pages.first.?; - const first_page_mem_ptr = s.pages.first.?.data.memory.ptr; + const first_page_mem_ptr = s.pages.first.?.page().memory.ptr; // Create a tracked pin pointing to the non-standard first page const tracked_pin = try s.trackPin(.{ .node = first_page_ptr, .x = 0, .y = 0 }); @@ -14018,7 +14457,7 @@ test "PageList grow reuses non-standard page without leak" { // If the non-standard page was properly destroyed and not reused, // the last page should not have the same memory pointer - try testing.expect(s.pages.last.?.data.memory.ptr != first_page_mem_ptr); + try testing.expect(s.pages.last.?.page().memory.ptr != first_page_mem_ptr); // The tracked pin should have been moved to the new first page and marked as garbage try testing.expectEqual(s.pages.first.?, tracked_pin.node); @@ -14047,19 +14486,19 @@ test "PageList grow non-standard page prune protection" { defer s.deinit(); // Make the first page non-standard - while (s.pages.first.?.data.memory.len <= std_size) { + while (s.pages.first.?.page().memory.len <= std_size) { _ = try s.increaseCapacity( s.pages.first.?, .grapheme_bytes, ); } - try testing.expect(s.pages.first.?.data.memory.len > std_size); + try testing.expect(s.pages.first.?.page().memory.len > std_size); const first_page_node = s.pages.first.?; - const first_page_cap = first_page_node.data.capacity.rows; + const first_page_cap = first_page_node.capacity().rows; // Fill first page to capacity - while (first_page_node.data.size.rows < first_page_cap) _ = try s.grow(); + while (first_page_node.rows() < first_page_cap) _ = try s.grow(); // Grow until we have a second page (first page fills up first) var second_node: ?*List.Node = null; @@ -14068,8 +14507,8 @@ test "PageList grow non-standard page prune protection" { // Fill the second page to capacity so that the next grow() triggers prune const last_node = s.pages.last.?; - const second_cap = last_node.data.capacity.rows; - while (last_node.data.size.rows < second_cap) _ = try s.grow(); + const second_cap = last_node.capacity().rows; + while (last_node.rows() < second_cap) _ = try s.grow(); // Now the last page is full. The next grow must either: // 1. Prune the first page and reuse it, OR @@ -14086,7 +14525,7 @@ test "PageList grow non-standard page prune protection" { try testing.expect(s.totalRows() >= s.rows); // Verify last page is at capacity (so grow must prune or allocate new) - try testing.expectEqual(second_cap, last_node.data.size.rows); + try testing.expectEqual(second_cap, last_node.rows()); // The next grow should trigger prune consideration. // Without the fix, this would destroy the non-standard first page, @@ -14120,12 +14559,12 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { // Trim a history row so the first page has spare capacity. // This triggers the backfill path in resizeWithoutReflowGrowCols. s.eraseHistory(.{ .history = .{ .y = 0 } }); - try testing.expect(first_page.data.size.rows < first_page.data.capacity.rows); + try testing.expect(first_page.rows() < first_page.capacity().rows); // Ensure the resize takes the slow path (new capacity > current capacity). const new_cols: size.CellCountInt = cols + 1; - const adjusted = try second_page.data.capacity.adjust(.{ .cols = new_cols }); - try testing.expect(second_page.data.capacity.cols < adjusted.cols); + const adjusted = try second_page.capacity().adjust(.{ .cols = new_cols }); + try testing.expect(second_page.capacity().cols < adjusted.cols); // Track a pin in row 0 of the second page. This row will be copied // to the first page during backfill and the pin must be remapped. @@ -14152,7 +14591,7 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" { } } try testing.expect(found); - try testing.expect(tracked.y < tracked.node.data.size.rows); + try testing.expect(tracked.y < tracked.node.rows()); // Verify the pin still points to the cell with our marker content. const cell = tracked.rowAndCell().cell; @@ -14170,20 +14609,20 @@ test "PageList compact pool page produces exact-size heap page" { // A freshly created page is pool-owned at std_size. const node = s.pages.first.?; try testing.expectEqual(.pool, node.owned); - try testing.expect(node.data.memory.len <= std_size); - const original_size = node.data.size; + try testing.expect(node.page().memory.len <= std_size); + const original_size = node.page().size; // Compacting it should produce a much smaller exact-size heap page. const new_node = (try s.compact(node)).?; try testing.expectEqual(.heap, new_node.owned); - try testing.expect(new_node.data.memory.len < std_size); - try testing.expectEqual(original_size.rows, new_node.data.size.rows); - try testing.expectEqual(original_size.cols, new_node.data.size.cols); + try testing.expect(new_node.page().memory.len < std_size); + try testing.expectEqual(original_size.rows, new_node.rows()); + try testing.expectEqual(original_size.cols, new_node.cols()); try testing.expectEqual(new_node, s.pages.first.?); // Our page size accounting should exactly match the compacted // page since it is the only page in the list. - try testing.expectEqual(new_node.data.memory.len, s.page_size); + try testing.expectEqual(new_node.page().memory.len, s.page_size); // Compacting again should be a no-op since it is already exact. try testing.expectEqual(null, try s.compact(new_node)); @@ -14198,7 +14637,7 @@ test "PageList compact then grow allocates new page" { // Compact the only page. It now has no spare row capacity. const node = (try s.compact(s.pages.first.?)).?; - try testing.expectEqual(node.data.size.rows, node.data.capacity.rows); + try testing.expectEqual(node.rows(), node.capacity().rows); // Growing must allocate a fresh standard page from the pool, // exercising that a compacted page remains a valid live page. @@ -14219,7 +14658,7 @@ test "PageList compact then reset frees heap pages" { // heap-owned page. const node = (try s.compact(s.pages.first.?)).?; try testing.expectEqual(.heap, node.owned); - try testing.expect(node.data.memory.len < std_size); + try testing.expect(node.page().memory.len < std_size); // Reset must free the heap page (testing allocator catches leaks // and invalid frees) and rebuild from the pool. @@ -14238,7 +14677,7 @@ test "PageList compact then clone" { // Write a marker so we can verify contents survive. { const node = s.pages.first.?; - const rac = node.data.getRowAndCell(1, 2); + const rac = node.page().getRowAndCell(1, 2); rac.cell.* = .{ .content_tag = .codepoint, .content = .{ .codepoint = 'X' }, @@ -14248,7 +14687,7 @@ test "PageList compact then clone" { // Compact so the source list contains a sub-std_size heap page. const node = (try s.compact(s.pages.first.?)).?; try testing.expectEqual(.heap, node.owned); - try testing.expect(node.data.memory.len < std_size); + try testing.expect(node.page().memory.len < std_size); var s2 = try s.clone(alloc, .{ .top = .{ .screen = .{} }, @@ -14259,7 +14698,7 @@ test "PageList compact then clone" { // Verify the marker survived the clone. { const node2 = s2.pages.first.?; - const rac = node2.data.getRowAndCell(1, 2); + const rac = node2.page().getRowAndCell(1, 2); try testing.expectEqual(@as(u21, 'X'), rac.cell.content.codepoint); } } @@ -14273,11 +14712,11 @@ test "PageList compact oversized page" { // Grow until we have multiple pages const page1_node = s.pages.first.?; - page1_node.data.pauseIntegrityChecks(true); - for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| { + page1_node.page().pauseIntegrityChecks(true); + for (0..page1_node.capacity().rows - page1_node.rows()) |_| { _ = try s.grow(); } - page1_node.data.pauseIntegrityChecks(false); + page1_node.page().pauseIntegrityChecks(false); _ = try s.grow(); try testing.expect(s.pages.first != s.pages.last); @@ -14285,7 +14724,7 @@ test "PageList compact oversized page" { // Write content to verify it's preserved { - const page = &node.data; + const page = node.page(); for (0..page.size.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -14302,30 +14741,30 @@ test "PageList compact oversized page" { defer s.untrackPin(tracked); // Make the page oversized - while (node.data.memory.len <= std_size) { + while (node.page().memory.len <= std_size) { node = try s.increaseCapacity(node, .grapheme_bytes); } - try testing.expect(node.data.memory.len > std_size); - const oversized_len = node.data.memory.len; - const original_size = node.data.size; + try testing.expect(node.page().memory.len > std_size); + const oversized_len = node.page().memory.len; + const original_size = node.page().size; const second_node = node.next.?; // Set dirty flag after increaseCapacity - node.data.dirty = true; + node.page().dirty = true; // Compact the page const new_node = try s.compact(node); try testing.expect(new_node != null); // Verify memory is smaller - try testing.expect(new_node.?.data.memory.len < oversized_len); + try testing.expect(new_node.?.page().memory.len < oversized_len); // Verify size preserved - try testing.expectEqual(original_size.rows, new_node.?.data.size.rows); - try testing.expectEqual(original_size.cols, new_node.?.data.size.cols); + try testing.expectEqual(original_size.rows, new_node.?.rows()); + try testing.expectEqual(original_size.cols, new_node.?.cols()); // Verify dirty flag preserved - try testing.expect(new_node.?.data.dirty); + try testing.expect(new_node.?.page().dirty); // Verify linked list integrity try testing.expectEqual(new_node.?, s.pages.first.?); @@ -14339,7 +14778,7 @@ test "PageList compact oversized page" { try testing.expectEqual(@as(size.CellCountInt, 10), tracked.y); // Verify content preserved - const page = &new_node.?.data; + const page = new_node.?.page(); for (0..page.size.rows) |y| { for (0..s.cols) |x| { const rac = page.getRowAndCell(x, y); @@ -14361,25 +14800,25 @@ test "PageList destroyed pool page reuse is zeroed" { // Create a page and scribble over its entire backing memory, // then destroy it so the buffer returns to the pool free list. const node = try s.createPage(.{ .cap = initialCapacity(80) }); - node.data.size.rows = 1; - const mem_ptr = node.data.memory.ptr; - @memset(node.data.memory, 0xAA); + node.page().size.rows = 1; + const mem_ptr = node.page().memory.ptr; + @memset(node.page().memory, 0xAA); s.destroyNode(node); // Reusing the buffer must produce a fully valid, zeroed page. const node2 = try s.createPage(.{ .cap = initialCapacity(80) }); - try testing.expectEqual(mem_ptr, node2.data.memory.ptr); - node2.data.size.rows = node2.data.capacity.rows; + try testing.expectEqual(mem_ptr, node2.page().memory.ptr); + node2.page().size.rows = node2.capacity().rows; - const cells_len = @as(usize, node2.data.capacity.cols) * - @as(usize, node2.data.capacity.rows); - const cells = node2.data.cells.ptr(node2.data.memory)[0..cells_len]; + const cells_len = @as(usize, node2.capacity().cols) * + @as(usize, node2.capacity().rows); + const cells = node2.page().cells.ptr(node2.page().memory)[0..cells_len]; try testing.expect(std.mem.allEqual( u64, @as([]const u64, @ptrCast(cells)), 0, )); - node2.data.assertIntegrity(); + node2.page().assertIntegrity(); s.destroyNode(node2); } @@ -14394,27 +14833,27 @@ test "PageList increaseCapacity from zero-capacity dimensions" { // or hyperlink content so the exact capacity is zero in every // managed dimension. var node = (try s.compact(s.pages.first.?)).?; - try testing.expectEqual(0, node.data.capacity.styles); - try testing.expectEqual(0, node.data.capacity.grapheme_bytes); - try testing.expectEqual(0, node.data.capacity.string_bytes); - try testing.expectEqual(0, node.data.capacity.hyperlink_bytes); + try testing.expectEqual(0, node.capacity().styles); + try testing.expectEqual(0, node.capacity().grapheme_bytes); + try testing.expectEqual(0, node.capacity().string_bytes); + try testing.expectEqual(0, node.capacity().hyperlink_bytes); // Increasing each dimension from zero must actually grow it. // Regression: 0 * 2 == 0 used to "succeed" without growing, // which turned caller retry loops into infinite loops. node = try s.increaseCapacity(node, .styles); - try testing.expect(node.data.capacity.styles > 0); + try testing.expect(node.capacity().styles > 0); node = try s.increaseCapacity(node, .grapheme_bytes); - try testing.expect(node.data.capacity.grapheme_bytes > 0); + try testing.expect(node.capacity().grapheme_bytes > 0); node = try s.increaseCapacity(node, .string_bytes); - try testing.expect(node.data.capacity.string_bytes > 0); + try testing.expect(node.capacity().string_bytes > 0); node = try s.increaseCapacity(node, .hyperlink_bytes); - try testing.expect(node.data.capacity.hyperlink_bytes > 0); + try testing.expect(node.capacity().hyperlink_bytes > 0); // Increasing a non-zero dimension still doubles. - const styles = node.data.capacity.styles; + const styles = node.capacity().styles; node = try s.increaseCapacity(node, .styles); - try testing.expectEqual(styles * 2, node.data.capacity.styles); + try testing.expectEqual(styles * 2, node.capacity().styles); } test "PageList compact after increaseCapacity" { @@ -14429,12 +14868,12 @@ test "PageList compact after increaseCapacity" { // Grow the page capacity. The content is unchanged, so compaction // should always shrink it back down to an exact-size heap page. node = try s.increaseCapacity(node, .grapheme_bytes); - const grown_len = node.data.memory.len; + const grown_len = node.page().memory.len; const new_node = (try s.compact(node)).?; try testing.expectEqual(.heap, new_node.owned); - try testing.expect(new_node.data.memory.len < grown_len); - try testing.expect(new_node.data.memory.len < std_size); + try testing.expect(new_node.page().memory.len < grown_len); + try testing.expect(new_node.page().memory.len < std_size); } test "PageList split at middle row" { @@ -14444,7 +14883,7 @@ test "PageList split at middle row" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to rows: row 0 gets codepoint 0, row 1 gets 1, etc. for (0..page.size.rows) |y| { @@ -14463,8 +14902,8 @@ test "PageList split at middle row" { try testing.expect(s.pages.first != null); try testing.expect(s.pages.first.?.next != null); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have rows 0-4 (5 rows) try testing.expectEqual(@as(usize, 5), first_page.size.rows); @@ -14491,7 +14930,7 @@ test "PageList split at row 0 is no-op" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to all rows for (0..page.size.rows) |y| { @@ -14525,7 +14964,7 @@ test "PageList split at last row" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Write content to all rows for (0..page.size.rows) |y| { @@ -14544,8 +14983,8 @@ test "PageList split at last row" { try testing.expect(s.pages.first != null); try testing.expect(s.pages.first.?.next != null); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have 9 rows try testing.expectEqual(@as(usize, 9), first_page.size.rows); @@ -14731,8 +15170,8 @@ test "PageList split middle page preserves linked list order" { // Now we have 2 pages const page1 = s.pages.first.?; const page2 = s.pages.first.?.next.?; - try testing.expectEqual(@as(usize, 4), page1.data.size.rows); - try testing.expectEqual(@as(usize, 8), page2.data.size.rows); + try testing.expectEqual(@as(usize, 4), page1.rows()); + try testing.expectEqual(@as(usize, 8), page2.rows()); // Split page2 at row 4 to create: page1 -> page2 (rows 0-3) -> page3 (rows 4-7) const split_pin2: Pin = .{ .node = page2, .y = 4, .x = 0 }; @@ -14759,9 +15198,9 @@ test "PageList split middle page preserves linked list order" { try testing.expect(last.next == null); // Verify row counts - try testing.expectEqual(@as(usize, 4), first.data.size.rows); - try testing.expectEqual(@as(usize, 4), middle.data.size.rows); - try testing.expectEqual(@as(usize, 4), last.data.size.rows); + try testing.expectEqual(@as(usize, 4), first.rows()); + try testing.expectEqual(@as(usize, 4), middle.rows()); + try testing.expectEqual(@as(usize, 4), last.rows()); } test "PageList split last page makes new page the last" { @@ -14779,7 +15218,7 @@ test "PageList split last page makes new page the last" { // Now split the last page const last_before_split = s.pages.last.?; - try testing.expectEqual(@as(usize, 5), last_before_split.data.size.rows); + try testing.expectEqual(@as(usize, 5), last_before_split.rows()); const split_pin2: Pin = .{ .node = last_before_split, .y = 2, .x = 0 }; try s.split(split_pin2); @@ -14791,8 +15230,8 @@ test "PageList split last page makes new page the last" { try testing.expect(new_last.next == null); // Verify row counts: original last has 2 rows, new last has 3 rows - try testing.expectEqual(@as(usize, 2), last_before_split.data.size.rows); - try testing.expectEqual(@as(usize, 3), new_last.data.size.rows); + try testing.expectEqual(@as(usize, 2), last_before_split.rows()); + try testing.expectEqual(@as(usize, 3), new_last.rows()); } test "PageList split first page keeps original as first" { @@ -14824,9 +15263,9 @@ test "PageList split first page keeps original as first" { try testing.expectEqual(second_page, inserted.next.?); // Verify row counts: first has 2, inserted has 3, second has 5 - try testing.expectEqual(@as(usize, 2), s.pages.first.?.data.size.rows); - try testing.expectEqual(@as(usize, 3), inserted.data.size.rows); - try testing.expectEqual(@as(usize, 5), second_page.data.size.rows); + try testing.expectEqual(@as(usize, 2), s.pages.first.?.rows()); + try testing.expectEqual(@as(usize, 3), inserted.rows()); + try testing.expectEqual(@as(usize, 5), second_page.rows()); } test "PageList split preserves wrap flags" { @@ -14836,7 +15275,7 @@ test "PageList split preserves wrap flags" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Set wrap flags on rows that will be in the second page after split // Row 5: wrap = true (this is the start of a wrapped line) @@ -14858,7 +15297,7 @@ test "PageList split preserves wrap flags" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const second_page = &s.pages.first.?.next.?.data; + const second_page = s.pages.first.?.next.?.page(); // Verify wrap flags are preserved in new page // Original row 5 is now row 0 in second page @@ -14890,7 +15329,7 @@ test "PageList split preserves styled cells" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Create a style and apply it to cells in rows 5-7 (which will be in the second page) const style: stylepkg.Style = .{ .flags = .{ .bold = true } }; @@ -14913,8 +15352,8 @@ test "PageList split preserves styled cells" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no styles (all styled rows moved to second page) try testing.expectEqual(@as(usize, 0), first_page.styles.count()); @@ -14941,7 +15380,7 @@ test "PageList split preserves grapheme clusters" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Add a grapheme cluster to row 6 (will be row 1 in second page after split at 5) { @@ -14960,8 +15399,8 @@ test "PageList split preserves grapheme clusters" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no graphemes (the grapheme row moved to second page) try testing.expectEqual(@as(usize, 0), first_page.graphemeCount()); @@ -14989,7 +15428,7 @@ test "PageList split preserves hyperlinks" { var s = try init(alloc, 10, 10, 0); defer s.deinit(); - const page = &s.pages.first.?.data; + const page = s.pages.first.?.page(); // Add a hyperlink to row 7 (will be row 2 in second page after split at 5) const hyperlink_id = try page.insertHyperlink(.{ @@ -15009,8 +15448,8 @@ test "PageList split preserves hyperlinks" { const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 }; try s.split(split_pin); - const first_page = &s.pages.first.?.data; - const second_page = &s.pages.first.?.next.?.data; + const first_page = s.pages.first.?.page(); + const second_page = s.pages.first.?.next.?.page(); // First page should have no hyperlinks (the hyperlink row moved to second page) try testing.expectEqual(@as(usize, 0), first_page.hyperlink_set.count()); diff --git a/src/terminal/Screen.zig b/src/terminal/Screen.zig index 5ecfe7933..95cba1bf9 100644 --- a/src/terminal/Screen.zig +++ b/src/terminal/Screen.zig @@ -542,7 +542,7 @@ pub fn clone( const end_pin = pin_remap.get(ordered.br) orelse try pages.trackPin(.{ .node = pages.pages.last.?, .x = if (sel.rectangle) ordered.br.x else pages.cols - 1, - .y = pages.pages.last.?.data.size.rows - 1, + .y = pages.pages.last.?.rows() - 1, }); break :sel .{ @@ -584,7 +584,7 @@ pub fn increaseCapacity( // capacity below it will be short the ref count on our // current style and hyperlink, so we need to init those. const new_node = try self.pages.increaseCapacity(node, adjustment); - const new_page: *Page = &new_node.data; + const new_page: *Page = new_node.page(); // Re-add the style, if the page somehow doesn't have enough // memory to add it, we emit a warning and gracefully degrade @@ -808,7 +808,7 @@ pub fn cursorDownScroll(self: *Screen) !void { // so our cursor is in the correct place we just have to clear // the cells. if (self.pages.rows == 1) { - const page: *Page = &self.cursor.page_pin.node.data; + const page: *Page = self.cursor.page_pin.node.page(); self.clearCells( page, self.cursor.page_row, @@ -845,10 +845,9 @@ pub fn cursorDownScroll(self: *Screen) !void { // allocate, prune scrollback, whatever. _ = try self.pages.grow(); - self.cursorChangePin(new_pin: { - // We do this all in a block here because referencing this pin - // after cursorChangePin is unsafe, and we want to keep it out - // of scope. + const new_pin = new_pin: { + // Calculate this before cursorChangePin because that function may + // adjust the underlying page and invalidate references to its pin. // If our pin page change it means that the page that the pin // was on was pruned. In this case, grow() moves the pin to @@ -874,10 +873,27 @@ pub fn cursorDownScroll(self: *Screen) !void { } break :new_pin page_pin; - }); - const page_rac = self.cursor.page_pin.rowAndCell(); - self.cursor.page_row = page_rac.row; - self.cursor.page_cell = page_rac.cell; + }; + + if (self.cursor.page_pin.node == new_pin.node) { + // Scrolling normally stays within one page. Resolve the page once + // while refreshing the cursor pointers. The cursor already holds + // live row and cell pointers into this mapping, and grow does not + // compress pages, so the node must still be resident here. + self.cursorMarkDirty(); + const page = new_pin.node.pageAssumeResident(); + const page_rac = page.getRowAndCell(new_pin.x, new_pin.y); + self.cursor.page_pin.* = new_pin; + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + } else { + // Crossing a page may require migrating the cursor's style and + // hyperlink references, so retain the general path here. + self.cursorChangePin(new_pin); + const page_rac = self.cursor.page_pin.rowAndCell(); + self.cursor.page_row = page_rac.row; + self.cursor.page_cell = page_rac.cell; + } // Our new row is always dirty self.cursorMarkDirty(); @@ -885,7 +901,7 @@ pub fn cursorDownScroll(self: *Screen) !void { // Clear the new row so it gets our bg color. We only do this // if we have a bg color at all. if (self.cursor.style.bg_color != .none) { - const page: *Page = &self.cursor.page_pin.node.data; + const page: *Page = self.cursor.page_pin.node.page(); self.clearCells( page, self.cursor.page_row, @@ -950,7 +966,7 @@ pub fn cursorScrollAbove(self: *Screen) !void { self.cursor.page_pin.* = self.cursor.page_pin.down(1).?; const pin = self.cursor.page_pin; - const page: *Page = &self.cursor.page_pin.node.data; + const page: *Page = self.cursor.page_pin.node.page(); // Rotate the rows so that the newly created empty row is at the // beginning. e.g. [ 0 1 2 3 ] in to [ 3 0 1 2 ]. @@ -1011,8 +1027,8 @@ fn cursorScrollAboveRotate(self: *Screen) !void { var current = self.pages.pages.last.?; while (current != self.cursor.page_pin.node) : (current = current.prev.?) { const prev = current.prev.?; - const prev_page = &prev.data; - const cur_page = ¤t.data; + const prev_page = prev.page(); + const cur_page = current.page(); const prev_rows = prev_page.rows.ptr(prev_page.memory.ptr); const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); @@ -1033,7 +1049,7 @@ fn cursorScrollAboveRotate(self: *Screen) !void { // Our current is our cursor page, we need to rotate down from // our cursor and clear our row. assert(current == self.cursor.page_pin.node); - const cur_page = ¤t.data; + const cur_page = current.page(); const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr); fastmem.rotateOnceR(Row, cur_rows[self.cursor.page_pin.y..cur_page.size.rows]); self.clearCells( @@ -1087,7 +1103,11 @@ pub fn cursorScrollRegionUp(self: *Screen, limit: usize) !void { // Fast path: the entire region is in a single page. We can clear // the top row and rotate it down to the cursor row, updating any // tracked pins along the way. - const page: *Page = &pin.node.data; + // + // The cursor's cached row and cell pointers refer into this mapping. + // PageList cannot compress the cursor page while those pointers are + // installed, so the node is known to be resident here. + const page: *Page = pin.node.pageAssumeResident(); const rows = page.rows.ptr(page.memory.ptr)[pin.y - limit ..][0 .. limit + 1]; // Clear the erased (top) row. @@ -1256,7 +1276,7 @@ pub fn cursorCopy(self: *Screen, other: Cursor, opts: struct { // If the other cursor had a hyperlink, add it to ours. if (opts.hyperlink and other.hyperlink_id != 0) { // Get the hyperlink from the other cursor's page. - const other_page = &other.page_pin.node.data; + const other_page = other.page_pin.node.page(); const other_link = other_page.hyperlink_set.get(other_page.memory, other.hyperlink_id); const uri = other_link.uri.slice(other_page.memory); @@ -1311,7 +1331,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // Release the style directly from the old page instead of going through // manualStyleUpdate, because the cursor position may have already been // updated but the pin has not, which would fail integrity checks. - const old_page: *Page = &self.cursor.page_pin.node.data; + const old_page: *Page = self.cursor.page_pin.node.page(); old_page.styles.release(old_page.memory, self.cursor.style_id); self.cursor.style = .{}; self.cursor.style_id = style.default_id; @@ -1319,7 +1339,7 @@ inline fn cursorChangePin(self: *Screen, new: Pin) void { // If we have a hyperlink then we need to release it from the old page. if (self.cursor.hyperlink != null) { - const old_page: *Page = &self.cursor.page_pin.node.data; + const old_page: *Page = self.cursor.page_pin.node.page(); old_page.hyperlink_set.release(old_page.memory, self.cursor.hyperlink_id); } @@ -1394,12 +1414,12 @@ pub fn cursorResetWrap(self: *Screen) void { // If the last cell in the row is a spacer head we need to clear it. const cells = self.cursor.page_pin.cells(.all); - const cell = cells[self.cursor.page_pin.node.data.size.cols - 1]; + const cell = cells[self.cursor.page_pin.node.cols() - 1]; if (cell.wide == .spacer_head) { self.clearCells( - &self.cursor.page_pin.node.data, + self.cursor.page_pin.node.page(), page_row, - cells[self.cursor.page_pin.node.data.size.cols - 1 ..][0..1], + cells[self.cursor.page_pin.node.cols() - 1 ..][0..1], ); } } @@ -1498,17 +1518,17 @@ pub fn clearRows( while (it.next()) |chunk| { for (chunk.rows()) |*row| { const cells_offset = row.cells; - const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory); + const cells_multi: [*]Cell = row.cells.ptr(chunk.node.page().memory); const cells = cells_multi[0..self.pages.cols]; // Clear all cells if (protected) { - self.clearUnprotectedCells(&chunk.node.data, row, cells); + self.clearUnprotectedCells(chunk.node.page(), row, cells); // We need to preserve other row attributes since we only // cleared unprotected cells. row.cells = cells_offset; } else { - self.clearCells(&chunk.node.data, row, cells); + self.clearCells(chunk.node.page(), row, cells); row.* = .{ .cells = cells_offset }; } @@ -1686,12 +1706,12 @@ pub fn splitCellBoundary( self: *Screen, x: size.CellCountInt, ) void { - const page = &self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); page.pauseIntegrityChecks(true); defer page.pauseIntegrityChecks(false); - const cols = self.cursor.page_pin.node.data.size.cols; + const cols = self.cursor.page_pin.node.cols(); // `x` may be up to an INCLUDING `cols`, since that signifies splitting // the boundary to the right of the final cell in the row. @@ -1735,12 +1755,12 @@ pub fn splitCellBoundary( if (self.cursor.page_pin.up(1)) |p_row| { const p_rac = p_row.rowAndCell(); const p_cells = p_row.cells(.all); - const p_cell = p_cells[p_row.node.data.size.cols - 1]; + const p_cell = p_cells[p_row.node.cols() - 1]; if (p_cell.wide == .spacer_head) { self.clearCells( - &p_row.node.data, + p_row.node.page(), p_rac.row, - p_cells[p_row.node.data.size.cols - 1 ..][0..1], + p_cells[p_row.node.cols() - 1 ..][0..1], ); } } @@ -1851,7 +1871,7 @@ pub inline fn resize( if (self.cursor.hyperlink_id != 0) { // Note we do NOT use endHyperlink because we want to keep // our allocated self.cursor.hyperlink valid. - var page = &self.cursor.page_pin.node.data; + var page = self.cursor.page_pin.node.page(); page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); self.cursor.hyperlink_id = 0; self.cursor.hyperlink = null; @@ -1888,7 +1908,7 @@ pub inline fn resize( // For `.last`, only clear the current line where the cursor is. // For `.true`, clear all prompt lines starting from the beginning. .last => { - const page = &self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); const row = self.cursor.page_row; const cells = page.getCells(row); self.clearCells(page, row, cells); @@ -1914,7 +1934,7 @@ pub inline fn resize( // shell is going to expect this space to be available. var it = start.rowIterator(.right_down, null); while (it.next()) |pin| { - const page = &pin.node.data; + const page = pin.node.page(); const row = pin.rowAndCell().row; const cells = page.getCells(row); self.clearCells(page, row, cells); @@ -2178,7 +2198,7 @@ pub fn setAttribute( /// there was still no room for the new style. pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { defer self.assertIntegrity(); - var page: *Page = &self.cursor.page_pin.node.data; + var page: *Page = self.cursor.page_pin.node.page(); // std.log.warn("active styles={}", .{page.styles.count()}); @@ -2229,7 +2249,7 @@ pub fn manualStyleUpdate(self: *Screen) PageList.IncreaseCapacityError!void { }, }; - page = &node.data; + page = node.page(); break :id page.styles.add( page.memory, self.cursor.style, @@ -2277,13 +2297,13 @@ fn splitForCapacity( ) PageList.SplitError!void { // Get our capacities. We include our target row because its // capacity will be preserved. - const bytes_above = Page.layout(pin.node.data.exactRowCapacity( + const bytes_above = Page.layout(pin.node.page().exactRowCapacity( 0, pin.y + 1, )).total_size; - const bytes_below = Page.layout(pin.node.data.exactRowCapacity( + const bytes_below = Page.layout(pin.node.page().exactRowCapacity( pin.y, - pin.node.data.size.rows, + pin.node.rows(), )).total_size; // We need to track the old cursor pin because if our split @@ -2319,8 +2339,8 @@ pub fn appendGrapheme( cell: *Cell, cp: u21, ) PageList.IncreaseCapacityError!void { - defer self.cursor.page_pin.node.data.assertIntegrity(); - self.cursor.page_pin.node.data.appendGrapheme( + defer self.cursor.page_pin.node.page().assertIntegrity(); + self.cursor.page_pin.node.page().appendGrapheme( self.cursor.page_row, cell, cp, @@ -2351,7 +2371,7 @@ pub fn appendGrapheme( .gt => self.cursorCellRight(@intCast(cell_idx - self.cursor.x)), }; - self.cursor.page_pin.node.data.appendGrapheme( + self.cursor.page_pin.node.page().appendGrapheme( self.cursor.page_row, reloaded_cell, cp, @@ -2439,7 +2459,7 @@ fn startHyperlinkOnce( errdefer link.deinit(self.alloc); // Insert the hyperlink into page memory - var page = &self.cursor.page_pin.node.data; + var page = self.cursor.page_pin.node.page(); const id: hyperlink.Id = try page.insertHyperlink(link.*); // Save it all @@ -2466,7 +2486,7 @@ pub fn endHyperlink(self: *Screen) void { // how RefCountedSet works). This causes some memory fragmentation but // is fine because if it is ever pruned the context deleted callback // will be called. - var page: *Page = &self.cursor.page_pin.node.data; + var page: *Page = self.cursor.page_pin.node.page(); page.hyperlink_set.release(page.memory, self.cursor.hyperlink_id); self.cursor.hyperlink.?.deinit(self.alloc); self.alloc.destroy(self.cursor.hyperlink.?); @@ -2478,7 +2498,7 @@ pub fn endHyperlink(self: *Screen) void { pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { assert(self.cursor.hyperlink_id != 0); - var page = &self.cursor.page_pin.node.data; + var page = self.cursor.page_pin.node.page(); if (page.setHyperlink( self.cursor.page_row, self.cursor.page_cell, @@ -2516,7 +2536,7 @@ pub fn cursorSetHyperlink(self: *Screen) PageList.IncreaseCapacityError!void { .string_bytes, ); assert(new_node == self.cursor.page_pin.node); - page = &new_node.data; + page = new_node.page(); } _ = try self.increaseCapacity( @@ -2718,7 +2738,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // First, check the current row for semantic boundaries before the clicked position. if (semantic_prompt_state) |v| { const row = it_prev.rowAndCell().row; - const cells = it_prev.node.data.getCells(row); + const cells = it_prev.node.page().getCells(row); // Scan backwards from clicked position to find where our content starts for (0..opts.pin.x + 1) |i| { const x_rev = opts.pin.x - i; @@ -2746,7 +2766,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (semantic_prompt_state) |v| { // We need to check every cell in this row in reverse // order since we're going up and back. - const cells = p.node.data.getCells(row); + const cells = p.node.page().getCells(row); for (0..cells.len) |x| { const x_rev = cells.len - 1 - x; const cell = cells[x_rev]; @@ -2774,7 +2794,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (semantic_prompt_state) |v| { // We need to check every cell in this row - const cells = p.node.data.getCells(row); + const cells = p.node.page().getCells(row); // If this is our pin row we can start from our x because // the start_pin logic already found the real start. @@ -2787,7 +2807,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { // so we scan forward to find where our content ends. if (start_offset == 0 and cells[0].semantic_content != v) { var prev = p.up(1).?; - prev.x = p.node.data.size.cols - 1; + prev.x = p.node.cols() - 1; break :end_pin prev; } @@ -2803,7 +2823,7 @@ pub fn selectLine(self: *const Screen, opts: SelectLine) ?Selection { if (!row.wrap) { var copy = p; - copy.x = p.node.data.size.cols - 1; + copy.x = p.node.cols() - 1; break :end_pin copy; } } @@ -2993,7 +3013,7 @@ pub fn selectWord( // If we are going to the next row and it isn't wrapped, we // return the previous. - if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) { + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { break :end p; } @@ -3013,7 +3033,7 @@ pub fn selectWord( // If we are going to the next row and it isn't wrapped, we // return the previous. - if (p.x == p.node.data.size.cols - 1 and !rac.row.wrap) { + if (p.x == p.node.cols() - 1 and !rac.row.wrap) { break :start prev; } @@ -3065,7 +3085,7 @@ pub fn selectOutput(self: *Screen, pin: Pin) ?Selection { // the prompt and will trim the trailing whitespace. const start_pin = self.pages.getTopLeft(.screen); var end_pin = next.up(1) orelse return null; - end_pin.x = end_pin.node.data.size.cols - 1; + end_pin.x = end_pin.node.cols() - 1; var cell_it = end_pin.cellIterator(.left_up, start_pin); while (cell_it.next()) |p| { const cell = p.rowAndCell().cell; @@ -3194,7 +3214,7 @@ fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove { ); row_it: while (row_it.next()) |row_pin| { const rac = row_pin.rowAndCell(); - const cells = row_pin.node.data.getCells(rac.row); + const cells = row_pin.node.page().getCells(rac.row); // Determine if this row is our cursor. const is_cursor_row = row_pin.node == cursor_pin.node and @@ -3270,7 +3290,7 @@ fn promptClickLine(self: *Screen, click_pin: Pin) PromptClickMove { ); row_it: while (row_it.next()) |row_pin| { const rac = row_pin.rowAndCell(); - const cells = row_pin.node.data.getCells(rac.row); + const cells = row_pin.node.page().getCells(rac.row); // Determine the length of the cells we look at in this row. const end_len: usize = end_len: { @@ -3420,7 +3440,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { break :cell cell; }; - try self.cursor.page_pin.node.data.appendGrapheme( + try self.cursor.page_pin.node.page().appendGrapheme( self.cursor.page_row, cell, c, @@ -3454,7 +3474,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { // If we have a ref-counted style, increase. if (self.cursor.style_id != style.default_id) { - const page = self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; } @@ -3511,7 +3531,7 @@ pub fn testWriteString(self: *Screen, text: []const u8) !void { // If we have a ref-counted style, increase twice. if (self.cursor.style_id != style.default_id) { - const page = self.cursor.page_pin.node.data; + const page = self.cursor.page_pin.node.page(); page.styles.use(page.memory, self.cursor.style_id); page.styles.use(page.memory, self.cursor.style_id); self.cursor.page_row.styled = true; @@ -3651,7 +3671,7 @@ test "Screen cursorCopy style deref" { var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // Bold should create our style try s2.setAttribute(.{ .bold = {} }); @@ -3675,15 +3695,15 @@ test "Screen cursorCopy style deref new page" { defer s2.deinit(); // We need to get the cursor on a new page. - const first_page_size = s2.pages.pages.first.?.data.capacity.rows; + const first_page_size = s2.pages.pages.first.?.capacity().rows; // Fill the scrollback with blank lines until // there are only 5 rows left on the first page. - s2.pages.pages.first.?.data.pauseIntegrityChecks(true); + s2.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } - s2.pages.pages.first.?.data.pauseIntegrityChecks(false); + s2.pages.pages.first.?.page().pauseIntegrityChecks(false); try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); @@ -3709,10 +3729,10 @@ test "Screen cursorCopy style deref new page" { // +-------------+ // This should be PAGE 1 - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // It should be the last page in the list. - try testing.expectEqual(&s2.pages.pages.last.?.data, page); + try testing.expectEqual(s2.pages.pages.last.?.page(), page); // It should have a previous page. try testing.expect(s2.cursor.page_pin.node.prev != null); @@ -3731,7 +3751,7 @@ test "Screen cursorCopy style deref new page" { try testing.expect(!s2.cursor.style.flags.bold); try testing.expectEqual(@as(usize, 0), page.styles.count()); // The page after the page the cursor is now in should be page 1. - try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data); + try testing.expectEqual(page, s2.cursor.page_pin.node.next.?.page()); // The cursor should be at 0, 0 try testing.expect(s2.cursor.x == 0); try testing.expect(s2.cursor.y == 0); @@ -3747,7 +3767,7 @@ test "Screen cursorCopy style copy" { var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); try s2.cursorCopy(s.cursor, .{}); try testing.expect(s2.cursor.style.flags.bold); try testing.expectEqual(@as(usize, 1), page.styles.count()); @@ -3762,7 +3782,7 @@ test "Screen cursorCopy hyperlink deref" { var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // Create a hyperlink for the cursor. try s2.startHyperlink("https://example.com/", null); @@ -3791,10 +3811,10 @@ test "Screen write regrows compacted page capacity" { // point into the replaced page. { const node = (try s.pages.compact(s.cursor.page_pin.node)).?; - try testing.expectEqual(0, node.data.capacity.styles); - try testing.expectEqual(0, node.data.capacity.grapheme_bytes); - try testing.expectEqual(0, node.data.capacity.string_bytes); - try testing.expectEqual(0, node.data.capacity.hyperlink_bytes); + try testing.expectEqual(0, node.capacity().styles); + try testing.expectEqual(0, node.capacity().grapheme_bytes); + try testing.expectEqual(0, node.capacity().string_bytes); + try testing.expectEqual(0, node.capacity().hyperlink_bytes); s.cursorReload(); } @@ -3817,7 +3837,7 @@ test "Screen write regrows compacted page capacity" { s.endHyperlink(); // Verify the content landed on the page. - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(page.styles.count() >= 1); try testing.expect(page.hyperlink_set.count() >= 1); try testing.expect(page.graphemeCount() >= 1); @@ -3834,15 +3854,15 @@ test "Screen cursorCopy hyperlink deref new page" { defer s2.deinit(); // We need to get the cursor on a new page. - const first_page_size = s2.pages.pages.first.?.data.capacity.rows; + const first_page_size = s2.pages.pages.first.?.capacity().rows; // Fill the scrollback with blank lines until // there are only 5 rows left on the first page. - s2.pages.pages.first.?.data.pauseIntegrityChecks(true); + s2.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 5) |_| { try s2.testWriteString("\n"); } - s2.pages.pages.first.?.data.pauseIntegrityChecks(false); + s2.pages.pages.first.?.page().pauseIntegrityChecks(false); try s2.testWriteString("1\n2\n3\n4\n5\n6\n7\n8\n9\n10"); @@ -3868,10 +3888,10 @@ test "Screen cursorCopy hyperlink deref new page" { // +-------------+ // This should be PAGE 1 - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); // It should be the last page in the list. - try testing.expectEqual(&s2.pages.pages.last.?.data, page); + try testing.expectEqual(s2.pages.pages.last.?.page(), page); // It should have a previous page. try testing.expect(s2.cursor.page_pin.node.prev != null); @@ -3890,7 +3910,7 @@ test "Screen cursorCopy hyperlink deref new page" { try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); // The page after the page the cursor is now in should be page 1. - try testing.expectEqual(page, &s2.cursor.page_pin.node.next.?.data); + try testing.expectEqual(page, s2.cursor.page_pin.node.next.?.page()); // The cursor should be at 0, 0 try testing.expect(s2.cursor.x == 0); try testing.expect(s2.cursor.y == 0); @@ -3905,12 +3925,12 @@ test "Screen cursorCopy hyperlink copy" { // Create a hyperlink for the cursor. try s.startHyperlink("https://example.com/", null); - try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); + try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); @@ -3930,12 +3950,12 @@ test "Screen cursorCopy hyperlink copy disabled" { // Create a hyperlink for the cursor. try s.startHyperlink("https://example.com/", null); - try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.data.hyperlink_set.count()); + try testing.expectEqual(@as(usize, 1), s.cursor.page_pin.node.page().hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); var s2 = try Screen.init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 }); defer s2.deinit(); - const page = &s2.cursor.page_pin.node.data; + const page = s2.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.hyperlink_set.count()); try testing.expect(s2.cursor.hyperlink_id == 0); @@ -3952,7 +3972,7 @@ test "Screen style basics" { var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); // Set a new style @@ -3974,7 +3994,7 @@ test "Screen style reset to default" { var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); // Set a new style @@ -3994,7 +4014,7 @@ test "Screen style reset with unset" { var s = try Screen.init(alloc, .{ .cols = 80, .rows = 24, .max_scrollback = 1000 }); defer s.deinit(); - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); // Set a new style @@ -4051,7 +4071,7 @@ test "Screen clearRows active styled line" { try s.setAttribute(.{ .unset = {} }); // We should have one style - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); s.clearRows(.{ .active = .{} }, null, false); @@ -4197,7 +4217,7 @@ test "Screen: cursorDown across pages preserves style" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); @@ -4207,21 +4227,21 @@ test "Screen: cursorDown across pages preserves style" { // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page != page); } // Scroll back to the previous page s.cursorUp(1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page == page); } // Go back up, set a style try s.setAttribute(.{ .bold = {} }); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4232,7 +4252,7 @@ test "Screen: cursorDown across pages preserves style" { // Go back down into the next page and we should have that style s.cursorDown(1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4249,7 +4269,7 @@ test "Screen: cursorUp across pages preserves style" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); @@ -4259,14 +4279,14 @@ test "Screen: cursorUp across pages preserves style" { // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page != page); } // Go back up, set a style try s.setAttribute(.{ .bold = {} }); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4277,7 +4297,7 @@ test "Screen: cursorUp across pages preserves style" { // Go back down into the prev page and we should have that style s.cursorUp(1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page == page); const styleval = page.styles.get( @@ -4296,7 +4316,7 @@ test "Screen: cursorAbsolute across pages preserves style" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); @@ -4306,14 +4326,14 @@ test "Screen: cursorAbsolute across pages preserves style" { // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page != page); } // Go back up, set a style try s.setAttribute(.{ .bold = {} }); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styleval = page.styles.get( page.memory, s.cursor.style_id, @@ -4324,7 +4344,7 @@ test "Screen: cursorAbsolute across pages preserves style" { // Go back down into the prev page and we should have that style s.cursorAbsolute(1, 1); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expect(start_page == page); const styleval = page.styles.get( @@ -4351,13 +4371,13 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { defer s.deinit(); // Scroll down enough to go to another page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false); - const new_page = &s.cursor.page_pin.node.data; + const new_page = s.cursor.page_pin.node.page(); // We need our page to change for this test to make sense. If this // assertion fails then the bug is in the test: we should be scrolling @@ -4392,7 +4412,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { // Go back up into the start page and we should still have that style. s.cursorAbsolute(1, 1); { - const cur_page = &s.cursor.page_pin.node.data; + const cur_page = s.cursor.page_pin.node.page(); // The page we're on now should NOT equal start_page, since its // capacity should have been adjusted, which invalidates our ptr. try testing.expect(start_page != cur_page); @@ -4406,7 +4426,7 @@ test "Screen: cursorAbsolute to page with insufficient capacity" { try testing.expect(styleval.flags.bold); } - s.cursor.page_pin.node.data.assertIntegrity(); + s.cursor.page_pin.node.page().assertIntegrity(); new_page.assertIntegrity(); } @@ -4510,7 +4530,7 @@ test "Screen: scrolling across pages preserves style" { defer s.deinit(); try s.setAttribute(.{ .bold = {} }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); // Scroll down enough to go to another page const rem = start_page.capacity.rows - start_page.size.rows + 1; @@ -4521,7 +4541,7 @@ test "Screen: scrolling across pages preserves style" { // We need our page to change for this test o make sense. If this // assertion fails then the bug is in the test: we should be scrolling // above enough for a new page to show up. - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); try testing.expect(start_page != page); const styleval = page.styles.get( @@ -4859,10 +4879,10 @@ test "Screen: cursorScrollRegionUp region spans pages" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); // At this point: @@ -4909,7 +4929,7 @@ test "Screen: cursorScrollRegionUp region spans pages" { // verify the style ref counting is intact on the cursor's page. try s.testWriteString("X"); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const styles = page.styles.count(); try testing.expectEqual(@as(usize, 1), styles); } @@ -4924,10 +4944,10 @@ test "Screen: cursorScrollRegionUp region spans pages with background SGR" { // We need to get the cursor to a new page. See the previous test // for a diagram of the page layout. - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); s.cursorAbsolute(0, 3); @@ -4983,7 +5003,7 @@ test "Screen: cursorScrollRegionUp with styled erased row" { // The style should be gone from the page since the only user // was the erased row. - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -5221,10 +5241,10 @@ test "Screen: scroll above same page but cursor on previous page" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -5302,10 +5322,10 @@ test "Screen: scroll above same page but cursor on previous page last row" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 2) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1A\n2B\n3C\n4D\n5E"); @@ -5380,8 +5400,8 @@ test "Screen: scroll above same page but cursor on previous page last row" { // This catches a case of memory corruption where the cursor // is moved between pages without accounting for style refs. try s.setAttribute(.{ .reset_bg = {} }); - s.pages.pages.first.?.data.assertIntegrity(); - s.pages.pages.last.?.data.assertIntegrity(); + s.pages.pages.first.?.page().assertIntegrity(); + s.pages.pages.last.?.page().assertIntegrity(); } test "Screen: scroll above creates new page" { @@ -5392,10 +5412,10 @@ test "Screen: scroll above creates new page" { defer s.deinit(); // We need to get the cursor to a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -5464,10 +5484,10 @@ test "Screen: scroll above with cursor on non-final row" { defer s.deinit(); // Get the cursor to be 2 rows above a new page - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); // Write 3 lines of text, forcing the last line into the first // row of a new page. Move our cursor onto the previous page. @@ -5540,10 +5560,10 @@ test "Screen: scroll above no scrollback bottom of page" { var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 0 }); defer s.deinit(); - const first_page_size = s.pages.pages.first.?.data.capacity.rows; - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + const first_page_size = s.pages.pages.first.?.capacity().rows; + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 3) |_| try s.testWriteString("\n"); - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.setAttribute(.{ .direct_color_bg = .{ .r = 155 } }); try s.testWriteString("1ABCD\n2EFGH\n3IJKL"); @@ -6949,7 +6969,7 @@ test "Screen: resize more cols bounded scrollback keeps viewport valid" { // That cuts the total row count down and is what stresses the viewport pin. var it = s.pages.pageIterator(.right_down, .{ .screen = .{} }, null); while (it.next()) |chunk| { - const page = &chunk.node.data; + const page = chunk.node.page(); for (chunk.start..chunk.end) |y| { const rac = page.getRowAndCell(0, y); if (y % 2 == 0) { @@ -9583,7 +9603,7 @@ test "Screen: selectionString with zero width joiner" { const cell = pin.rowAndCell().cell; try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = pin.node.data.lookupGrapheme(cell).?; + const cps = pin.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } @@ -9718,14 +9738,14 @@ test "Screen: selectionString multi-page" { var s = try init(alloc, .{ .cols = 10, .rows = 3, .max_scrollback = 2048 }); defer s.deinit(); - const first_page_size = s.pages.pages.first.?.data.capacity.rows; + const first_page_size = s.pages.pages.first.?.capacity().rows; // Lazy way to seek to the first page boundary. - s.pages.pages.first.?.data.pauseIntegrityChecks(true); + s.pages.pages.first.?.page().pauseIntegrityChecks(true); for (0..first_page_size - 1) |_| { try s.testWriteString("\n"); } - s.pages.pages.first.?.data.pauseIntegrityChecks(false); + s.pages.pages.first.?.page().pauseIntegrityChecks(false); try s.testWriteString("123456789\n!@#$%^&*(\n123456789"); @@ -9816,21 +9836,21 @@ test "Screen: hyperlink start/end" { defer s.deinit(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } try s.startHyperlink("http://example.com", null); try testing.expect(s.cursor.hyperlink_id != 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -9844,7 +9864,7 @@ test "Screen: hyperlink reuse" { try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } @@ -9857,14 +9877,14 @@ test "Screen: hyperlink reuse" { try s.startHyperlink("http://example.com", null); try testing.expectEqual(id, s.cursor.hyperlink_id); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -9884,7 +9904,7 @@ test "Screen: hyperlink cursor state on resize" { try s.startHyperlink("http://example.com", null); try testing.expect(s.cursor.hyperlink_id != 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } @@ -9892,14 +9912,14 @@ test "Screen: hyperlink cursor state on resize" { try s.resize(.{ .cols = 10, .rows = 10 }); try testing.expect(s.cursor.hyperlink_id != 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } s.endHyperlink(); try testing.expect(s.cursor.hyperlink_id == 0); { - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -9920,8 +9940,8 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { // Figure out how many cells should can have hyperlinks in this page, // and write twice that number, to guarantee the capacity needs to be // increased at some point. - const base_capacity = s.cursor.page_pin.node.data.hyperlinkCapacity(); - const base_string_bytes = s.cursor.page_pin.node.data.capacity.string_bytes; + const base_capacity = s.cursor.page_pin.node.page().hyperlinkCapacity(); + const base_string_bytes = s.cursor.page_pin.node.capacity().string_bytes; for (0..base_capacity * 2) |_| { try s.cursorSetHyperlink(); if (s.cursor.x >= s.pages.cols - 1) { @@ -9933,9 +9953,9 @@ test "Screen: cursorSetHyperlink OOM + URI too large for string alloc" { } // Make sure the capacity really did increase. - try testing.expect(base_capacity < s.cursor.page_pin.node.data.hyperlinkCapacity()); + try testing.expect(base_capacity < s.cursor.page_pin.node.page().hyperlinkCapacity()); // And that our string_bytes increased as well. - try testing.expect(base_string_bytes < s.cursor.page_pin.node.data.capacity.string_bytes); + try testing.expect(base_string_bytes < s.cursor.page_pin.node.capacity().string_bytes); } test "Screen: increaseCapacity cursor style ref count preserved" { @@ -9958,7 +9978,7 @@ test "Screen: increaseCapacity cursor style ref count preserved" { const old_style = s.cursor.style; { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); // 5 chars + cursor = 6 refs try testing.expectEqual( 6, @@ -9987,7 +10007,7 @@ test "Screen: increaseCapacity cursor style ref count preserved" { // After increaseCapacity, the 5 chars are cloned (5 refs) and // the cursor's style is re-added (1 ref) = 6 total. { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); try testing.expectEqual(6, ref_count); } @@ -10011,7 +10031,7 @@ test "Screen: increaseCapacity cursor hyperlink ref count preserved" { try testing.expect(s.pages.pages.first == s.cursor.page_pin.node); { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); // Cursor has the hyperlink active = 1 count in hyperlink_set try testing.expectEqual(1, page.hyperlink_set.count()); try testing.expect(s.cursor.hyperlink_id != 0); @@ -10031,7 +10051,7 @@ test "Screen: increaseCapacity cursor hyperlink ref count preserved" { // After increaseCapacity, the hyperlink is re-added to the new page. { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } } @@ -10061,7 +10081,7 @@ test "Screen: increaseCapacity cursor with both style and hyperlink preserved" { const old_style = s.cursor.style; { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); // 5 chars + cursor = 6 refs for bold style try testing.expectEqual( 6, @@ -10091,7 +10111,7 @@ test "Screen: increaseCapacity cursor with both style and hyperlink preserved" { // After increaseCapacity, both style and hyperlink are re-added to the new page. { - const page = &s.pages.pages.last.?.data; + const page = s.pages.pages.last.?.page(); const ref_count = page.styles.refCount(page.memory, s.cursor.style_id); try testing.expectEqual(6, ref_count); try testing.expectEqual(1, page.hyperlink_set.count()); @@ -10130,11 +10150,11 @@ test "Screen: increaseCapacity non-cursor page returns early" { // Grow pages until we have multiple pages. The cursor's pin stays on // the first page since we're just adding rows. const first_page_node = s.pages.pages.first.?; - first_page_node.data.pauseIntegrityChecks(true); - for (0..first_page_node.data.capacity.rows - first_page_node.data.size.rows) |_| { + first_page_node.page().pauseIntegrityChecks(true); + for (0..first_page_node.capacity().rows - first_page_node.rows()) |_| { _ = try s.pages.grow(); } - first_page_node.data.pauseIntegrityChecks(false); + first_page_node.page().pauseIntegrityChecks(false); _ = try s.pages.grow(); // Now we have two pages @@ -10145,8 +10165,8 @@ test "Screen: increaseCapacity non-cursor page returns early" { try testing.expect(s.cursor.page_pin.node == s.pages.pages.first.?); try testing.expect(s.cursor.page_pin.node != second_page); - const second_page_styles_cap = second_page.data.capacity.styles; - const cursor_page_styles_cap = s.cursor.page_pin.node.data.capacity.styles; + const second_page_styles_cap = second_page.capacity().styles; + const cursor_page_styles_cap = s.cursor.page_pin.node.capacity().styles; // Call increaseCapacity on the second page (NOT the cursor's page) const new_second_page = try s.increaseCapacity(second_page, .styles); @@ -10154,13 +10174,13 @@ test "Screen: increaseCapacity non-cursor page returns early" { // The second page should have increased capacity try testing.expectEqual( second_page_styles_cap * 2, - new_second_page.data.capacity.styles, + new_second_page.capacity().styles, ); // The cursor's page (first page) should be unchanged try testing.expectEqual( cursor_page_styles_cap, - s.cursor.page_pin.node.data.capacity.styles, + s.cursor.page_pin.node.capacity().styles, ); // Cursor state should be completely unchanged since we didn't touch its page @@ -10195,14 +10215,14 @@ test "Screen: cursorDown to page with insufficient capacity" { defer s.deinit(); // Scroll down enough to create a second page - const start_page = &s.pages.pages.last.?.data; + const start_page = s.pages.pages.last.?.page(); const rem = start_page.capacity.rows; start_page.pauseIntegrityChecks(true); for (0..rem) |_| try s.cursorDownOrScroll(); start_page.pauseIntegrityChecks(false); // Cursor should now be on a new page - const new_page = &s.cursor.page_pin.node.data; + const new_page = s.cursor.page_pin.node.page(); try testing.expect(start_page != new_page); // Fill new_page's style map to capacity. When we move INTO this page @@ -10232,7 +10252,7 @@ test "Screen: cursorDown to page with insufficient capacity" { if (s.cursor.page_pin.down(1)) |next_pin| { if (next_pin.node != cur_node) { // Cursor is at 'row', moving down crosses to new_page - try testing.expect(&next_pin.node.data == new_page); + try testing.expect(next_pin.node.page() == new_page); // This cursorDown triggers the bug: the local page_pin copy // becomes stale after increaseCapacity, causing rowAndCell() @@ -10267,7 +10287,7 @@ test "Screen setAttribute increases capacity when style map is full" { try s.testWriteString("line1\nline2\nline3\nline4\nline5"); // Get the page and fill its style map to capacity - const page = &s.cursor.page_pin.node.data; + const page = s.cursor.page_pin.node.page(); const original_styles_capacity = page.capacity.styles; // Fill the style map to capacity using the StyleSet's layout capacity @@ -10298,7 +10318,7 @@ test "Screen setAttribute increases capacity when style map is full" { try testing.expect(s.cursor.style_id != style.default_id); // Either the capacity increased or the page was split/changed - const current_page = &s.cursor.page_pin.node.data; + const current_page = s.cursor.page_pin.node.page(); const capacity_increased = current_page.capacity.styles > original_styles_capacity; const page_changed = current_page != page; try testing.expect(capacity_increased or page_changed); @@ -10324,7 +10344,7 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { // Increase the page's style capacity to max by repeatedly calling increaseCapacity // Use Screen.increaseCapacity to properly maintain cursor state const max_styles = std.math.maxInt(size.CellCountInt); - while (s.cursor.page_pin.node.data.capacity.styles < max_styles) { + while (s.cursor.page_pin.node.capacity().styles < max_styles) { _ = s.increaseCapacity( s.cursor.page_pin.node, .styles, @@ -10332,7 +10352,7 @@ test "Screen setAttribute splits page on OutOfSpace at max styles" { } // Get the page reference after increaseCapacity - cursor may have moved - var page = &s.cursor.page_pin.node.data; + var page = s.cursor.page_pin.node.page(); try testing.expectEqual(max_styles, page.capacity.styles); // Fill the style map to capacity using the StyleSet's layout capacity diff --git a/src/terminal/Selection.zig b/src/terminal/Selection.zig index 5258210cf..8aacc9ba9 100644 --- a/src/terminal/Selection.zig +++ b/src/terminal/Selection.zig @@ -432,7 +432,7 @@ pub fn adjust( var current = end_pin.*; while (current.down(1)) |next| : (current = next) { const rac = next.rowAndCell(); - const cells = next.node.data.getCells(rac.row); + const cells = next.node.page().getCells(rac.row); if (page.Cell.hasTextAny(cells)) { end_pin.* = next; break; @@ -494,7 +494,7 @@ pub fn adjust( ); while (it.next()) |next| { const rac = next.rowAndCell(); - const cells = next.node.data.getCells(rac.row); + const cells = next.node.page().getCells(rac.row); if (page.Cell.hasTextAny(cells)) { end_pin.* = next; end_pin.x = @intCast(cells.len - 1); @@ -505,7 +505,7 @@ pub fn adjust( .beginning_of_line => end_pin.x = 0, - .end_of_line => end_pin.x = end_pin.node.data.size.cols - 1, + .end_of_line => end_pin.x = end_pin.node.cols() - 1, } } diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 74d6b8f0f..05af1075e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -642,7 +642,9 @@ fn printSliceFill( const avail: usize = right_limit - cursor.x; assert(avail > 0); - const page = &cursor.page_pin.node.data; + // The cursor caches live row and cell pointers into this mapping, so + // its page cannot be compressed while this print path is active. + const page = cursor.page_pin.node.pageAssumeResident(); const cells: [*]Cell = @ptrCast(cursor.page_cell); const style_id = cursor.style_id; const template: Cell = .{ @@ -946,7 +948,7 @@ pub fn print(self: *Terminal, c: u21) !void { const grapheme_break = brk: { var state: uucode.grapheme.BreakState = .default; if (prev.cell.hasGrapheme()) { - const cps = self.screens.active.cursor.page_pin.node.data.lookupGrapheme(prev.cell).?; + const cps = self.screens.active.cursor.page_pin.node.page().lookupGrapheme(prev.cell).?; for (cps) |cp2| { // log.debug("cp1={x} cp2={x}", .{ previous_codepoint, cp2 }); assert(!unicode.graphemeBreak(previous_codepoint, cp2, &state)); @@ -1003,19 +1005,19 @@ pub fn print(self: *Terminal, c: u21) !void { const old_rac = old_pin.rowAndCell(); if (new_pin.node == old_pin.node) { - new_pin.node.data.moveGrapheme(prev.cell, new_rac.cell); + new_pin.node.page().moveGrapheme(prev.cell, new_rac.cell); prev.cell.content_tag = .codepoint; new_rac.cell.content_tag = .codepoint_grapheme; new_rac.row.grapheme = true; } else { - const cps = old_pin.node.data.lookupGrapheme(old_rac.cell).?; + const cps = old_pin.node.page().lookupGrapheme(old_rac.cell).?; for (cps) |cp| { try self.screens.active.appendGrapheme(new_rac.cell, cp); } - old_pin.node.data.clearGrapheme(old_rac.cell); + old_pin.node.page().clearGrapheme(old_rac.cell); } - old_pin.node.data.updateRowGraphemeFlag(old_rac.row); + old_pin.node.page().updateRowGraphemeFlag(old_rac.row); } // Point prev.cell to our new previous cell that @@ -1282,7 +1284,7 @@ fn printCell( const spacer_cell = self.screens.active.cursorCellRight(1); self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, spacer_cell[0..1], ); @@ -1308,7 +1310,7 @@ fn printCell( const wide_cell = self.screens.active.cursorCellLeft(1); self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, wide_cell[0..1], ); @@ -1331,7 +1333,7 @@ fn printCell( // If the prior value had graphemes, clear those if (cell.hasGrapheme()) { - const page = &self.screens.active.cursor.page_pin.node.data; + const page = self.screens.active.cursor.page_pin.node.page(); page.clearGrapheme(cell); page.updateRowGraphemeFlag(self.screens.active.cursor.page_row); } @@ -1340,7 +1342,7 @@ fn printCell( // cell's new style will be different after writing. const style_changed = cell.style_id != self.screens.active.cursor.style_id; if (style_changed) { - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // Release the old style. if (cell.style_id != style.default_id) { @@ -1363,7 +1365,7 @@ fn printCell( }; if (style_changed) { - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // Use the new style. if (cell.style_id != style.default_id) { @@ -1392,7 +1394,7 @@ fn printCell( }; } else if (had_hyperlink) { // If the previous cell had a hyperlink then we need to clear it. - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); page.clearHyperlink(cell); page.updateRowHyperlinkFlag(self.screens.active.cursor.page_row); } @@ -2439,8 +2441,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { const off_rac = off_p.rowAndCell(); const off_row: *Row = off_rac.row; - self.rowWillBeShifted(&cur_p.node.data, cur_row); - self.rowWillBeShifted(&off_p.node.data, off_row); + self.rowWillBeShifted(cur_p.node.page(), cur_row); + self.rowWillBeShifted(off_p.node.page(), off_row); // If our scrolling region is full width, then we unset wrap. if (!left_right) { @@ -2458,8 +2460,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { // If our page doesn't match, then we need to do a copy from // one page to another. This is the slow path. if (src_p.node != dst_p.node) { - dst_p.node.data.clonePartialRowFrom( - &src_p.node.data, + dst_p.node.page().clonePartialRowFrom( + src_p.node.page(), dst_row, src_row, self.scrolling_region.left, @@ -2522,11 +2524,11 @@ pub fn insertLines(self: *Terminal, count: usize) void { src_row.* = dst; // Ensure what we did didn't corrupt the page - cur_p.node.data.assertIntegrity(); + cur_p.node.page().assertIntegrity(); } else { // Left/right scroll margins we have to // copy cells, which is much slower... - const page = &cur_p.node.data; + const page = cur_p.node.page(); page.moveCells( src_row, self.scrolling_region.left, @@ -2538,8 +2540,8 @@ pub fn insertLines(self: *Terminal, count: usize) void { } } else { // Clear the cells for this row, it has been shifted. - self.rowWillBeShifted(&cur_p.node.data, cur_row); - const page = &cur_p.node.data; + self.rowWillBeShifted(cur_p.node.page(), cur_row); + const page = cur_p.node.page(); const cells = page.getCells(cur_row); self.screens.active.clearCells( page, @@ -2634,8 +2636,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { const off_rac = off_p.rowAndCell(); const off_row: *Row = off_rac.row; - self.rowWillBeShifted(&cur_p.node.data, cur_row); - self.rowWillBeShifted(&off_p.node.data, off_row); + self.rowWillBeShifted(cur_p.node.page(), cur_row); + self.rowWillBeShifted(off_p.node.page(), off_row); // If our scrolling region is full width, then we unset wrap. if (!left_right) { @@ -2653,8 +2655,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { // If our page doesn't match, then we need to do a copy from // one page to another. This is the slow path. if (src_p.node != dst_p.node) { - dst_p.node.data.clonePartialRowFrom( - &src_p.node.data, + dst_p.node.page().clonePartialRowFrom( + src_p.node.page(), dst_row, src_row, self.scrolling_region.left, @@ -2710,11 +2712,11 @@ pub fn deleteLines(self: *Terminal, count: usize) void { src_row.* = dst; // Ensure what we did didn't corrupt the page - cur_p.node.data.assertIntegrity(); + cur_p.node.page().assertIntegrity(); } else { // Left/right scroll margins we have to // copy cells, which is much slower... - const page = &cur_p.node.data; + const page = cur_p.node.page(); page.moveCells( src_row, self.scrolling_region.left, @@ -2726,8 +2728,8 @@ pub fn deleteLines(self: *Terminal, count: usize) void { } } else { // Clear the cells for this row, it's from out of bounds. - self.rowWillBeShifted(&cur_p.node.data, cur_row); - const page = &cur_p.node.data; + self.rowWillBeShifted(cur_p.node.page(), cur_row); + const page = cur_p.node.page(); const cells = page.getCells(cur_row); self.screens.active.clearCells( page, @@ -2781,7 +2783,7 @@ pub fn insertBlanks(self: *Terminal, count: usize) void { // left is just the cursor position but as a multi-pointer const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // If our X is a wide spacer tail then we need to erase the // previous cell too so we don't split a multi-cell character. @@ -2864,7 +2866,7 @@ pub fn deleteChars(self: *Terminal, count_req: usize) void { // left is just the cursor position but as a multi-pointer const left: [*]Cell = @ptrCast(self.screens.active.cursor.page_cell); - var page = &self.screens.active.cursor.page_pin.node.data; + var page = self.screens.active.cursor.page_pin.node.page(); // Remaining cols from our cursor to the right margin. const rem = self.scrolling_region.right - self.screens.active.cursor.x + 1; @@ -2941,7 +2943,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { // mode was not ISO we also always ignore protection attributes. if (self.screens.active.protected_mode != .iso) { self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[0..count], ); @@ -2949,7 +2951,7 @@ pub fn eraseChars(self: *Terminal, count_req: usize) void { } self.screens.active.clearUnprotectedCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[0..count], ); @@ -3021,7 +3023,7 @@ pub fn eraseLine( // to fill the entire line. if (!protected) { self.screens.active.clearCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[start..end], ); @@ -3029,7 +3031,7 @@ pub fn eraseLine( } self.screens.active.clearUnprotectedCells( - &self.screens.active.cursor.page_pin.node.data, + self.screens.active.cursor.page_pin.node.page(), self.screens.active.cursor.page_row, cells[start..end], ); @@ -3203,7 +3205,7 @@ pub fn decaln(self: *Terminal) !void { // Fill with Es by moving the cursor but reset it after. while (true) { - const page = &self.screens.active.cursor.page_pin.node.data; + const page = self.screens.active.cursor.page_pin.node.page(); const row = self.screens.active.cursor.page_row; const cells_multi: [*]Cell = row.cells.ptr(page.memory); const cells = cells_multi[0..page.size.cols]; @@ -3838,19 +3840,19 @@ test "Terminal: input glitch text" { // Get our initial grapheme capacity. const grapheme_cap = cap: { const page = t.screens.active.pages.pages.first.?; - break :cap page.data.capacity.grapheme_bytes; + break :cap page.capacity().grapheme_bytes; }; // Print glitch text until our capacity changes while (true) { const page = t.screens.active.pages.pages.first.?; - if (page.data.capacity.grapheme_bytes != grapheme_cap) break; + if (page.capacity().grapheme_bytes != grapheme_cap) break; try t.printString(glitch); } // We're testing to make sure that grapheme capacity gets increased. const page = t.screens.active.pages.pages.first.?; - try testing.expect(page.data.capacity.grapheme_bytes > grapheme_cap); + try testing.expect(page.capacity().grapheme_bytes > grapheme_cap); } test "Terminal: zero-width character at start" { @@ -4086,7 +4088,7 @@ test "Terminal: print over wide char with bold" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -4097,7 +4099,7 @@ test "Terminal: print over wide char with bold" { // verify our style is gone { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -4116,7 +4118,7 @@ test "Terminal: print over wide char with bg color" { try t.print(0x1F600); // Smiley face // verify we have styles in our style map { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -4127,7 +4129,7 @@ test "Terminal: print over wide char with bg color" { // verify our style is gone { - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -4158,7 +4160,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } { @@ -4167,7 +4169,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 2, .y = 0 } }).?; @@ -4175,7 +4177,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0x1F469), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } { @@ -4184,7 +4186,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 4, .y = 0 } }).?; @@ -4192,7 +4194,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0x1F467), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } { const list_cell = t.screens.active.pages.getCell(.{ .screen = .{ .x = 5, .y = 0 } }).?; @@ -4200,7 +4202,7 @@ test "Terminal: print multicodepoint grapheme, disabled mode 2027" { try testing.expectEqual(@as(u21, 0), cell.content.codepoint); try testing.expect(!cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.spacer_tail, cell.wide); - try testing.expect(list_cell.node.data.lookupGrapheme(cell) == null); + try testing.expect(list_cell.node.page().lookupGrapheme(cell) == null); } try testing.expect(t.isDirty(.{ .screen = .{ .x = 0, .y = 0 } })); @@ -4266,7 +4268,7 @@ test "Terminal: VS16 doesn't make character with 2027 disabled" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4347,7 +4349,7 @@ test "Terminal: variation selectors apply to preceding codepoint" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x1F3F4), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x200D, 0x2620, 0xFE0F }, list_cell.node.page().lookupGrapheme(cell).?); } test "Terminal: print multicodepoint grapheme, mode 2027" { @@ -4380,7 +4382,7 @@ test "Terminal: print multicodepoint grapheme, mode 2027" { try testing.expectEqual(@as(u21, 0x1F468), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 4), cps.len); } { @@ -4588,7 +4590,7 @@ test "Terminal: VS15 to make narrow character" { try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4623,7 +4625,7 @@ test "Terminal: VS15 on already narrow emoji" { try testing.expectEqual(@as(u21, 0x26C8), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4682,7 +4684,7 @@ test "Terminal: print invalid VS15 in emoji ZWJ sequence" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '\u{1F469}'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ '\u{200D}', '\u{1F466}' }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4734,7 +4736,7 @@ test "Terminal: VS15 to make narrow character with pending wrap" { try testing.expectEqual(@as(u21, 0x2614), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.narrow, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } @@ -4785,7 +4787,7 @@ test "Terminal: VS16 to make wide character on next line" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4836,7 +4838,7 @@ test "Terminal: VS16 to make wide character on next line with hyperlink" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); try testing.expect(cell.hyperlink); } @@ -4874,7 +4876,7 @@ test "Terminal: VS16 to make wide character with pending wrap" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, '#'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0xFE0F}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -4913,7 +4915,7 @@ test "Terminal: VS16 to make wide character with mode 2027" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -4944,7 +4946,7 @@ test "Terminal: VS16 repeated with mode 2027" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } { @@ -4953,7 +4955,7 @@ test "Terminal: VS16 repeated with mode 2027" { try testing.expectEqual(@as(u21, 0x2764), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); try testing.expectEqual(Cell.Wide.wide, cell.wide); - const cps = list_cell.node.data.lookupGrapheme(cell).?; + const cps = list_cell.node.page().lookupGrapheme(cell).?; try testing.expectEqual(@as(usize, 1), cps.len); } } @@ -5046,7 +5048,7 @@ test "Terminal: print grapheme ò (o with nonspacing mark) should be narrow" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'o'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{0x0300}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } } @@ -5075,7 +5077,7 @@ test "Terminal: print Devanagari grapheme should be wide" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -5123,7 +5125,7 @@ test "Terminal: print Devanagari grapheme should be wide on next line" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -5144,7 +5146,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" { t.cursorDown(rows - 1); - for (rows..t.screens.active.pages.pages.first.?.data.capacity.rows) |_| { + for (rows..t.screens.active.pages.pages.first.?.capacity().rows) |_| { try t.index(); } @@ -5182,7 +5184,7 @@ test "Terminal: print Devanagari grapheme should be wide on next page" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 0x0915), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{ 0x094D, 0x200D, 0x0937 }, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.wide, cell.wide); } { @@ -5215,7 +5217,7 @@ test "Terminal: print invalid VS16 with second char (combining)" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'n'), cell.content.codepoint); try testing.expect(cell.hasGrapheme()); - try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.data.lookupGrapheme(cell).?); + try testing.expectEqualSlices(u21, &.{'\u{0303}'}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { @@ -5277,7 +5279,7 @@ test "Terminal: overwrite multicodepoint grapheme clears grapheme data" { try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -5317,7 +5319,7 @@ test "Terminal: overwrite multicodepoint grapheme tail clears grapheme data" { try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); // Move back and overwrite wide @@ -5368,7 +5370,7 @@ test "Terminal: print breaks valid grapheme cluster with Prepend + ASCII for spe try testing.expect(!cell.hasGrapheme()); // This is what we'd expect if we did break correctly: //try testing.expect(cell.hasGrapheme()); - //try testing.expectEqualSlices(u21, &.{'1'}, list_cell.node.data.lookupGrapheme(cell).?); + //try testing.expectEqualSlices(u21, &.{'1'}, list_cell.node.page().lookupGrapheme(cell).?); try testing.expectEqual(Cell.Wide.narrow, cell.wide); } { @@ -5816,7 +5818,7 @@ test "Terminal: print with hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } @@ -5843,7 +5845,7 @@ test "Terminal: print over cell with same hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } @@ -5870,7 +5872,7 @@ test "Terminal: print and end hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { @@ -5905,7 +5907,7 @@ test "Terminal: print and change hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (3..6) |x| { @@ -5915,7 +5917,7 @@ test "Terminal: print and change hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 2), id); } @@ -5939,7 +5941,7 @@ test "Terminal: overwrite hyperlink" { .x = @intCast(x), .y = 0, } }).?; - const page = &list_cell.node.data; + const page = list_cell.node.page(); const row = list_cell.row; try testing.expect(!row.hyperlink); const cell = list_cell.cell; @@ -6754,7 +6756,7 @@ test "Terminal: insertLines handles style refs" { try t.setAttribute(.{ .unset = {} }); // verify we have styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(2, 2); @@ -6838,7 +6840,7 @@ test "Terminal: insertLines across page boundary marks all shifted rows dirty" { defer t.deinit(alloc); const first_page = t.screens.active.pages.pages.first.?; - const first_page_nrows = first_page.data.capacity.rows; + const first_page_nrows = first_page.capacity().rows; // Fill up the first page minus 3 rows for (0..first_page_nrows - 3) |_| try t.linefeed(); @@ -7170,9 +7172,9 @@ test "Terminal: scrollUp moves hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { @@ -7184,7 +7186,7 @@ test "Terminal: scrollUp moves hyperlink" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -7221,7 +7223,7 @@ test "Terminal: scrollUp clears hyperlink" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -7323,7 +7325,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (1..4) |x| { @@ -7335,9 +7337,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { @@ -7347,7 +7349,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -7363,9 +7365,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { @@ -7375,7 +7377,7 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (4..6) |x| { @@ -7387,9 +7389,9 @@ test "Terminal: scrollUp left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } } @@ -7670,9 +7672,9 @@ test "Terminal: scrollDown hyperlink moves" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (0..3) |x| { @@ -7684,7 +7686,7 @@ test "Terminal: scrollDown hyperlink moves" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -7794,9 +7796,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (1..4) |x| { @@ -7806,7 +7808,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (4..6) |x| { @@ -7818,9 +7820,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } } @@ -7834,7 +7836,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } for (1..4) |x| { @@ -7846,9 +7848,9 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(1, page.hyperlink_set.count()); } for (4..6) |x| { @@ -7858,7 +7860,7 @@ test "Terminal: scrollDown left/right scroll region hyperlink" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -8092,7 +8094,7 @@ test "Terminal: eraseChars handles refcounted styles" { try t.print('C'); // verify we have styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); t.setCursorPos(1, 1); @@ -8169,7 +8171,7 @@ test "Terminal: eraseChars wide char boundary conditions" { t.setCursorPos(1, 2); t.eraseChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -8199,7 +8201,7 @@ test "Terminal: eraseChars wide char splits proper cell boundaries" { t.setCursorPos(1, 6); // At: て t.eraseChars(4); // Delete: て下 - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -8226,7 +8228,7 @@ test "Terminal: eraseChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.eraseChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -8529,7 +8531,7 @@ test "Terminal: index scrolling with hyperlink" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } { @@ -8541,7 +8543,7 @@ test "Terminal: index scrolling with hyperlink" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -8703,7 +8705,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } { @@ -8715,7 +8717,7 @@ test "Terminal: index bottom of scroll region with hyperlinks" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -8752,9 +8754,9 @@ test "Terminal: index bottom of scroll region clear hyperlinks" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); - const page = &list_cell.node.data; + const page = list_cell.node.page(); try testing.expectEqual(0, page.hyperlink_set.count()); } } @@ -9629,7 +9631,7 @@ test "Terminal: deleteLines across page boundary marks all shifted rows dirty" { defer t.deinit(alloc); const first_page = t.screens.active.pages.pages.first.?; - const first_page_nrows = first_page.data.capacity.rows; + const first_page_nrows = first_page.capacity().rows; // Fill up the first page minus 3 rows for (0..first_page_nrows - 3) |_| try t.linefeed(); @@ -10244,7 +10246,7 @@ test "Terminal: bold style" { const cell = list_cell.cell; try testing.expectEqual(@as(u21, 'A'), cell.content.codepoint); try testing.expect(cell.style_id != 0); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 1); } } @@ -10268,7 +10270,7 @@ test "Terminal: garbage collect overwritten" { } // verify we have no styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 0), page.styles.count()); } @@ -10290,7 +10292,7 @@ test "Terminal: do not garbage collect old styles in use" { } // verify we have no styles in our style map - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); } @@ -10681,7 +10683,7 @@ test "Terminal: insertBlanks deleting graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -10717,7 +10719,7 @@ test "Terminal: insertBlanks shift graphemes" { try t.print(0x1F467); // We should have one cell with graphemes - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.graphemeCount()); t.setCursorPos(1, 1); @@ -10785,7 +10787,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { try testing.expect(row.hyperlink); const cell = list_cell.cell; try testing.expect(cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell).?; + const id = list_cell.node.page().lookupHyperlink(cell).?; try testing.expectEqual(@as(hyperlink.Id, 1), id); } for (0..2) |x| { @@ -10795,7 +10797,7 @@ test "Terminal: insertBlanks shifts hyperlinks" { } }).?; const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -10825,7 +10827,7 @@ test "Terminal: insertBlanks pushes hyperlink off end completely" { try testing.expect(!row.hyperlink); const cell = list_cell.cell; try testing.expect(!cell.hyperlink); - const id = list_cell.node.data.lookupHyperlink(cell); + const id = list_cell.node.page().lookupHyperlink(cell); try testing.expect(id == null); } } @@ -11398,7 +11400,7 @@ test "Terminal: deleteChars wide char boundary conditions" { t.setCursorPos(1, 2); t.deleteChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -11450,7 +11452,7 @@ test "Terminal: deleteChars wide char wrap boundary conditions" { t.setCursorPos(2, 2); t.deleteChars(3); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); { const str = try t.plainString(alloc); @@ -11489,7 +11491,7 @@ test "Terminal: deleteChars wide char across right margin" { t.setCursorPos(1, 2); t.deleteChars(1); - t.screens.active.cursor.page_pin.node.data.assertIntegrity(); + t.screens.active.cursor.page_pin.node.page().assertIntegrity(); // NOTE: This behavior is slightly inconsistent with xterm. xterm // _visually_ splits the wide character (half the wide character shows @@ -11650,14 +11652,14 @@ test "Terminal: restoreCursor uses default style on OutOfSpace" { // Fill the style map to max capacity const max_styles = std.math.maxInt(size.CellCountInt); - while (t.screens.active.cursor.page_pin.node.data.capacity.styles < max_styles) { + while (t.screens.active.cursor.page_pin.node.capacity().styles < max_styles) { _ = t.screens.active.increaseCapacity( t.screens.active.cursor.page_pin.node, .styles, ) catch break; } - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(max_styles, page.capacity.styles); // Fill all style slots using the StyleSet's layout capacity which accounts @@ -12473,8 +12475,8 @@ fn testPrintSliceDifferential( } // Page integrity (styles refcounts, grapheme maps, etc.) must hold. - try t1.screens.active.cursor.page_pin.node.data.verifyIntegrity(alloc); - try t2.screens.active.cursor.page_pin.node.data.verifyIntegrity(alloc); + try t1.screens.active.cursor.page_pin.node.page().verifyIntegrity(alloc); + try t2.screens.active.cursor.page_pin.node.page().verifyIntegrity(alloc); } test "Terminal: printSlice differential fuzz vs print" { @@ -13950,7 +13952,7 @@ test "Terminal: mode 47 copies cursor both directions" { // Verify that our style is set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -13965,7 +13967,7 @@ test "Terminal: mode 47 copies cursor both directions" { // Verify that our style is still set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -14037,7 +14039,7 @@ test "Terminal: mode 1047 copies cursor both directions" { // Verify that our style is set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } @@ -14052,7 +14054,7 @@ test "Terminal: mode 1047 copies cursor both directions" { // Verify that our style is still set { try testing.expect(t.screens.active.cursor.style_id != style.default_id); - const page = &t.screens.active.cursor.page_pin.node.data; + const page = t.screens.active.cursor.page_pin.node.page(); try testing.expectEqual(@as(usize, 1), page.styles.count()); try testing.expect(page.styles.refCount(page.memory, t.screens.active.cursor.style_id) > 0); } diff --git a/src/terminal/c/grid_ref.zig b/src/terminal/c/grid_ref.zig index db7ff2e81..946ca8a1e 100644 --- a/src/terminal/c/grid_ref.zig +++ b/src/terminal/c/grid_ref.zig @@ -98,7 +98,8 @@ pub fn grid_ref_hyperlink_uri( out_len: *usize, ) callconv(lib.calling_conv) Result { const p = ref.toPin() orelse return .invalid_value; - const rac = p.node.data.getRowAndCell(p.x, p.y); + const terminal_page = p.node.page(); + const rac = terminal_page.getRowAndCell(p.x, p.y); const cell = rac.cell; if (!cell.hyperlink) { @@ -106,12 +107,15 @@ pub fn grid_ref_hyperlink_uri( return .success; } - const link_id = p.node.data.lookupHyperlink(cell) orelse { + const link_id = terminal_page.lookupHyperlink(cell) orelse { out_len.* = 0; return .success; }; - const entry = p.node.data.hyperlink_set.get(p.node.data.memory, link_id); - const uri = entry.uri.slice(p.node.data.memory); + const entry = terminal_page.hyperlink_set.get( + terminal_page.memory, + link_id, + ); + const uri = entry.uri.slice(terminal_page.memory); if (out_buf == null or buf_len < uri.len) { out_len.* = uri.len; @@ -133,8 +137,9 @@ pub fn grid_ref_style( if (cell.style_id == stylepkg.default_id) { o.* = .fromStyle(.{}); } else { - o.* = .fromStyle(p.node.data.styles.get( - p.node.data.memory, + const terminal_page = p.node.page(); + o.* = .fromStyle(terminal_page.styles.get( + terminal_page.memory, cell.style_id, ).*); } diff --git a/src/terminal/compress/Page.zig b/src/terminal/compress/Page.zig index 6736b950a..bc103bb66 100644 --- a/src/terminal/compress/Page.zig +++ b/src/terminal/compress/Page.zig @@ -8,11 +8,11 @@ //! as `Page.cloneBuf`: page internals use offsets, so a shallow page copy remains //! valid when its memory contents are restored at the same address. //! -//! The resident memory is deliberately not freed by this type. A future -//! `PageList` integration will keep the virtual range allocated while asking -//! the operating system to discard its physical pages. Keeping the range has -//! two useful properties: the embedded page never contains a dangling pointer, -//! and restoring the page does not require a fallible allocation. +//! The resident memory is deliberately not freed by this type. `PageList` +//! keeps the virtual range allocated while asking the operating system to +//! discard its physical pages. Keeping the range has two useful properties: +//! the embedded page never contains a dangling pointer, and restoring the page +//! does not require a fallible allocation. //! //! The intended state transition is: //! @@ -49,6 +49,13 @@ page: TerminalPage, /// Exact raw LZ4 block for `page.memory`. encoded: []u8, +/// Allocator which owns `encoded`. +/// +/// The allocator's backing state must outlive this compressed page. Storing +/// the allocator here lets a PageList node restore and discard its compressed +/// state without needing a reference back to the PageList. +alloc: Allocator, + /// Return the largest scratch buffer that can produce a useful compressed /// representation for `raw_len` bytes. /// @@ -56,8 +63,8 @@ encoded: []u8, /// compressed representation includes an additional slice compared to a /// resident terminal page, so an encoded block which fills more than this /// buffer cannot reduce resident memory. Limiting the output here lets a -/// future PageList caller borrow a standard page-pool item as scratch instead -/// of retaining a larger, compression-bound allocation. +/// PageList borrow a standard page-pool item as scratch instead of retaining a +/// larger, compression-bound allocation. /// /// Callers are expected to reuse the scratch memory when practical. The /// scratch buffer and hash table are never retained by this type. @@ -80,7 +87,8 @@ pub fn requiredScratch(raw_len: usize) lz4.CompressError!usize { /// scratch buffer must be at least `requiredScratch(source.memory.len)` bytes. /// It must not overlap the source memory. On success, the returned value aliases /// the source's resident mapping and owns only its exact-sized `encoded` -/// allocation. +/// allocation. The allocator and its backing state must remain valid until +/// `deinit` is called. /// /// Returns null if retaining the compressed representation would not use less /// memory than the resident representation. An `OutputTooSmall` result from @@ -119,6 +127,7 @@ pub fn init( return .{ .page = source.*, .encoded = try alloc.dupe(u8, scratch[0..encoded_len]), + .alloc = alloc, }; } @@ -139,8 +148,8 @@ pub fn restore(self: *const Page) lz4.DecompressError!TerminalPage { /// /// This intentionally does not free `page.memory`. The PageList node which /// supplied the source page continues to own that pool or heap allocation. -pub fn deinit(self: *Page, alloc: Allocator) void { - alloc.free(self.encoded); +pub fn deinit(self: *Page) void { + self.alloc.free(self.encoded); self.* = undefined; } @@ -194,7 +203,7 @@ test "compressed Page retained mapping round trip" { scratch, &table, )).?; - defer compressed.deinit(testing.allocator); + defer compressed.deinit(); try testing.expectEqual(memory_ptr, compressed.page.memory.ptr); try testing.expectEqual(memory_len, compressed.page.memory.len); @@ -317,7 +326,7 @@ test "compressed Page can retry after malformed encoded data" { scratch, &table, )).?; - defer compressed.deinit(testing.allocator); + defer compressed.deinit(); const full_encoded = compressed.encoded; const first_byte = full_encoded[0]; diff --git a/src/terminal/formatter.zig b/src/terminal/formatter.zig index a375b4dd7..553ea28d0 100644 --- a/src/terminal/formatter.zig +++ b/src/terminal/formatter.zig @@ -747,7 +747,7 @@ pub const PageListFormatter = struct { assert(chunk.start < chunk.end); assert(chunk.end > 0); - var formatter: PageFormatter = .init(&chunk.node.data, self.opts); + var formatter: PageFormatter = .init(chunk.node.page(), self.opts); formatter.start_y = chunk.start; formatter.end_y = chunk.end - 1; formatter.trailing_state = page_state; @@ -1612,7 +1612,7 @@ test "Page plain single line" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); // Test our point map. @@ -1659,7 +1659,7 @@ test "Page plain single line soft-wrapped unwrapped" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true, @@ -1729,7 +1729,7 @@ test "Page plain single wide char" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); // Test our point map. @@ -1820,7 +1820,7 @@ test "Page plain single wide char soft-wrapped unwrapped" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.opts.unwrap = true; @@ -1937,7 +1937,7 @@ test "Page plain multiline" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -1988,7 +1988,7 @@ test "Page plain multiline rectangle" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 1; formatter.end_x = 3; @@ -2042,7 +2042,7 @@ test "Page plain multi blank lines" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2095,7 +2095,7 @@ test "Page plain trailing blank lines" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2148,7 +2148,7 @@ test "Page plain trailing whitespace" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2201,7 +2201,7 @@ test "Page plain trailing whitespace no trim" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .trim = false, @@ -2255,7 +2255,7 @@ test "Page plain with prior trailing state rows" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.trailing_state = .{ .rows = 2, .cells = 0 }; @@ -2301,7 +2301,7 @@ test "Page plain with prior trailing state cells no wrapped line" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.trailing_state = .{ .rows = 0, .cells = 3 }; @@ -2346,7 +2346,7 @@ test "Page plain with prior trailing state cells with wrap continuation" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Surgically modify the first row to be a wrap continuation const row = page.getRow(0); @@ -2400,7 +2400,7 @@ test "Page plain soft-wrapped without unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2449,7 +2449,7 @@ test "Page plain soft-wrapped with unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2497,7 +2497,7 @@ test "Page plain soft-wrapped 3 lines without unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2551,7 +2551,7 @@ test "Page plain soft-wrapped 3 lines with unwrap" { try testing.expect(pages.pages.first != null); try testing.expect(pages.pages.first == pages.pages.last); - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true }); var point_map: std.ArrayList(Coordinate) = .empty; @@ -2600,7 +2600,7 @@ test "Page plain start_y subset" { s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; @@ -2647,7 +2647,7 @@ test "Page plain end_y subset" { s.nextSlice("hello\r\nworld\r\ntest"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 1; @@ -2694,7 +2694,7 @@ test "Page plain start_y and end_y range" { s.nextSlice("hello\r\nworld\r\ntest\r\nfoo"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; @@ -2742,7 +2742,7 @@ test "Page plain start_y out of bounds" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 30; @@ -2780,7 +2780,7 @@ test "Page plain end_y greater than rows" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 30; @@ -2823,7 +2823,7 @@ test "Page plain end_y less than start_y" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 5; @@ -2862,7 +2862,7 @@ test "Page plain start_x on first row only" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; @@ -2904,7 +2904,7 @@ test "Page plain end_x on last row only" { s.nextSlice("first line\r\nsecond line\r\nthird line"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_y = 2; @@ -2957,7 +2957,7 @@ test "Page plain start_x and end_x multiline" { s.nextSlice("hello world\r\ntest case\r\nfoo bar"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; @@ -3014,7 +3014,7 @@ test "Page plain start_x out of bounds" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 100; @@ -3052,7 +3052,7 @@ test "Page plain end_x greater than cols" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.end_x = 100; @@ -3094,7 +3094,7 @@ test "Page plain end_x less than start_x single row" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 10; @@ -3134,7 +3134,7 @@ test "Page plain start_y non-zero ignores trailing state" { s.nextSlice("hello\r\nworld"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 1; @@ -3178,7 +3178,7 @@ test "Page plain start_x non-zero ignores trailing state" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_x = 6; @@ -3222,7 +3222,7 @@ test "Page plain start_y and start_x zero uses trailing state" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); formatter.start_y = 0; @@ -3274,7 +3274,7 @@ test "Page plain single line with styling" { try testing.expect(pages.pages.first == pages.pages.last); // Create the formatter - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .plain); var point_map: std.ArrayList(Coordinate) = .empty; @@ -3315,7 +3315,7 @@ test "Page VT single line plain text" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3354,7 +3354,7 @@ test "Page VT single line with bold" { s.nextSlice("\x1b[1mhello\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3400,7 +3400,7 @@ test "Page VT multiple styles" { s.nextSlice("\x1b[1mhello \x1b[3mworld\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3435,7 +3435,7 @@ test "Page VT with foreground color" { s.nextSlice("\x1b[31mred\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3481,7 +3481,7 @@ test "Page VT with background and foreground colors" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .vt, @@ -3518,7 +3518,7 @@ test "Page VT multi-line with styles" { s.nextSlice("\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3555,7 +3555,7 @@ test "Page VT duplicate style not emitted twice" { s.nextSlice("\x1b[1mhel\x1b[1mlo\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); @@ -3624,7 +3624,7 @@ test "PageList plain spanning two pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page almost completely for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3697,7 +3697,7 @@ test "PageList soft-wrapped line spanning two pages without unwrap" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page with soft-wrapped content for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3761,7 +3761,7 @@ test "PageList soft-wrapped line spanning two pages with unwrap" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page with soft-wrapped content for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3822,7 +3822,7 @@ test "PageList VT spanning two pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill the first page almost completely for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3928,7 +3928,7 @@ test "PageList plain with x offset spanning two pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_rows = pages.pages.first.?.data.capacity.rows; + const first_page_rows = pages.pages.first.?.capacity().rows; // Fill first page almost completely for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -3950,7 +3950,7 @@ test "PageList plain with x offset spanning two pages" { defer pin_map.deinit(alloc); var formatter: PageListFormatter = .init(pages, .plain); - formatter.top_left = .{ .node = first_node, .y = first_node.data.size.rows - 1, .x = 6 }; + formatter.top_left = .{ .node = first_node, .y = first_node.rows() - 1, .x = 6 }; formatter.bottom_right = .{ .node = last_node, .y = 1, .x = 2 }; formatter.pin_map = .{ .alloc = alloc, .map = &pin_map }; @@ -5175,7 +5175,7 @@ test "Page html with multiple styles" { s.nextSlice("\x1b[1mbold\x1b[3mitalic\x1b[0mnormal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5210,7 +5210,7 @@ test "Page html plain text" { s.nextSlice("hello, world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5243,7 +5243,7 @@ test "Page html with colors" { s.nextSlice("\x1b[31;44mcolored"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5313,7 +5313,7 @@ test "Page html with background and foreground colors" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html, .background = .{ .r = 0x12, .g = 0x34, .b = 0x56 }, @@ -5348,7 +5348,7 @@ test "Page html with escaping" { s.nextSlice("&\"'text"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); var point_map: std.ArrayList(Coordinate) = .empty; @@ -5419,7 +5419,7 @@ test "Page html with unicode as numeric entities" { s.nextSlice("╰─ ❯"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5452,7 +5452,7 @@ test "Page html ascii characters unchanged" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5484,7 +5484,7 @@ test "Page html mixed ascii and unicode" { s.nextSlice("test ╰─❯ ok"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -5518,7 +5518,7 @@ test "Page VT with palette option emits RGB" { s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Without palette option - should emit palette index { @@ -5562,7 +5562,7 @@ test "Page html with palette option emits RGB" { s.nextSlice("\x1b[31mred"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Without palette option - should emit CSS variable { @@ -5615,7 +5615,7 @@ test "Page VT style reset properly closes styles" { s.nextSlice("\x1b[1mbold\x1b[0mnormal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); builder.clearRetainingCapacity(); var formatter: PageFormatter = .init(page, .vt); @@ -5645,7 +5645,7 @@ test "Page codepoint_map single replacement" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'o' with 'x' var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5704,7 +5704,7 @@ test "Page codepoint_map conflicting replacement prefers last" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'o' with 'x', then with 'y' - should prefer last var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5746,7 +5746,7 @@ test "Page codepoint_map replace with string" { s.nextSlice("hello"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'o' with a multi-byte string var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5802,7 +5802,7 @@ test "Page codepoint_map range replacement" { s.nextSlice("abcdefg"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'b' through 'e' with 'X' var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5840,7 +5840,7 @@ test "Page codepoint_map multiple ranges" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z' var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5884,7 +5884,7 @@ test "Page codepoint_map unicode replacement" { s.nextSlice("hello ⚡ world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace lightning bolt with fire emoji var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5949,7 +5949,7 @@ test "Page codepoint_map with styled formats" { s.nextSlice("\x1b[31mred text\x1b[0m"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Replace 'e' with 'X' in styled text var map: std.MultiArrayList(CodepointMap) = .{}; @@ -5990,7 +5990,7 @@ test "Page codepoint_map empty map" { s.nextSlice("hello world"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); // Empty map should not change anything var map: std.MultiArrayList(CodepointMap) = .{}; @@ -6032,7 +6032,7 @@ test "Page VT background color on trailing blank cells" { s.nextSlice("\x1b[0m\r\nline2"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .vt); formatter.opts.trim = false; // Don't trim so we can see the trailing behavior @@ -6079,7 +6079,7 @@ test "Page HTML with hyperlinks" { s.nextSlice("\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6114,7 +6114,7 @@ test "Page HTML with multiple hyperlinks" { s.nextSlice("\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6150,7 +6150,7 @@ test "Page HTML with hyperlink escaping" { s.nextSlice("\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6184,7 +6184,7 @@ test "Page HTML with styled hyperlink" { s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6219,7 +6219,7 @@ test "Page HTML hyperlink closes style before anchor" { s.nextSlice("\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); try formatter.format(&builder.writer); @@ -6253,7 +6253,7 @@ test "Page HTML hyperlink point map maps closing to previous cell" { s.nextSlice("\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal"); const pages = &t.screens.active.pages; - const page = &pages.pages.last.?.data; + const page = pages.pages.last.?.page(); var formatter: PageFormatter = .init(page, .{ .emit = .html }); var point_map: std.ArrayList(Coordinate) = .empty; diff --git a/src/terminal/render.zig b/src/terminal/render.zig index 78906a663..01ded1d6b 100644 --- a/src/terminal/render.zig +++ b/src/terminal/render.zig @@ -511,7 +511,7 @@ pub const RenderState = struct { while (y < self.rows) { const chunk = page_it.next() orelse break; const node = chunk.node; - const p: *page.Page = &node.data; + const p: *page.Page = node.page(); // The number of rows we consume from this chunk. The chunk // may extend beyond the viewport (the viewport is always @@ -910,7 +910,7 @@ pub const RenderState = struct { // Grab our link ID const link_pin: PageList.Pin = row_pins[viewport_point.y]; - const link_page: *page.Page = &link_pin.node.data; + const link_page: *page.Page = link_pin.node.page(); const link = link: { const rac = link_page.getRowAndCell( viewport_point.x, @@ -939,7 +939,7 @@ pub const RenderState = struct { for (0.., cells.items(.raw)) |x, cell| { if (!cell.hyperlink) continue; - const other_page: *page.Page = &pin.node.data; + const other_page: *page.Page = pin.node.page(); const other = link: { const rac = other_page.getRowAndCell(x, pin.y); const link_id = other_page.lookupHyperlink(rac.cell) orelse continue; @@ -1978,7 +1978,7 @@ test "linkCells with scrollback spanning pages" { defer s.deinit(); const pages = &t.screens.active.pages; - const first_page_cap = pages.pages.first.?.data.capacity.rows; + const first_page_cap = pages.pages.first.?.capacity().rows; // Fill first page for (0..first_page_cap - 1) |_| s.nextSlice("\r\n"); diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index 692a10e12..345c842fa 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -68,19 +68,19 @@ pub const ActiveSearch = struct { // page that contains the active area. We go to the previous // page once more since its the first page of our required // overlap. - if (rem <= node.data.size.rows) { + if (rem <= node.rows()) { node_ = node.prev; break; } - rem -= node.data.size.rows; + rem -= node.rows(); } // Next, add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. while (node_) |node| : (node_ = node.prev) { // If the last row of this node isn't wrapped we can't overlap. - const row = node.data.getRow(node.data.size.rows - 1); + const row = node.page().getRow(node.rows() - 1); if (!row.wrap) break; // We could be more accurate here and count bytes since the diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index f76ad4e4b..9d98e5eaf 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -56,8 +56,8 @@ pub const PageListSearch = struct { // be moved somewhere safe. const pin = try list.trackPin(.{ .node = start, - .y = start.data.size.rows - 1, - .x = start.data.size.cols - 1, + .y = start.rows() - 1, + .x = start.cols() - 1, }); errdefer list.untrackPin(pin); @@ -190,7 +190,7 @@ test "feed multiple pages with matches" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); s.nextSlice("Fizz"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); @@ -234,7 +234,7 @@ test "feed multiple pages no matches" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); s.nextSlice("Hello"); @@ -272,7 +272,7 @@ test "feed iteratively through multiple matches" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page with a match at the end for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -313,7 +313,7 @@ test "feed with match spanning page boundary" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -367,7 +367,7 @@ test "feed with match spanning page boundary with newline" { var s = t.vtStream(); defer s.deinit(); - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; // Fill first page ending with "Te" for (0..first_page_rows - 1) |_| s.nextSlice("\r\n"); @@ -404,14 +404,14 @@ test "feed with pruned page" { // Grow to capacity const page1_node = p.pages.last.?; - const page1 = page1_node.data; + const page1 = page1_node.page(); for (0..page1.capacity.rows - page1.size.rows) |_| { try testing.expect(try p.grow() == null); } // Grow and allocate one more page. Then fill that page up. const page2_node = (try p.grow()).?; - const page2 = page2_node.data; + const page2 = page2_node.page(); for (0..page2.capacity.rows - page2.size.rows) |_| { try testing.expect(try p.grow() == null); } diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 93a606fda..77b4d0ad0 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -375,7 +375,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = @intCast(map.y), - .end = meta.node.data.size.rows, + .end = meta.node.rows(), }); break :tl .{ @@ -410,7 +410,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = 0, - .end = meta.node.data.size.rows, + .end = meta.node.rows(), }); meta_consumed += meta.cell_map.items.len; @@ -491,7 +491,7 @@ pub const SlidingWindow = struct { // order. assert(nodes.len >= 2); starts[0] = ends[0] - 1; - ends[0] = nodes[0].data.size.rows; + ends[0] = nodes[0].rows(); ends[nodes.len - 1] = starts[nodes.len - 1] + 1; starts[nodes.len - 1] = 0; } else { @@ -544,7 +544,7 @@ pub const SlidingWindow = struct { // Encode the page into the buffer. const formatter: PageFormatter = formatter: { - var formatter: PageFormatter = .init(&meta.node.data, .{ + var formatter: PageFormatter = .init(meta.node.page(), .{ .emit = .plain, .unwrap = true, }); @@ -563,7 +563,7 @@ pub const SlidingWindow = struct { // If the node we're adding isn't soft-wrapped, we add the // trailing newline. - const row = node.data.getRow(node.data.size.rows - 1); + const row = node.page().getRow(node.rows() - 1); if (!row.wrap) { encoded.writer.writeByte('\n') catch return error.OutOfMemory; try meta.cell_map.append( @@ -813,7 +813,7 @@ test "SlidingWindow two pages" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -868,7 +868,7 @@ test "SlidingWindow two pages single char" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -923,7 +923,7 @@ test "SlidingWindow two pages match across boundary" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -968,7 +968,7 @@ test "SlidingWindow two pages no match across boundary with newline" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -1001,7 +1001,7 @@ test "SlidingWindow two pages no match across boundary with newline reverse" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -1031,7 +1031,7 @@ test "SlidingWindow two pages no match prunes first page" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1063,7 +1063,7 @@ test "SlidingWindow two pages no match keeps both pages" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1164,7 +1164,7 @@ test "SlidingWindow single append match on boundary" { // We need to surgically modify the last row to be soft-wrapped try testing.expect(s.pages.pages.first == s.pages.pages.last); const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; + node.page().getRow(node.rows() - 1).wrap = true; // We are trying to break a circular buffer boundary so the way we // do this is to duplicate the data then do a failing search. This @@ -1290,7 +1290,7 @@ test "SlidingWindow two pages reversed" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1345,7 +1345,7 @@ test "SlidingWindow two pages match across boundary reversed" { // Fill up the first page. The final bytes in the first page // are "hell" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("hell"); @@ -1391,7 +1391,7 @@ test "SlidingWindow two pages no match prunes first page reversed" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1423,7 +1423,7 @@ test "SlidingWindow two pages no match keeps both pages reversed" { // Fill up the first page. The final bytes in the first page // are "boo!" - const first_page_rows = s.pages.pages.first.?.data.capacity.rows; + const first_page_rows = s.pages.pages.first.?.capacity().rows; for (0..first_page_rows - 1) |_| try s.testWriteString("\n"); for (0..s.pages.cols - 4) |_| try s.testWriteString("x"); try s.testWriteString("boo!"); @@ -1525,7 +1525,7 @@ test "SlidingWindow single append match on boundary reversed" { // We need to surgically modify the last row to be soft-wrapped try testing.expect(s.pages.pages.first == s.pages.pages.last); const node: *PageList.List.Node = s.pages.pages.first.?; - node.data.getRow(node.data.size.rows - 1).wrap = true; + node.page().getRow(node.rows() - 1).wrap = true; // We are trying to break a circular buffer boundary so the way we // do this is to duplicate the data then do a failing search. This @@ -1665,7 +1665,7 @@ test "SlidingWindow append whitespace only node" { // This is invasive but its otherwise hard to reproduce naturally // without creating a slow test. const node: *PageList.List.Node = s.pages.pages.first.?; - const last_row = node.data.getRow(node.data.size.rows - 1); + const last_row = node.page().getRow(node.rows() - 1); last_row.wrap = true; try testing.expect(s.pages.pages.first == s.pages.pages.last); diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 35dd93315..67e8cabf2 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -138,7 +138,7 @@ pub const ViewportSearch = struct { var added: usize = 0; while (node_) |node| : (node_ = node.prev) { // If the last row of this node isn't wrapped we can't overlap. - const row = node.data.getRow(node.data.size.rows - 1); + const row = node.page().getRow(node.rows() - 1); if (!row.wrap) break; // We could be more accurate here and count bytes since the @@ -157,7 +157,7 @@ pub const ViewportSearch = struct { // Add any trailing overlap as well. trailing: { const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1]; - if (!end.data.getRow(end.data.size.rows - 1).wrap) break :trailing; + if (!end.page().getRow(end.rows() - 1).wrap) break :trailing; node_ = end.next; added = 0; @@ -166,7 +166,7 @@ pub const ViewportSearch = struct { if (added >= self.window.needle.len - 1) break; // If this row doesn't wrap, then we can quit - const row = node.data.getRow(node.data.size.rows - 1); + const row = node.page().getRow(node.rows() - 1); if (!row.wrap) break; } } @@ -348,7 +348,7 @@ test "history search, no active area" { defer s.deinit(); // Fill up first page - const first_page_rows = t.screens.active.pages.pages.first.?.data.capacity.rows; + const first_page_rows = t.screens.active.pages.pages.first.?.capacity().rows; s.nextSlice("Fizz\r\n"); for (1..first_page_rows - 1) |_| s.nextSlice("\r\n"); try testing.expect(t.screens.active.pages.pages.first == t.screens.active.pages.pages.last); From 9156ada1692c7b6ac8408463639e3836af51f432 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jul 2026 13:30:38 -0700 Subject: [PATCH 05/18] terminal: add cold-history compression pass PageList could compress individual nodes but had no policy-level operation for selecting pages that are safe to reclaim. The compressed state therefore remained reachable only from tests. Add a stateless pass that considers only complete history pages while the viewport follows the active area. It gates work on supported retained-mapping reclamation, reports attempts and retained bytes, and leaves restoration lazy when a resize pulls compressed history back into the active area. Add a live scrollback-compression benchmark for measuring complete PageList compression and restoration against saved VT corpora. The pass still has no production callers, and ReleaseFast terminal-stream comparisons remain within the existing throughput guardrail. --- src/benchmark/ScrollbackCompression.zig | 299 ++++++++++++++++++++++++ src/benchmark/cli.zig | 2 + src/benchmark/main.zig | 1 + src/terminal/PageList.zig | 298 ++++++++++++++++++++++- src/terminal/mem.zig | 51 ++++ 5 files changed, 649 insertions(+), 2 deletions(-) create mode 100644 src/benchmark/ScrollbackCompression.zig diff --git a/src/benchmark/ScrollbackCompression.zig b/src/benchmark/ScrollbackCompression.zig new file mode 100644 index 000000000..b24d6e348 --- /dev/null +++ b/src/benchmark/ScrollbackCompression.zig @@ -0,0 +1,299 @@ +//! Benchmarks cold-history compression and restoration on pages owned by a +//! live terminal. +//! +//! Unlike `page-compression`, which measures the standalone codec against raw +//! byte chunks, this benchmark first parses a VT corpus into a real `Terminal`. +//! Its timed operations therefore include the PageList state transition, +//! exact-sized encoded allocation, retained-mapping reclamation, and transparent +//! restoration through `PageList.Node.page`. +//! +//! ## Input +//! +//! `--data` names a pre-generated VT byte stream. Parsing happens during setup +//! and is not part of the benchmark's timed region. Use the same saved corpus, +//! terminal dimensions, and scrollback limit when comparing revisions. In +//! particular, do not pipe a generator into this benchmark: generation and +//! pipe scheduling add noise which can overwhelm the operation being measured. +//! +//! The benchmark always operates on the primary screen's PageList. This keeps +//! the result focused on scrollback even if malformed or incomplete input +//! leaves the terminal in its alternate screen at end of file. `--max-scrollback` +//! is expressed in bytes and defaults to 10 MB. +//! +//! ## Modes +//! +//! * `noop` parses the corpus but performs no timed PageList operation. This is +//! the common process and setup baseline for the other modes. +//! * `compress` times one complete `compressColdPages` pass and retains its +//! aggregate statistics until teardown. +//! * `restore` compresses cold history during setup, outside the timed region, +//! then visits every fully historical node through `Node.page`. That public +//! content-access boundary transparently restores compressed nodes. +//! * `report` performs one compression pass and prints aggregate page counts, +//! encoded ratio, and estimated resident-byte savings. It is intended for +//! inspecting a corpus rather than timing comparisons. +//! +//! A fully historical page is a node strictly before the node containing the +//! top of the active area. The boundary node is deliberately excluded because +//! it can contain both history and active rows. Compression is currently an +//! on-demand PageList operation only; this benchmark does not enable it in +//! normal terminal execution. +//! +//! ## Examples +//! +//! Build the benchmark in ReleaseFast mode: +//! +//! zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false +//! +//! Inspect the memory reduction for a saved VT corpus: +//! +//! ghostty-bench +scrollback-compression --mode=report \ +//! --data=/tmp/scrollback.vt --terminal-cols=120 --terminal-rows=80 +//! +//! Compare PageList compression and restoration cost. Setup still contributes +//! to full process time, so use a sufficiently large corpus and compare against +//! `noop` with identical arguments: +//! +//! hyperfine --warmup 3 \ +//! 'ghostty-bench +scrollback-compression --mode=noop --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=compress --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=restore --data=/tmp/scrollback.vt' +const ScrollbackCompression = @This(); + +const std = @import("std"); +const Allocator = std.mem.Allocator; +const terminalpkg = @import("../terminal/main.zig"); +const Benchmark = @import("Benchmark.zig"); +const options = @import("options.zig"); +const PageList = terminalpkg.PageList; +const Terminal = terminalpkg.Terminal; + +const log = std.log.scoped(.@"scrollback-compression-bench"); + +opts: Options, +terminal: Terminal, + +/// Result of the timed compression pass, or the setup compression performed +/// for restore mode. Keeping this on the benchmark state prevents the compiler +/// from treating the complete pass as dead work. +stats: PageList.CompressionStats = .{}, + +pub const Options = struct { + /// Set by the shared CLI parser so the `data` string remains valid for the + /// lifetime of the benchmark implementation. + _arena: ?std.heap.ArenaAllocator = null, + + /// Select the PageList operation performed inside the timed benchmark. + mode: Mode = .compress, + + /// Dimensions used to construct the terminal which consumes the corpus. + /// These affect wrapping and therefore the number and contents of pages. + @"terminal-rows": u16 = 80, + @"terminal-cols": u16 = 120, + + /// Maximum primary-screen scrollback allocation in bytes. PageList rounds + /// this as required by its page allocation policy. + @"max-scrollback": usize = 10_000_000, + + /// Pre-generated VT corpus. `-` reads stdin, although a regular file is + /// strongly recommended so comparisons can reuse identical input bytes. + /// When unset, every mode operates on the initial empty terminal. + data: ?[]const u8 = null, + + pub fn deinit(self: *Options) void { + if (self._arena) |arena| arena.deinit(); + self.* = undefined; + } +}; + +pub const Mode = enum { + /// Establish process and setup overhead without a PageList operation. + noop, + + /// Compress every eligible fully historical page once. + compress, + + /// Restore pages compressed outside the timed region. + restore, + + /// Compress once and print aggregate memory statistics. + report, +}; + +pub fn create( + alloc: Allocator, + opts: Options, +) !*ScrollbackCompression { + const ptr = try alloc.create(ScrollbackCompression); + errdefer alloc.destroy(ptr); + + ptr.* = .{ + .opts = opts, + .terminal = try .init(alloc, .{ + .rows = opts.@"terminal-rows", + .cols = opts.@"terminal-cols", + .max_scrollback = opts.@"max-scrollback", + }), + }; + return ptr; +} + +pub fn destroy(self: *ScrollbackCompression, alloc: Allocator) void { + self.terminal.deinit(alloc); + alloc.destroy(self); +} + +pub fn benchmark(self: *ScrollbackCompression) Benchmark { + return .init(self, .{ + .stepFn = switch (self.opts.mode) { + .noop => stepNoop, + .compress => stepCompress, + .restore => stepRestore, + .report => stepReport, + }, + .setupFn = setup, + }); +} + +/// Reset the terminal and consume the complete VT corpus before timing starts. +/// Restore mode also prepares its compressed representation here so its timed +/// step contains decoding and mapping writes, but not encoding. +fn setup(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + self.terminal.fullReset(); + self.stats = .{}; + + self.loadCorpus() catch |err| { + log.warn("failed to prepare scrollback compression benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; + + if (self.opts.mode == .restore) { + self.stats = self.pages().compressColdPages(); + } +} + +/// Feed the corpus in the same 64 KiB chunks used by the real IO thread and +/// terminal-stream benchmark. Parser and file IO costs remain in setup. +fn loadCorpus(self: *ScrollbackCompression) !void { + const data_file = try options.dataFile(self.opts.data) orelse return; + defer data_file.close(); + + var stream = self.terminal.vtStream(); + defer stream.deinit(); + + var read_buf: [64 * 1024]u8 align(std.atomic.cache_line) = undefined; + var file_reader = data_file.reader(&read_buf); + const reader = &file_reader.interface; + + var buf: [64 * 1024]u8 = undefined; + while (true) { + const n = reader.readSliceShort(&buf) catch + return file_reader.err orelse error.ReadFailed; + if (n == 0) return; + stream.nextSlice(buf[0..n]); + } +} + +/// Return the primary screen because it is the terminal screen which owns +/// scrollback. A corpus ending in the alternate screen must not turn this into +/// an alternate-screen allocation benchmark. +fn pages(self: *ScrollbackCompression) *PageList { + return &self.terminal.screens.get(.primary).?.pages; +} + +fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + self.stats = self.pages().compressColdPages(); + std.mem.doNotOptimizeAway(&self.stats); +} + +fn stepRestore(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + std.mem.doNotOptimizeAway(self.visitColdPages()); +} + +/// Visit all nodes which were eligible for the setup compression pass. +/// +/// `page` is intentionally used instead of inspecting the union payload so the +/// benchmark follows the same transparent restoration boundary as consumers. +/// Compressible nodes restore here; resident candidates which failed the +/// opportunistic pass are harmlessly visited through the same boundary. +fn visitColdPages(self: *ScrollbackCompression) usize { + const page_list = self.pages(); + const active_node = page_list.getTopLeft(.active).node; + var visited: usize = 0; + var node_ = page_list.pages.first; + while (node_) |node| : (node_ = node.next) { + if (node == active_node) break; + std.mem.doNotOptimizeAway(node.page()); + visited += 1; + } + return visited; +} + +fn stepReport(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + self.stats = self.pages().compressColdPages(); + + const savings = self.stats.raw_bytes -| self.stats.encoded_bytes; + std.debug.print( + "scrollback-compression attempted={d} compressed={d} raw={d} " ++ + "encoded={d} ratio={d:.2}% savings={d}\n", + .{ + self.stats.attempted_pages, + self.stats.compressed_pages, + self.stats.raw_bytes, + self.stats.encoded_bytes, + percentage(self.stats.encoded_bytes, self.stats.raw_bytes), + savings, + }, + ); +} + +fn percentage(part: usize, whole: usize) f64 { + if (whole == 0) return 0; + return @as(f64, @floatFromInt(part)) * 100 / + @as(f64, @floatFromInt(whole)); +} + +test ScrollbackCompression { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{}); + defer impl.destroy(testing.allocator); + + const bench = impl.benchmark(); + _ = try bench.run(.once); +} + +test "ScrollbackCompression restores cold terminal pages" { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{ + .mode = .restore, + .@"terminal-rows" = 4, + // Standard-width pages hold 215 rows. Keeping that row capacity here + // makes this test corpus small while still producing cold history. + .@"terminal-cols" = 215, + .@"max-scrollback" = 1_000_000, + }); + defer impl.destroy(testing.allocator); + + var stream = impl.terminal.vtStream(); + defer stream.deinit(); + for (0..256) |_| stream.nextSlice("aaaa\r\n"); + + impl.stats = impl.pages().compressColdPages(); + try testing.expect(impl.stats.compressed_pages > 0); + try testing.expect(impl.visitColdPages() >= impl.stats.compressed_pages); + + // Restored historical pages are resident and therefore eligible for a + // later explicit pass. This also verifies that the benchmark traversal + // went through Node.page rather than merely inspecting page metadata. + const recompressed = impl.pages().compressColdPages(); + try testing.expectEqual(impl.stats.compressed_pages, recompressed.compressed_pages); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 0d2c2398e..b48a35b4b 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -9,6 +9,7 @@ pub const Action = enum { @"codepoint-width", @"grapheme-break", @"page-compression", + @"scrollback-compression", @"screen-clone", @"terminal-parser", @"terminal-stream", @@ -27,6 +28,7 @@ pub const Action = enum { return switch (action) { .@"screen-clone" => @import("ScreenClone.zig"), .@"page-compression" => @import("PageCompression.zig"), + .@"scrollback-compression" => @import("ScrollbackCompression.zig"), .@"terminal-stream" => @import("TerminalStream.zig"), .@"codepoint-width" => @import("CodepointWidth.zig"), .@"grapheme-break" => @import("GraphemeBreak.zig"), diff --git a/src/benchmark/main.zig b/src/benchmark/main.zig index 12d58cc95..61404c265 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -8,6 +8,7 @@ pub const ScreenClone = @import("ScreenClone.zig"); pub const TerminalParser = @import("TerminalParser.zig"); pub const IsSymbol = @import("IsSymbol.zig"); pub const PageCompression = @import("PageCompression.zig"); +pub const ScrollbackCompression = @import("ScrollbackCompression.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 7844bcc0f..26a52ec69 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -358,6 +358,26 @@ pub const Viewport = union(enum) { pin, }; +/// Results from one opportunistic cold-history compression pass. +/// +/// Only pages whose raw mappings were successfully discarded contribute to +/// the byte totals. A page that is already compressed is skipped rather than +/// counted as another attempt, while a resident page that fails compression +/// contributes only to `attempted_pages`. +pub const CompressionStats = struct { + /// Resident cold pages passed to the compression primitive. + attempted_pages: usize = 0, + + /// Attempts which produced encoded storage and discarded the raw mapping. + compressed_pages: usize = 0, + + /// Bytes in raw mappings discarded by successful attempts. + raw_bytes: usize = 0, + + /// Exact encoded bytes retained for successful attempts. + encoded_bytes: usize = 0, +}; + /// Returns the minimum valid "max size" for a given number of rows and cols /// such that we can fit the active area AND at least two pages. Note we /// need the two pages for algorithms to work properly (such as grow) but @@ -2311,6 +2331,11 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { // just update our rows and we're done. This effectively // "pulls down" scrollback. // + // This traversal intentionally reads only node metadata. A + // compressed history page pulled into the active area remains + // compressed until a renderer or other content consumer goes + // through `Node.page`, which restores it transparently. + // // If we don't have enough scrollback, we add the difference, // to the active area. var count: usize = 0; @@ -3714,11 +3739,69 @@ const CompressionScratch = union(enum) { } }; +/// Compress every fully historical resident page which is currently cold. +/// +/// A page is fully historical only when it precedes the node containing the +/// first active row. The boundary node itself is excluded because a single +/// page can contain both scrollback and active rows, and compression discards +/// the complete backing mapping rather than an individual row range. +/// +/// The viewport must be following the active area. When the user is viewing +/// history, even pages outside the visible range are left alone so this pass +/// cannot race ahead of rendering and immediately trigger restoration work. +/// +/// This operation is deliberately stateless. Already-compressed pages are +/// skipped, while any resident page is attempted on each pass. Consequently, +/// a page restored by content access becomes eligible for recompression and a +/// page which previously failed opportunistically can be retried without a +/// per-node attempt flag. +/// +/// There are intentionally no production callers yet. This whole-history +/// operation exists so its policy, memory savings, and restoration costs can +/// be measured before choosing an incremental scheduling policy. +pub fn compressColdPages(self: *PageList) CompressionStats { + var result: CompressionStats = .{}; + + // Avoid the codec, scratch allocation, and exact encoded allocation on a + // target where strict retained-mapping reclamation cannot succeed. + if (!terminal_mem.canReclaim(.strict)) return result; + + // Rendering a historical viewport may need any history page. Deferring + // the complete pass avoids compressing data that is about to be restored. + if (self.viewport != .active) return result; + + // The active top is metadata-only and does not restore its page. Every + // node strictly before it is fully historical; the boundary node may + // contain a historical prefix followed by active rows. + const active_node = self.getTopLeft(.active).node; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + + // Don't restore an already-compressed page just to recompress it. + if (node.isCompressed()) continue; + + // Count attempts and capture the mapping size before changing state. + result.attempted_pages += 1; + const raw_len = node.metadata().memory.len; + + // Failure leaves this node resident and unchanged. + if (!self.compressPage(node)) continue; + + // Only successful reclamation contributes to the byte totals. + result.compressed_pages += 1; + result.raw_bytes += raw_len; + result.encoded_bytes += node.data.compressed.encoded.len; + } + + return result; +} + /// Attempt to compress one resident page while retaining its raw mapping. /// /// Compression is opportunistic: every failure leaves the page resident and -/// usable. This function is intentionally private and has no production -/// callers yet; policy for choosing cold pages belongs to a later change. +/// usable. Candidate selection and retry policy belong to +/// `compressColdPages`; this primitive only performs one state transition. fn compressPage(self: *PageList, node: *List.Node) bool { // Recompression requires first restoring the raw page and is a policy // decision, so this primitive only accepts resident nodes. @@ -5934,6 +6017,217 @@ pub const Cell = struct { } }; +/// Grow a test PageList until it contains at least `count` complete history +/// pages. The production cold-page boundary is intentionally reused here so +/// tests do not duplicate the row-to-page arithmetic. +fn growColdPagesForTest(self: *PageList, count: usize) !void { + while (true) { + const active_node = self.getTopLeft(.active).node; + var cold_count: usize = 0; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + cold_count += 1; + } + + if (cold_count >= count) return; + _ = try self.grow(); + } +} + +test "PageList does not compress the mixed history and active page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // One additional row creates history, but the history and all active rows + // still share the first page. The active boundary therefore has a + // historical prefix and must remain resident as one indivisible mapping. + _ = try s.grow(); + const active = s.getTopLeft(.active); + try testing.expectEqual(s.pages.first.?, active.node); + try testing.expect(active.y > 0); + + try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + try testing.expect(!s.pages.first.?.isCompressed()); +} + +test "PageList compresses only complete cold history pages" { + const testing = std.testing; + const alloc = testing.allocator; + + // More active rows than one page at these dimensions can hold ensures the + // active area spans multiple nodes when the pass chooses its boundary. + const active_rows = initialCapacity(80).rows + 1; + var s = try init(alloc, 80, active_rows, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + // Move the active top into the boundary page so it has both a historical + // prefix and active rows while the active area still spans later pages. + _ = try s.grow(); + + const active = s.getTopLeft(.active); + const active_node = active.node; + try testing.expect(active.y > 0); + try testing.expect(active_node != s.pages.last.?); + + var expected_attempts: usize = 0; + var expected_raw_bytes: usize = 0; + var current = s.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + expected_attempts += 1; + expected_raw_bytes += node.metadata().memory.len; + } + try testing.expectEqual(@as(usize, 2), expected_attempts); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const expected = try alloc.dupe(u8, first.page().memory); + defer alloc.free(expected); + const first_memory = first.page().memory.ptr; + const page_size = s.page_size; + + const stats = s.compressColdPages(); + try testing.expectEqual(expected_attempts, stats.attempted_pages); + try testing.expectEqual(expected_attempts, stats.compressed_pages); + try testing.expectEqual(expected_raw_bytes, stats.raw_bytes); + try testing.expect(stats.encoded_bytes < stats.raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + current = s.pages.first; + var actual_encoded_bytes: usize = 0; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + try testing.expect(node.isCompressed()); + actual_encoded_bytes += node.data.compressed.encoded.len; + } + try testing.expectEqual(actual_encoded_bytes, stats.encoded_bytes); + current = active_node; + while (current) |node| : (current = node.next) { + try testing.expect(!node.isCompressed()); + } + + // Restoring the oldest page preserves both the mapping identity and all + // of its bytes even though the pass discarded its physical pages. + try testing.expectEqual(first_memory, first.metadata().memory.ptr); + try testing.expectEqualSlices(u8, expected, first.page().memory); + try testing.expectEqual(first_memory, first.page().memory.ptr); +} + +test "PageList lazily restores compressed history made active by resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const memory_ptr = first.page().memory.ptr; + const memory_len = first.page().memory.len; + const page_size = s.page_size; + + const compressed = s.compressColdPages(); + try testing.expectEqual(@as(usize, 1), compressed.compressed_pages); + try testing.expect(first.isCompressed()); + + // Pull all scrollback into the active area by making the viewport as tall + // as the complete screen. A row-only resize needs only page metadata, so + // the newly active page can remain compressed until its contents are used. + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + const active = s.getTopLeft(.active); + try testing.expectEqual(first, active.node); + try testing.expectEqual(@as(size.CellCountInt, 0), active.y); + try testing.expect(first.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + + // The compression pass must not reconsider the node now that it is active. + // Content access follows the normal page boundary, which recommits and + // restores the retained mapping before returning the cell. + try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + const cell = s.getCell(.{ .active = .{} }).?; + try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(memory_ptr, first.page().memory.ptr); + try testing.expectEqual(memory_len, first.page().memory.len); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList cold compression defers historical viewports and retries restored pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const page_size = s.page_size; + s.scroll(.top); + try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + try testing.expect(!s.pages.first.?.isCompressed()); + + s.scroll(.{ .row = 1 }); + try testing.expect(s.viewport == .pin); + try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + try testing.expect(!s.pages.first.?.isCompressed()); + + s.scroll(.active); + const initial = s.compressColdPages(); + try testing.expectEqual(@as(usize, 2), initial.attempted_pages); + try testing.expectEqual(@as(usize, 2), initial.compressed_pages); + try testing.expectEqual(page_size, s.page_size); + + // A second pass skips the two compressed representations entirely. + try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + + // Content access restores one page. Because the policy is stateless, the + // next explicit pass sees that resident page and compresses it again. + const first = s.pages.first.?; + const memory_ptr = first.metadata().memory.ptr; + _ = first.page(); + try testing.expect(!first.isCompressed()); + + const retry = s.compressColdPages(); + try testing.expectEqual(@as(usize, 1), retry.attempted_pages); + try testing.expectEqual(@as(usize, 1), retry.compressed_pages); + try testing.expectEqual(memory_ptr, first.metadata().memory.ptr); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList cold compression continues after an incompressible page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x434F_4C44_5041_4745); + prng.random().bytes(first.page().memory); + + const page_size = s.page_size; + const stats = s.compressColdPages(); + try testing.expectEqual(@as(usize, 2), stats.attempted_pages); + try testing.expectEqual(@as(usize, 1), stats.compressed_pages); + try testing.expect(!first.isCompressed()); + try testing.expect(second.isCompressed()); + try testing.expectEqual(second.metadata().memory.len, stats.raw_bytes); + try testing.expect(stats.encoded_bytes < stats.raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + // Failed resident candidates are deliberately retried on later passes, + // while the successful page remains compressed and is skipped. + const retry = s.compressColdPages(); + try testing.expectEqual(@as(usize, 1), retry.attempted_pages); + try testing.expectEqual(@as(usize, 0), retry.compressed_pages); + try testing.expectEqual(@as(usize, 0), retry.raw_bytes); + try testing.expectEqual(@as(usize, 0), retry.encoded_bytes); +} + test "PageList compression restores through page access" { const testing = std.testing; const alloc = testing.allocator; diff --git a/src/terminal/mem.zig b/src/terminal/mem.zig index 940985766..7fa314802 100644 --- a/src/terminal/mem.zig +++ b/src/terminal/mem.zig @@ -21,6 +21,51 @@ pub const DecommitMode = enum { strict, }; +/// Return whether this target can reclaim physical memory for `mode` while +/// retaining the mapping's virtual address range. +/// +/// Test builds support both modes because `decommit` simulates reclamation by +/// clearing the supplied range. Runtime reclamation is intentionally limited +/// to 64-bit Linux and Darwin. Other targets must leave strict callers' memory +/// resident; zero mode still provides its documented memset fallback through +/// `decommit` even when this function returns false. +pub inline fn canReclaim(comptime mode: DecommitMode) bool { + // Both modes use the same retained-mapping primitives. Keeping the switch + // exhaustive makes additions to DecommitMode choose target support + // explicitly rather than inheriting it accidentally. + return switch (mode) { + .zero, .strict => supported: { + // Tests never call into the OS because their allocator ranges can + // share mappings with unrelated allocations. `decommit` simulates + // successful reclamation by zeroing the requested range instead, + // so both modes are always available to tests on every target. + if (builtin.is_test) break :supported true; + + // Compression currently retains complete page mappings for its + // lifetime. Limit the initial runtime support to 64-bit address + // spaces where that virtual-memory cost is negligible and where + // the retained-mapping behavior has been validated. + if (builtin.target.ptrBitWidth() != 64) break :supported false; + + // Linux provides MADV_DONTNEED, which immediately discards pages + // from a private anonymous mapping and faults them back as zeroes. + // Zig reaches this through the raw syscall path without libc. + if (builtin.target.os.tag == .linux) break :supported true; + + // Darwin provides the paired MADV_FREE_REUSABLE/FREE_REUSE + // operations used below. Darwin requires libc independently of + // this feature, so using its madvise entry point adds no new + // dependency to libghostty-vt. + if (builtin.target.os.tag.isDarwin()) break :supported true; + + // Other targets have no retained-mapping reclamation contract in + // this module. Zero mode can still clear through its memset + // fallback, but strict callers must leave their mapping resident. + break :supported false; + }, + }; +} + /// Discard physical pages while retaining a mapping's virtual address range. /// /// The complete mapping must be page-aligned and a multiple of the minimum @@ -169,3 +214,9 @@ test "strict decommit retains the mapping for recommit" { @memset(memory, 0xBB); try testing.expect(std.mem.allEqual(u8, memory, 0xBB)); } + +test "test builds can reclaim retained mappings" { + const testing = std.testing; + try testing.expect(canReclaim(.zero)); + try testing.expect(canReclaim(.strict)); +} From 70e788f06677e36d1f0e8c1f21c3751531ac3246 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jul 2026 15:07:15 -0700 Subject: [PATCH 06/18] terminal: add incremental scrollback compression Cold-history compression previously required scanning every eligible page in one call, which makes it unsuitable for an idle-time scheduler. The inspector also restored compressed pages while traversing collapsed entries, hiding the representation and undoing reclamation. Add caller-owned serial state that resumes compression without retaining node pointers. Each invocation inspects at most eight candidates and attempts one resident page. List mutations restart safely, while unsupported or historical viewports stop work. Keep a stateless whole-history operation for measurement. Expose metadata-only storage and memory accounting for diagnostics, update the inspector to restore only expanded pages, and add an incremental live scrollback benchmark. This remains disconnected from production scheduling. --- src/benchmark/ScrollbackCompression.zig | 116 +++- src/inspector/widgets/page.zig | 19 +- src/inspector/widgets/pagelist.zig | 129 +++- src/terminal/PageList.zig | 808 +++++++++++++++++++++--- 4 files changed, 946 insertions(+), 126 deletions(-) diff --git a/src/benchmark/ScrollbackCompression.zig b/src/benchmark/ScrollbackCompression.zig index b24d6e348..d541f25fd 100644 --- a/src/benchmark/ScrollbackCompression.zig +++ b/src/benchmark/ScrollbackCompression.zig @@ -24,12 +24,15 @@ //! //! * `noop` parses the corpus but performs no timed PageList operation. This is //! the common process and setup baseline for the other modes. -//! * `compress` times one complete `compressColdPages` pass and retains its -//! aggregate statistics until teardown. +//! * `compress` times one complete `compress` invocation. +//! * `incremental` performs the same complete pass through +//! `compressIncremental`. It drains the candidate-bounded steps in the timed +//! region, making the cursor and repeated traversal overhead directly +//! comparable with `compress`. //! * `restore` compresses cold history during setup, outside the timed region, //! then visits every fully historical node through `Node.page`. That public //! content-access boundary transparently restores compressed nodes. -//! * `report` performs one compression pass and prints aggregate page counts, +//! * `report` performs one compression pass and prints compressed page count, //! encoded ratio, and estimated resident-byte savings. It is intended for //! inspecting a corpus rather than timing comparisons. //! @@ -57,6 +60,7 @@ //! hyperfine --warmup 3 \ //! 'ghostty-bench +scrollback-compression --mode=noop --data=/tmp/scrollback.vt' \ //! 'ghostty-bench +scrollback-compression --mode=compress --data=/tmp/scrollback.vt' \ +//! 'ghostty-bench +scrollback-compression --mode=incremental --data=/tmp/scrollback.vt' \ //! 'ghostty-bench +scrollback-compression --mode=restore --data=/tmp/scrollback.vt' const ScrollbackCompression = @This(); @@ -73,10 +77,8 @@ const log = std.log.scoped(.@"scrollback-compression-bench"); opts: Options, terminal: Terminal, -/// Result of the timed compression pass, or the setup compression performed -/// for restore mode. Keeping this on the benchmark state prevents the compiler -/// from treating the complete pass as dead work. -stats: PageList.CompressionStats = .{}, +/// Cursor state used by incremental mode between candidate-bounded steps. +incremental_state: PageList.IncrementalCompressionState = .{}, pub const Options = struct { /// Set by the shared CLI parser so the `data` string remains valid for the @@ -113,6 +115,9 @@ pub const Mode = enum { /// Compress every eligible fully historical page once. compress, + /// Compress every eligible page through bounded resumable steps. + incremental, + /// Restore pages compressed outside the timed region. restore, @@ -148,6 +153,7 @@ pub fn benchmark(self: *ScrollbackCompression) Benchmark { .stepFn = switch (self.opts.mode) { .noop => stepNoop, .compress => stepCompress, + .incremental => stepIncremental, .restore => stepRestore, .report => stepReport, }, @@ -161,7 +167,7 @@ pub fn benchmark(self: *ScrollbackCompression) Benchmark { fn setup(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); self.terminal.fullReset(); - self.stats = .{}; + self.incremental_state.reset(); self.loadCorpus() catch |err| { log.warn("failed to prepare scrollback compression benchmark err={}", .{err}); @@ -169,7 +175,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { }; if (self.opts.mode == .restore) { - self.stats = self.pages().compressColdPages(); + self.pages().compress(); } } @@ -209,8 +215,34 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); - self.stats = self.pages().compressColdPages(); - std.mem.doNotOptimizeAway(&self.stats); + self.pages().compress(); + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + self.drainIncrementalCompression(); + std.mem.doNotOptimizeAway(&self.incremental_state); + std.mem.doNotOptimizeAway(&self.terminal); +} + +/// Drain one complete resumable compression pass. +/// +/// Each PageList step has a bounded candidate-inspection budget. The benchmark +/// loops until no more immediate work is available so its timed result can be +/// compared with the existing monolithic `compress` mode. +fn drainIncrementalCompression(self: *ScrollbackCompression) void { + while (true) { + switch (self.pages().compressIncremental( + &self.incremental_state, + )) { + .pending => continue, + .unsupported, + .deferred, + .complete, + => return, + } + } } fn stepRestore(ptr: *anyopaque) Benchmark.Error!void { @@ -239,19 +271,21 @@ fn visitColdPages(self: *ScrollbackCompression) usize { fn stepReport(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); - self.stats = self.pages().compressColdPages(); + self.pages().compress(); + const memory = self.pages().memoryStats(); - const savings = self.stats.raw_bytes -| self.stats.encoded_bytes; std.debug.print( - "scrollback-compression attempted={d} compressed={d} raw={d} " ++ + "scrollback-compression compressed={d} raw={d} " ++ "encoded={d} ratio={d:.2}% savings={d}\n", .{ - self.stats.attempted_pages, - self.stats.compressed_pages, - self.stats.raw_bytes, - self.stats.encoded_bytes, - percentage(self.stats.encoded_bytes, self.stats.raw_bytes), - savings, + memory.compressed_pages, + memory.decommitted_raw_bytes, + memory.encoded_bytes, + percentage( + memory.encoded_bytes, + memory.decommitted_raw_bytes, + ), + memory.estimatedSavings(), }, ); } @@ -287,13 +321,45 @@ test "ScrollbackCompression restores cold terminal pages" { defer stream.deinit(); for (0..256) |_| stream.nextSlice("aaaa\r\n"); - impl.stats = impl.pages().compressColdPages(); - try testing.expect(impl.stats.compressed_pages > 0); - try testing.expect(impl.visitColdPages() >= impl.stats.compressed_pages); + impl.pages().compress(); + const compressed = impl.pages().memoryStats(); + try testing.expect(compressed.compressed_pages > 0); + try testing.expect(impl.visitColdPages() >= compressed.compressed_pages); // Restored historical pages are resident and therefore eligible for a // later explicit pass. This also verifies that the benchmark traversal // went through Node.page rather than merely inspecting page metadata. - const recompressed = impl.pages().compressColdPages(); - try testing.expectEqual(impl.stats.compressed_pages, recompressed.compressed_pages); + impl.pages().compress(); + const recompressed = impl.pages().memoryStats(); + try testing.expectEqual( + compressed.compressed_pages, + recompressed.compressed_pages, + ); +} + +test "ScrollbackCompression drains incremental compression steps" { + const testing = std.testing; + const impl: *ScrollbackCompression = try .create(testing.allocator, .{ + .mode = .incremental, + .@"terminal-rows" = 4, + .@"terminal-cols" = 215, + .@"max-scrollback" = 1_000_000, + }); + defer impl.destroy(testing.allocator); + + var stream = impl.terminal.vtStream(); + defer stream.deinit(); + for (0..256) |_| stream.nextSlice("aaaa\r\n"); + + impl.incremental_state.reset(); + impl.drainIncrementalCompression(); + const incremental = impl.pages().memoryStats(); + try testing.expect(incremental.compressed_pages > 0); + + // Restore the same pages and compare against the monolithic operation. + // Both paths should produce the same final storage representation. + _ = impl.visitColdPages(); + impl.pages().compress(); + const monolithic = impl.pages().memoryStats(); + try testing.expectEqual(monolithic, incremental); } diff --git a/src/inspector/widgets/page.zig b/src/inspector/widgets/page.zig index 844abc355..e332ab3c6 100644 --- a/src/inspector/widgets/page.zig +++ b/src/inspector/widgets/page.zig @@ -25,8 +25,9 @@ pub fn inspector(page: *const terminal.Page) void { /// the tree node is open or not. If it is open you must close it with /// TreePop. pub fn treeNode(state: struct { - /// The page - page: *const terminal.Page, + /// Page dimensions available without reading its backing memory. + cols: terminal.size.CellCountInt, + rows: terminal.size.CellCountInt, /// The index of the page in a page list, used for headers. index: usize, /// The range of rows this page covers, inclusive. @@ -34,6 +35,10 @@ pub fn treeNode(state: struct { /// Whether this page is the active or viewport node. active: bool, viewport: bool, + /// Whether the page backing memory is currently compressed. + compressed: bool, + /// Dirty state is unavailable without restoring a compressed page. + dirty: ?bool, }) bool { // Setup our node. const open = open: { @@ -61,8 +66,8 @@ pub fn treeNode(state: struct { // Metadata cimgui.c.ImGui_TextDisabled( "%dc x %dr", - state.page.size.cols, - state.page.size.rows, + state.cols, + state.rows, ); cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_Text("rows %d..%d", state.row_range[0], state.row_range[1]); @@ -76,7 +81,11 @@ pub fn treeNode(state: struct { cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_TextColored(.{ .x = 0.4, .y = 0.8, .z = 1.0, .w = 1.0 }, "viewport"); } - if (state.page.isDirty()) { + if (state.compressed) { + cimgui.c.ImGui_SameLine(); + cimgui.c.ImGui_TextColored(.{ .x = 0.8, .y = 0.6, .z = 1.0, .w = 1.0 }, "compressed"); + } + if (state.dirty orelse false) { cimgui.c.ImGui_SameLine(); cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); } diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig index 651882046..4041ba332 100644 --- a/src/inspector/widgets/pagelist.zig +++ b/src/inspector/widgets/pagelist.zig @@ -58,8 +58,10 @@ pub const Inspector = struct { var index: usize = pages.totalPages(); var node = pages.pages.last; while (node) |page_node| : (node = page_node.prev) { - const page = page_node.page(); - row_offset -= page.size.rows; + const rows = page_node.rows(); + const resident = page_node.pageIfResident(); + const compressed = page_node.storage() == .compressed; + row_offset -= rows; index -= 1; // We use our location as the ID so that even if reallocations @@ -69,13 +71,21 @@ pub const Inspector = struct { // Open up the tree node. if (!widgets.page.treeNode(.{ - .page = page, + .cols = page_node.cols(), + .rows = rows, .index = index, - .row_range = .{ row_offset, row_offset + page.size.rows - 1 }, + .row_range = .{ row_offset, row_offset + rows - 1 }, .active = node == active_pin.node, .viewport = node == viewport_pin.node, + .compressed = compressed, + .dirty = if (resident) |page| page.isDirty() else null, })) continue; defer cimgui.c.ImGui_TreePop(); + + // Opening a compressed entry is an explicit request for its + // contents, so restoration is appropriate here. Collapsed + // entries use only metadata and remain compressed. + const page = resident orelse page_node.page(); widgets.page.inspector(page); } } @@ -83,6 +93,8 @@ pub const Inspector = struct { }; fn summaryTable(pages: *const PageList) void { + const memory = pages.memoryStats(); + if (!cimgui.c.ImGui_BeginTable( "pagelist_summary", 3, @@ -106,7 +118,18 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Total number of pages in the linked list."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.totalPages()); + cimgui.c.ImGui_Text("%zu", pages.totalPages()); + + summaryCountRow( + "Resident Pages", + "Pages whose raw backing memory is currently resident.", + memory.resident_pages, + ); + summaryCountRow( + "Compressed Pages", + "Pages retained as encoded data with their raw backing memory discarded.", + memory.compressed_pages, + ); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -114,17 +137,42 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Total rows represented by scrollback + active area."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.total_rows); + cimgui.c.ImGui_Text("%zu", pages.total_rows); - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Page Bytes"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker("Total bytes allocated for active pages."); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text( - "%d KiB", - units.toKibiBytes(pages.page_size), + summaryBytesRow( + "Logical Page Bytes", + "Bytes counted against the scrollback limit. Compression does not change this value.", + pages.page_size, + ); + summaryBytesRow( + "Raw Mapping Bytes", + "Total initialized raw page mappings, including discarded mappings retained for restoration.", + memory.raw_bytes, + ); + summaryBytesRow( + "Resident Raw Bytes", + "Raw page mapping bytes which have not been discarded.", + memory.resident_raw_bytes, + ); + summaryBytesRow( + "Decommitted Raw Bytes", + "Raw page mapping bytes discarded while their virtual addresses remain reserved.", + memory.decommitted_raw_bytes, + ); + summaryBytesRow( + "Encoded Bytes", + "Exact encoded storage retained for compressed pages.", + memory.encoded_bytes, + ); + summaryBytesRow( + "Estimated Resident Bytes", + "Resident raw allocation backing, including unused pool-item tails, plus encoded storage. Allocator and representation overhead are excluded.", + memory.estimatedResidentBytes(), + ); + summaryBytesRow( + "Estimated Savings", + "Decommitted raw mapping bytes minus their replacement encoded storage.", + memory.estimatedSavings(), ); cimgui.c.ImGui_TableNextRow(); @@ -139,7 +187,7 @@ fn summaryTable(pages: *const PageList) void { ); _ = cimgui.c.ImGui_TableSetColumnIndex(2); cimgui.c.ImGui_Text( - "%d KiB", + "%zu KiB", units.toKibiBytes(pages.maxSize()), ); @@ -157,7 +205,39 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker("Number of pins tracked for automatic updates."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.countTrackedPins()); + cimgui.c.ImGui_Text("%zu", pages.countTrackedPins()); +} + +fn summaryCountRow( + label: [:0]const u8, + help: [:0]const u8, + value: usize, +) void { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", label.ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker(help); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text("%zu", value); +} + +fn summaryBytesRow( + label: [:0]const u8, + help: [:0]const u8, + bytes: usize, +) void { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", label.ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker(help); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_Text( + "%zu bytes (%zu KiB)", + bytes, + units.toKibiBytes(bytes), + ); } fn scrollbarInfo(pages: *PageList) void { @@ -321,11 +401,18 @@ fn trackedPinsTable(pages: *const PageList) void { } _ = cimgui.c.ImGui_TableSetColumnIndex(3); - const dirty = pin.isDirty(); - if (dirty) { - cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + if (pin.node.pageIfResident()) |page| { + const dirty = page.dirty or + page.getRowAndCell(pin.x, pin.y).row.dirty; + if (dirty) { + cimgui.c.ImGui_TextColored(.{ .x = 1.0, .y = 0.4, .z = 0.4, .w = 1.0 }, "dirty"); + } else { + cimgui.c.ImGui_TextDisabled("clean"); + } } else { - cimgui.c.ImGui_TextDisabled("clean"); + // Dirty state lives in the discarded mapping. Keep inspector + // traversal metadata-only rather than restoring this page. + cimgui.c.ImGui_TextDisabled("compressed"); } _ = cimgui.c.ImGui_TableSetColumnIndex(4); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 26a52ec69..e5a9f514b 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -12,7 +12,7 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); -const compress = @import("compress.zig"); +const compression = @import("compress.zig"); const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); const terminal_mem = @import("mem.zig"); @@ -74,11 +74,14 @@ const Node = struct { /// virtual allocation so that restoration is infallible. const Data = union(enum) { resident: Page, - compressed: compress.Page, + compressed: compression.Page, }; const Owned = enum { pool, heap }; + /// The backing-memory representation currently stored by this node. + pub const Storage = enum { resident, compressed }; + /// Return the terminal page stored in this node. /// /// WARNING: This will DECOMPRESS compressed pages! Only use this if @@ -91,6 +94,27 @@ const Node = struct { }; } + /// Return the terminal page only when its raw memory is resident. + /// + /// Unlike `page`, this never restores a compressed node. This is useful + /// for diagnostics which can display node metadata without touching the + /// discarded mapping, but may inspect page contents when they are already + /// available. + pub inline fn pageIfResident(self: *Node) ?*Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => null, + }; + } + + /// Return the node's backing-memory representation without restoring it. + pub inline fn storage(self: *const Node) Storage { + return switch (self.data) { + .resident => .resident, + .compressed => .compressed, + }; + } + /// Return a page which the caller knows is already resident. /// /// This avoids the representation check in hot paths which already hold @@ -358,26 +382,6 @@ pub const Viewport = union(enum) { pin, }; -/// Results from one opportunistic cold-history compression pass. -/// -/// Only pages whose raw mappings were successfully discarded contribute to -/// the byte totals. A page that is already compressed is skipped rather than -/// counted as another attempt, while a resident page that fails compression -/// contributes only to `attempted_pages`. -pub const CompressionStats = struct { - /// Resident cold pages passed to the compression primitive. - attempted_pages: usize = 0, - - /// Attempts which produced encoded storage and discarded the raw mapping. - compressed_pages: usize = 0, - - /// Bytes in raw mappings discarded by successful attempts. - raw_bytes: usize = 0, - - /// Exact encoded bytes retained for successful attempts. - encoded_bytes: usize = 0, -}; - /// Returns the minimum valid "max size" for a given number of rows and cols /// such that we can fit the active area AND at least two pages. Note we /// need the two pages for algorithms to work properly (such as grow) but @@ -3739,6 +3743,151 @@ const CompressionScratch = union(enum) { } }; +/// Caller-owned state for incremental compression. +/// +/// The position is stored as a page serial rather than a node pointer so it +/// remains safe when PageList operations destroy, replace, or reuse nodes +/// between steps. It also records the first serial which was unallocated at +/// the prior step so new nodes before a valid marker restart safely. If the +/// exact serial no longer exists in the cold prefix, the next step also +/// restarts at the first page. +/// +/// A state value belongs to one initialized PageList. In Ghostty's intended +/// runtime use, it will be stored alongside the Screen's PageList and naturally +/// share its lifetime. Reset, resize/reflow, pruning, and node reuse all keep +/// the same PageList, so they do not violate this rule. The state must be reset +/// only if unusual caller code preserves it while deinitializing and replacing +/// the entire PageList, such as assigning a fresh `init` or `clone` result into +/// the same field. Callers may also reset it to reconsider pages which were +/// restored or which failed an earlier attempt. +pub const IncrementalCompressionState = struct { + /// Serial of the last page inspected by the traversal. This is + /// intentionally an implementation detail; callers should use `reset` + /// rather than changing the continuation marker directly. + last_serial: ?u64 = null, + + /// First node serial which had not been allocated at the prior step. A + /// node at or above this value before the saved marker requires a restart. + next_serial: u64 = 0, + + /// Restart the next step at the first page in the list. + pub fn reset(self: *IncrementalCompressionState) void { + self.* = .{}; + } +}; + +/// Result of one incremental compression step. +pub const IncrementalCompressionResult = enum { + /// Strict retained-mapping reclamation is unavailable on this target. + unsupported, + + /// The viewport is displaying history, so compression is postponed. + deferred, + + /// More cold pages remain after this invocation's candidate-bounded work. + pending, + + /// The traversal reached the page containing the first active row. + complete, +}; + +/// Bound candidate inspection independently from compression work. Skipping +/// an already-compressed page is cheap, but still counts toward this limit. +const incremental_compression_max_inspected = 8; + +/// Failure injection for the final reclamation step. The operating-system +/// failure path cannot otherwise be exercised by tests because terminal_mem +/// deliberately simulates successful decommit in test builds. +const compressPage_tw = tripwire.module( + enum { decommit }, + error{DecommitFailed}, +); + +/// Perform one candidate-bounded incremental cold-history compression step. +/// +/// Eligibility is identical to `compress`: strict retained-mapping +/// reclamation must be available, the viewport must follow the active area, +/// and only complete pages before the active boundary are candidates. The +/// mixed history/active boundary page is never inspected or compressed. +/// +/// The continuation marker is relocated by serial on every call. This cursor +/// positioning is a metadata-only traversal; the eight-page candidate bound +/// applies after positioning. Since the state intentionally retains no node +/// pointer or tracked pin, positioning may traverse the cold prefix. Missing +/// serials restart at the first page, as does finding a node allocated since +/// the prior step before the marker. This remains safe without retaining a +/// potentially dangling node pointer. +/// +/// Each step attempts at most one resident page and advances after every +/// outcome, including no savings, allocation failure, and failed decommit. An +/// unsuccessful page therefore cannot stall the traversal. Already-compressed +/// pages are skipped but count as inspected. Call +/// `IncrementalCompressionState.reset` when a new traversal should reconsider +/// restored or previously unsuccessful pages. +pub fn compressIncremental( + self: *PageList, + state: *IncrementalCompressionState, +) IncrementalCompressionResult { + if (!terminal_mem.canReclaim(.strict)) return .unsupported; + + if (self.viewport != .active) return .deferred; + + const active_node = self.getTopLeft(.active).node; + + // Find the node following the exact continuation marker within the cold + // prefix. A missing marker means the list changed between steps, so begin + // again at the current first page. This lookup does not touch page memory. + var current = self.pages.first.?; + if (state.last_serial) |last_serial| { + var found = false; + var inserted_before_marker = false; + var search = self.pages.first.?; + while (search != active_node) : (search = search.next.?) { + if (search.serial >= state.next_serial) + inserted_before_marker = true; + if (search.serial != last_serial) continue; + + // A newly allocated or replacement node appeared before the + // marker. Restart so that node cannot be skipped. Appends after + // the marker do not disturb progress through the existing prefix. + if (inserted_before_marker) { + state.last_serial = null; + current = self.pages.first.?; + } else { + current = search.next.?; + } + found = true; + break; + } + + if (!found) { + state.last_serial = null; + current = self.pages.first.?; + } + } + state.next_serial = self.page_serial; + + var inspected_pages: usize = 0; + while (current != active_node and + inspected_pages < incremental_compression_max_inspected) + { + const node = current; + current = node.next.?; + state.last_serial = node.serial; + inspected_pages += 1; + + if (node.isCompressed()) continue; + + // Compression is substantially more expensive than inspecting + // metadata, so one call attempts at most one resident page regardless + // of whether the attempt succeeds. + _ = self.compressPage(node); + break; + } + + return if (current == active_node) .complete else .pending; +} + /// Compress every fully historical resident page which is currently cold. /// /// A page is fully historical only when it precedes the node containing the @@ -3759,16 +3908,14 @@ const CompressionScratch = union(enum) { /// There are intentionally no production callers yet. This whole-history /// operation exists so its policy, memory savings, and restoration costs can /// be measured before choosing an incremental scheduling policy. -pub fn compressColdPages(self: *PageList) CompressionStats { - var result: CompressionStats = .{}; - +pub fn compress(self: *PageList) void { // Avoid the codec, scratch allocation, and exact encoded allocation on a // target where strict retained-mapping reclamation cannot succeed. - if (!terminal_mem.canReclaim(.strict)) return result; + if (!terminal_mem.canReclaim(.strict)) return; // Rendering a historical viewport may need any history page. Deferring // the complete pass avoids compressing data that is about to be restored. - if (self.viewport != .active) return result; + if (self.viewport != .active) return; // The active top is metadata-only and does not restore its page. Every // node strictly before it is fully historical; the boundary node may @@ -3781,27 +3928,17 @@ pub fn compressColdPages(self: *PageList) CompressionStats { // Don't restore an already-compressed page just to recompress it. if (node.isCompressed()) continue; - // Count attempts and capture the mapping size before changing state. - result.attempted_pages += 1; - const raw_len = node.metadata().memory.len; - // Failure leaves this node resident and unchanged. - if (!self.compressPage(node)) continue; - - // Only successful reclamation contributes to the byte totals. - result.compressed_pages += 1; - result.raw_bytes += raw_len; - result.encoded_bytes += node.data.compressed.encoded.len; + _ = self.compressPage(node); } - - return result; } /// Attempt to compress one resident page while retaining its raw mapping. /// /// Compression is opportunistic: every failure leaves the page resident and /// usable. Candidate selection and retry policy belong to -/// `compressColdPages`; this primitive only performs one state transition. +/// `compress` and `compressIncremental`; this primitive only performs one +/// state transition. fn compressPage(self: *PageList, node: *List.Node) bool { // Recompression requires first restoring the raw page and is a policy // decision, so this primitive only accepts resident nodes. @@ -3812,7 +3949,7 @@ fn compressPage(self: *PageList, node: *List.Node) bool { // The scratch size is capped just below the representation's break-even // point. Codec limits and pages too small to cover the compressed-state // overhead simply make this page ineligible for compression. - const required = compress.Page.requiredScratch(page.memory.len) catch |err| + const required = compression.Page.requiredScratch(page.memory.len) catch |err| switch (err) { error.InputTooLarge, error.OutputTooSmall, @@ -3834,8 +3971,8 @@ fn compressPage(self: *PageList, node: *List.Node) bool { }; defer scratch.deinit(&self.pool); - var table: compress.lz4.HashTable = undefined; - break :candidate compress.Page.init( + var table: compression.lz4.HashTable = undefined; + break :candidate compression.Page.init( self.pool.alloc, page, scratch.bytes()[0..required], @@ -3855,7 +3992,13 @@ fn compressPage(self: *PageList, node: *List.Node) bool { // Strict decommit is the final fallible step. It either discards the whole // raw mapping or leaves it untouched, so failure can safely free the // candidate and preserve the resident node exactly as it was. - if (!terminal_mem.decommit( + const decommit_allowed: bool = allowed: { + compressPage_tw.check(.decommit) catch |err| switch (err) { + error.DecommitFailed => break :allowed false, + }; + break :allowed true; + }; + if (!decommit_allowed or !terminal_mem.decommit( .strict, compressed.page.memory, compressed.page.memory.len, @@ -5520,6 +5663,88 @@ pub fn totalPages(self: *const PageList) usize { return pages; } +/// Snapshot of the storage used by page nodes in this list. +/// +/// The raw byte counts describe page backing mappings only. They exclude +/// nodes, allocator metadata, unused preheated pool items, and the small +/// representation values stored in each node. A compressed page retains its +/// raw mapping as virtual address space, but its bytes are counted as +/// decommitted because strict reclamation succeeded before the state was +/// published. +pub const MemoryStats = struct { + /// Pages whose raw backing mappings are resident. + resident_pages: usize = 0, + + /// Pages represented by encoded storage and a decommitted raw mapping. + compressed_pages: usize = 0, + + /// Logical bytes in every raw page mapping. + raw_bytes: usize = 0, + + /// Raw mapping bytes which remain resident. + resident_raw_bytes: usize = 0, + + /// Raw mapping bytes discarded for compressed pages. + decommitted_raw_bytes: usize = 0, + + /// Raw allocation bytes which remain physically resident. + /// + /// This can exceed `resident_raw_bytes` because a pool-owned page uses + /// only part of a standard pool item. Compressing such a page decommits + /// its initialized range, but the unused tail of the item stays resident. + resident_backing_bytes: usize = 0, + + /// Exact encoded allocations retained for compressed pages. + encoded_bytes: usize = 0, + + /// Estimate resident page backing storage after compression. + pub fn estimatedResidentBytes(self: MemoryStats) usize { + return self.resident_backing_bytes + self.encoded_bytes; + } + + /// Estimate physical bytes avoided by compressed page backing storage. + pub fn estimatedSavings(self: MemoryStats) usize { + return self.decommitted_raw_bytes -| self.encoded_bytes; + } +}; + +/// Return a metadata-only snapshot of page backing storage. +/// +/// This never restores compressed pages. It is intended for diagnostics and +/// other infrequent reporting because it traverses the complete page list. +pub fn memoryStats(self: *const PageList) MemoryStats { + var result: MemoryStats = .{}; + var current = self.pages.first; + while (current) |node| : (current = node.next) { + const raw_len = node.metadata().memory.len; + const backing_len = switch (node.owned) { + .pool => PagePool.item_size, + .heap => raw_len, + }; + assert(backing_len >= raw_len); + result.raw_bytes += raw_len; + + switch (node.data) { + .resident => { + result.resident_pages += 1; + result.resident_raw_bytes += raw_len; + result.resident_backing_bytes += backing_len; + }, + + .compressed => |compressed| { + result.compressed_pages += 1; + result.decommitted_raw_bytes += raw_len; + // Strict reclamation covers only Page.memory. A standard pool + // item can have an unused tail which remains resident. + result.resident_backing_bytes += backing_len - raw_len; + result.encoded_bytes += compressed.encoded.len; + }, + } + } + + return result; +} + /// Grow the number of rows available in the page list by n. /// This is only used for testing so it isn't optimized in any way. fn growRows(self: *PageList, n: usize) Allocator.Error!void { @@ -6035,6 +6260,439 @@ fn growColdPagesForTest(self: *PageList, count: usize) !void { } } +test "PageList incremental compression defers and completes" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + s.scroll(.top); + const deferred = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.deferred, deferred); + const cold = s.pages.first.?; + try testing.expect(!cold.isCompressed()); + + s.scroll(.active); + const complete = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, complete); + try testing.expect(cold.isCompressed()); + + // The exact continuation marker positions us directly at the active + // boundary on later calls. Positioning is not candidate inspection. + const repeated = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, repeated); + + // Restoring a page behind the marker does not cause immediate churn. The + // caller explicitly resets when it wants a new pass to reconsider it. + _ = cold.page(); + try testing.expect(!cold.isCompressed()); + const skipped = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, skipped); + + state.reset(); + const reconsidered = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, reconsidered); + try testing.expect(cold.isCompressed()); +} + +test "PageList incremental compression bounds inspected pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + + // Precompress every candidate so the incremental pass exercises its + // metadata-only skip budget without stopping at a resident attempt. + s.compress(); + try testing.expectEqual( + incremental_compression_max_inspected + 1, + s.memoryStats().compressed_pages, + ); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + var state: IncrementalCompressionState = .{}; + const first = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual(expected_last.serial, state.last_serial.?); + + expected_last = expected_last.next.?; + const second = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, second); + try testing.expectEqual(expected_last.serial, state.last_serial.?); +} + +test "PageList incremental compression advances after failure" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x494E_4352_5041_5353); + prng.random().bytes(first.page().memory); + + var state: IncrementalCompressionState = .{}; + const failed = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // The unsuccessful first page does not stall the pass. The next step + // continues at the following serial and compresses that page. + const complete = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, complete); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after allocation failure" { + const testing = std.testing; + + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + const first = s.pages.first.?; + const second = first.next.?; + + // Pool preheating supplies compression scratch. Failing the allocator's + // next request therefore rejects the exact encoded allocation while the + // source page and pass remain valid. + failing.fail_index = failing.alloc_index; + var state: IncrementalCompressionState = .{}; + const failed = s.compressIncremental(&state); + try testing.expect(failing.has_induced_failure); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // Allow allocations again. The pass must continue with the following page + // rather than retrying the failed candidate. + failing.fail_index = std.math.maxInt(usize); + const complete = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, complete); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after decommit failure" { + const testing = std.testing; + const tw = compressPage_tw; + defer tw.end(.reset) catch unreachable; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + tw.errorAlways(.decommit, error.DecommitFailed); + var state: IncrementalCompressionState = .{}; + const failed = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!s.pages.first.?.isCompressed()); + try tw.end(.reset); + + // The failed candidate remains resident and the pass continues at the + // next serial once reclamation is available again. + const complete = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, complete); + try testing.expect(s.pages.first.?.next.?.isCompressed()); +} + +test "PageList incremental compression restarts after replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + const initial = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const old = s.pages.first.?; + const old_serial = old.serial; + var replacement = old; + while (replacement.page().memory.len <= std_size) { + replacement = try s.increaseCapacity( + replacement, + .grapheme_bytes, + ); + } + try testing.expect(replacement.serial != old_serial); + try testing.expect(replacement.page().memory.len > std_size); + try testing.expect(!replacement.isCompressed()); + + // The exact continuation serial disappeared with the old node. The pass + // restarts at the first page and considers the oversized replacement. + const restarted = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, restarted); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression restarts after reset" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + const initial = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + // Reset replaces every page and advances their serials. The stale marker + // must restart at the new first page rather than dereferencing old state. + s.reset(); + try s.growColdPagesForTest(1); + const restarted = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, restarted); + try testing.expect(s.pages.first.?.isCompressed()); +} + +test "PageList incremental compression restarts after active boundary resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + const initial = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const first = s.pages.first.?; + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + try testing.expectEqual(first, s.getTopLeft(.active).node); + + // Restore the page while it is active. Because the continuation marker is + // no longer in the cold prefix, the next step resets itself and completes + // without reconsidering active contents. + _ = first.page(); + const active = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, active); + + // Shrinking the active area makes the page fully historical again. The + // marker was cleared above, so the next step can reclaim it. + try s.resize(.{ .rows = 24 }); + try s.growColdPagesForTest(1); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compressIncremental(&state), + ); + try testing.expect(first.isCompressed()); +} + +test "PageList incremental compression restarts after prune reuse" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + try s.growColdPagesForTest(1); + + var state: IncrementalCompressionState = .{}; + const initial = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const reused = s.pages.first.?; + const old_serial = reused.serial; + while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { + _ = try s.grow(); + } + try testing.expectEqual(reused, (try s.grow()).?); + try testing.expect(reused.serial != old_serial); + + // Make the remaining old page fully historical. The continuation serial + // disappeared when its node was recycled, so the pass safely restarts. + try s.growColdPagesForTest(1); + _ = s.compressIncremental(&state); + try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages); +} + +test "PageList incremental compression restarts after earlier replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + var state: IncrementalCompressionState = .{}; + _ = s.compressIncremental(&state); + _ = s.compressIncremental(&state); + try testing.expect(s.pages.first.?.isCompressed()); + try testing.expect(s.pages.first.?.next.?.isCompressed()); + + // Replace a page before the still-valid continuation marker. The list's + // allocation serial changes even though the marker itself remains, so the + // next step must restart and inspect the replacement. + const old_first = s.pages.first.?; + const old_serial = old_first.serial; + const replacement = try s.increaseCapacity( + old_first, + .grapheme_bytes, + ); + try testing.expect(replacement.serial != old_serial); + try testing.expect(!replacement.isCompressed()); + + _ = s.compressIncremental(&state); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression keeps progress after tail growth" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + s.compress(); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + var state: IncrementalCompressionState = .{}; + const first = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual(expected_last.serial, state.last_serial.?); + + // Allocate a new page at the active tail between steps. It is after the + // continuation marker and must not restart progress through cold history. + expected_last = expected_last.next.?; + const next_serial = s.page_serial; + while (s.page_serial == next_serial) _ = try s.grow(); + const continued = s.compressIncremental(&state); + try testing.expectEqual(IncrementalCompressionResult.complete, continued); + try testing.expectEqual(expected_last.serial, state.last_serial.?); +} + +test "PageList memory stats do not restore compressed pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const before = s.memoryStats(); + try testing.expectEqual(s.totalPages(), before.resident_pages); + try testing.expectEqual(@as(usize, 0), before.compressed_pages); + try testing.expectEqual(s.page_size, before.raw_bytes); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(@as(usize, 0), before.decommitted_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(@as(usize, 0), before.encoded_bytes); + try testing.expectEqual( + before.resident_backing_bytes, + before.estimatedResidentBytes(), + ); + try testing.expectEqual(@as(usize, 0), before.estimatedSavings()); + + s.compress(); + const first = s.pages.first.?; + try testing.expectEqual(Node.Storage.compressed, first.storage()); + try testing.expect(first.pageIfResident() == null); + + const after = s.memoryStats(); + try testing.expect(first.isCompressed()); + try testing.expectEqual(s.totalPages(), after.resident_pages + after.compressed_pages); + try testing.expectEqual(@as(usize, 2), after.compressed_pages); + try testing.expectEqual(s.page_size, after.raw_bytes); + try testing.expectEqual( + after.raw_bytes, + after.resident_raw_bytes + after.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + after.encoded_bytes, + after.estimatedResidentBytes(), + ); + try testing.expectEqual( + after.decommitted_raw_bytes - after.encoded_bytes, + after.estimatedSavings(), + ); + + const first_raw_len = first.metadata().memory.len; + const first_encoded_len = first.data.compressed.encoded.len; + _ = first.page(); + try testing.expectEqual(Node.Storage.resident, first.storage()); + try testing.expect(first.pageIfResident() != null); + + const restored = s.memoryStats(); + try testing.expectEqual(after.resident_pages + 1, restored.resident_pages); + try testing.expectEqual(after.compressed_pages - 1, restored.compressed_pages); + try testing.expectEqual(after.raw_bytes, restored.raw_bytes); + try testing.expectEqual( + after.resident_raw_bytes + first_raw_len, + restored.resident_raw_bytes, + ); + try testing.expectEqual( + after.decommitted_raw_bytes - first_raw_len, + restored.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + first_raw_len, + restored.resident_backing_bytes, + ); + try testing.expectEqual( + after.encoded_bytes - first_encoded_len, + restored.encoded_bytes, + ); +} + +test "PageList memory stats include unused pool backing" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // Pool allocation ownership is based on the requested layout fitting in a + // standard item. The Page itself exposes only the initialized prefix. + const node = try s.createPage(.{ .cap = .{ .cols = 1, .rows = 1 } }); + try testing.expectEqual(Node.Owned.pool, node.owned); + try testing.expect(node.page().memory.len < PagePool.item_size); + node.page().size.rows = 1; + s.pages.append(node); + s.total_rows += 1; + + const raw_len = node.metadata().memory.len; + const before = s.memoryStats(); + try testing.expect(before.raw_bytes < s.page_size); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(s.page_size, before.estimatedResidentBytes()); + + try testing.expect(s.compressPage(node)); + const encoded_len = node.data.compressed.encoded.len; + const compressed = s.memoryStats(); + try testing.expectEqual(before.raw_bytes, compressed.raw_bytes); + try testing.expectEqual( + before.resident_raw_bytes - raw_len, + compressed.resident_raw_bytes, + ); + try testing.expectEqual(raw_len, compressed.decommitted_raw_bytes); + try testing.expectEqual( + before.resident_backing_bytes - raw_len, + compressed.resident_backing_bytes, + ); + try testing.expectEqual(encoded_len, compressed.encoded_bytes); + try testing.expectEqual( + before.estimatedResidentBytes() - raw_len + encoded_len, + compressed.estimatedResidentBytes(), + ); +} + test "PageList does not compress the mixed history and active page" { const testing = std.testing; @@ -6049,7 +6707,7 @@ test "PageList does not compress the mixed history and active page" { try testing.expectEqual(s.pages.first.?, active.node); try testing.expect(active.y > 0); - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); try testing.expect(!s.pages.first.?.isCompressed()); } @@ -6073,15 +6731,15 @@ test "PageList compresses only complete cold history pages" { try testing.expect(active.y > 0); try testing.expect(active_node != s.pages.last.?); - var expected_attempts: usize = 0; + var expected_compressed: usize = 0; var expected_raw_bytes: usize = 0; var current = s.pages.first; while (current) |node| : (current = node.next) { if (node == active_node) break; - expected_attempts += 1; + expected_compressed += 1; expected_raw_bytes += node.metadata().memory.len; } - try testing.expectEqual(@as(usize, 2), expected_attempts); + try testing.expectEqual(@as(usize, 2), expected_compressed); const first = s.pages.first.?; first.page().getRowAndCell(0, 0).cell.* = .init('X'); @@ -6090,11 +6748,11 @@ test "PageList compresses only complete cold history pages" { const first_memory = first.page().memory.ptr; const page_size = s.page_size; - const stats = s.compressColdPages(); - try testing.expectEqual(expected_attempts, stats.attempted_pages); - try testing.expectEqual(expected_attempts, stats.compressed_pages); - try testing.expectEqual(expected_raw_bytes, stats.raw_bytes); - try testing.expect(stats.encoded_bytes < stats.raw_bytes); + s.compress(); + const memory = s.memoryStats(); + try testing.expectEqual(expected_compressed, memory.compressed_pages); + try testing.expectEqual(expected_raw_bytes, memory.decommitted_raw_bytes); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); try testing.expectEqual(page_size, s.page_size); current = s.pages.first; @@ -6104,7 +6762,7 @@ test "PageList compresses only complete cold history pages" { try testing.expect(node.isCompressed()); actual_encoded_bytes += node.data.compressed.encoded.len; } - try testing.expectEqual(actual_encoded_bytes, stats.encoded_bytes); + try testing.expectEqual(actual_encoded_bytes, memory.encoded_bytes); current = active_node; while (current) |node| : (current = node.next) { try testing.expect(!node.isCompressed()); @@ -6130,8 +6788,7 @@ test "PageList lazily restores compressed history made active by resize" { const memory_len = first.page().memory.len; const page_size = s.page_size; - const compressed = s.compressColdPages(); - try testing.expectEqual(@as(usize, 1), compressed.compressed_pages); + s.compress(); try testing.expect(first.isCompressed()); // Pull all scrollback into the active area by making the viewport as tall @@ -6148,7 +6805,8 @@ test "PageList lazily restores compressed history made active by resize" { // The compression pass must not reconsider the node now that it is active. // Content access follows the normal page boundary, which recommits and // restores the retained mapping before returning the cell. - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); + try testing.expect(first.isCompressed()); const cell = s.getCell(.{ .active = .{} }).?; try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint); try testing.expect(!first.isCompressed()); @@ -6166,22 +6824,23 @@ test "PageList cold compression defers historical viewports and retries restored const page_size = s.page_size; s.scroll(.top); - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); try testing.expect(!s.pages.first.?.isCompressed()); s.scroll(.{ .row = 1 }); try testing.expect(s.viewport == .pin); - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); try testing.expect(!s.pages.first.?.isCompressed()); s.scroll(.active); - const initial = s.compressColdPages(); - try testing.expectEqual(@as(usize, 2), initial.attempted_pages); + s.compress(); + const initial = s.memoryStats(); try testing.expectEqual(@as(usize, 2), initial.compressed_pages); try testing.expectEqual(page_size, s.page_size); // A second pass skips the two compressed representations entirely. - try testing.expectEqual(CompressionStats{}, s.compressColdPages()); + s.compress(); + try testing.expectEqual(initial, s.memoryStats()); // Content access restores one page. Because the policy is stateless, the // next explicit pass sees that resident page and compresses it again. @@ -6190,9 +6849,8 @@ test "PageList cold compression defers historical viewports and retries restored _ = first.page(); try testing.expect(!first.isCompressed()); - const retry = s.compressColdPages(); - try testing.expectEqual(@as(usize, 1), retry.attempted_pages); - try testing.expectEqual(@as(usize, 1), retry.compressed_pages); + s.compress(); + try testing.expectEqual(@as(usize, 2), s.memoryStats().compressed_pages); try testing.expectEqual(memory_ptr, first.metadata().memory.ptr); try testing.expectEqual(page_size, s.page_size); } @@ -6210,22 +6868,22 @@ test "PageList cold compression continues after an incompressible page" { prng.random().bytes(first.page().memory); const page_size = s.page_size; - const stats = s.compressColdPages(); - try testing.expectEqual(@as(usize, 2), stats.attempted_pages); - try testing.expectEqual(@as(usize, 1), stats.compressed_pages); + s.compress(); + const memory = s.memoryStats(); + try testing.expectEqual(@as(usize, 1), memory.compressed_pages); try testing.expect(!first.isCompressed()); try testing.expect(second.isCompressed()); - try testing.expectEqual(second.metadata().memory.len, stats.raw_bytes); - try testing.expect(stats.encoded_bytes < stats.raw_bytes); + try testing.expectEqual( + second.metadata().memory.len, + memory.decommitted_raw_bytes, + ); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); try testing.expectEqual(page_size, s.page_size); // Failed resident candidates are deliberately retried on later passes, // while the successful page remains compressed and is skipped. - const retry = s.compressColdPages(); - try testing.expectEqual(@as(usize, 1), retry.attempted_pages); - try testing.expectEqual(@as(usize, 0), retry.compressed_pages); - try testing.expectEqual(@as(usize, 0), retry.raw_bytes); - try testing.expectEqual(@as(usize, 0), retry.encoded_bytes); + s.compress(); + try testing.expectEqual(memory, s.memoryStats()); } test "PageList compression restores through page access" { From e7969ed43658b1330e282254cc1f0fe3805379f7 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jul 2026 21:32:21 -0700 Subject: [PATCH 07/18] terminal: make PageList compression self-contained Incremental compression previously exposed its traversal state to callers, requiring them to coordinate the cursor with PageList lifetime and topology changes. Read-only consumers also had to restore compressed nodes to inspect their contents. Move the continuation state into PageList and expose a single mode-based compression entry point. Incremental passes restart safely across mutations and verify a no-work pass before becoming idle, while full passes leave incremental state fresh. Add preserved page reads which decode compressed nodes into caller-owned storage without changing their representation, and migrate the scrollback compression benchmark to the new API. --- src/benchmark/ScrollbackCompression.zig | 32 +- src/terminal/PageList.zig | 613 +++++++++++++++++------- src/terminal/compress/Page.zig | 64 ++- 3 files changed, 504 insertions(+), 205 deletions(-) diff --git a/src/benchmark/ScrollbackCompression.zig b/src/benchmark/ScrollbackCompression.zig index d541f25fd..3684a91b3 100644 --- a/src/benchmark/ScrollbackCompression.zig +++ b/src/benchmark/ScrollbackCompression.zig @@ -25,10 +25,10 @@ //! * `noop` parses the corpus but performs no timed PageList operation. This is //! the common process and setup baseline for the other modes. //! * `compress` times one complete `compress` invocation. -//! * `incremental` performs the same complete pass through -//! `compressIncremental`. It drains the candidate-bounded steps in the timed -//! region, making the cursor and repeated traversal overhead directly -//! comparable with `compress`. +//! * `incremental` reaches the same final representation through +//! `compress(.incremental)`. It drains the candidate-bounded steps and final +//! no-work verification pass in the timed region, making cursor and repeated +//! traversal overhead directly comparable with `compress`. //! * `restore` compresses cold history during setup, outside the timed region, //! then visits every fully historical node through `Node.page`. That public //! content-access boundary transparently restores compressed nodes. @@ -77,9 +77,6 @@ const log = std.log.scoped(.@"scrollback-compression-bench"); opts: Options, terminal: Terminal, -/// Cursor state used by incremental mode between candidate-bounded steps. -incremental_state: PageList.IncrementalCompressionState = .{}, - pub const Options = struct { /// Set by the shared CLI parser so the `data` string remains valid for the /// lifetime of the benchmark implementation. @@ -167,7 +164,6 @@ pub fn benchmark(self: *ScrollbackCompression) Benchmark { fn setup(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); self.terminal.fullReset(); - self.incremental_state.reset(); self.loadCorpus() catch |err| { log.warn("failed to prepare scrollback compression benchmark err={}", .{err}); @@ -175,7 +171,7 @@ fn setup(ptr: *anyopaque) Benchmark.Error!void { }; if (self.opts.mode == .restore) { - self.pages().compress(); + _ = self.pages().compress(.full); } } @@ -215,27 +211,24 @@ fn stepNoop(ptr: *anyopaque) Benchmark.Error!void { fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); - self.pages().compress(); + _ = self.pages().compress(.full); std.mem.doNotOptimizeAway(&self.terminal); } fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); self.drainIncrementalCompression(); - std.mem.doNotOptimizeAway(&self.incremental_state); std.mem.doNotOptimizeAway(&self.terminal); } -/// Drain one complete resumable compression pass. +/// Drain incremental compression through its no-work verification pass. /// /// Each PageList step has a bounded candidate-inspection budget. The benchmark /// loops until no more immediate work is available so its timed result can be /// compared with the existing monolithic `compress` mode. fn drainIncrementalCompression(self: *ScrollbackCompression) void { while (true) { - switch (self.pages().compressIncremental( - &self.incremental_state, - )) { + switch (self.pages().compress(.incremental)) { .pending => continue, .unsupported, .deferred, @@ -271,7 +264,7 @@ fn visitColdPages(self: *ScrollbackCompression) usize { fn stepReport(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); - self.pages().compress(); + _ = self.pages().compress(.full); const memory = self.pages().memoryStats(); std.debug.print( @@ -321,7 +314,7 @@ test "ScrollbackCompression restores cold terminal pages" { defer stream.deinit(); for (0..256) |_| stream.nextSlice("aaaa\r\n"); - impl.pages().compress(); + _ = impl.pages().compress(.full); const compressed = impl.pages().memoryStats(); try testing.expect(compressed.compressed_pages > 0); try testing.expect(impl.visitColdPages() >= compressed.compressed_pages); @@ -329,7 +322,7 @@ test "ScrollbackCompression restores cold terminal pages" { // Restored historical pages are resident and therefore eligible for a // later explicit pass. This also verifies that the benchmark traversal // went through Node.page rather than merely inspecting page metadata. - impl.pages().compress(); + _ = impl.pages().compress(.full); const recompressed = impl.pages().memoryStats(); try testing.expectEqual( compressed.compressed_pages, @@ -351,7 +344,6 @@ test "ScrollbackCompression drains incremental compression steps" { defer stream.deinit(); for (0..256) |_| stream.nextSlice("aaaa\r\n"); - impl.incremental_state.reset(); impl.drainIncrementalCompression(); const incremental = impl.pages().memoryStats(); try testing.expect(incremental.compressed_pages > 0); @@ -359,7 +351,7 @@ test "ScrollbackCompression drains incremental compression steps" { // Restore the same pages and compare against the monolithic operation. // Both paths should produce the same final storage representation. _ = impl.visitColdPages(); - impl.pages().compress(); + _ = impl.pages().compress(.full); const monolithic = impl.pages().memoryStats(); try testing.expectEqual(monolithic, incremental); } diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e5a9f514b..e2b4da761 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -107,6 +107,89 @@ const Node = struct { }; } + /// Read-only page with full memory access that doesn't change + /// this node's storage state: if a node is compressed it remains + /// compressed. + /// + /// Resident pages are borrowed directly. So they're basically free. + /// + /// Compressed pages are decoded into an independently owned Page + /// by the caller so callers can inspect their contents. Because resident + /// pages are still borrowed, you have to continue to have exclusive + /// access to the PageList during this operation. + pub const PreservedPage = union(enum) { + borrowed: *const Page, + owned: struct { + page: Page, + alloc: Allocator, + }, + + /// Return the read-only page represented by this value. + pub fn page(self: *const PreservedPage) *const Page { + return switch (self.*) { + .borrowed => |page_| page_, + .owned => |*owned| &owned.page, + }; + } + + /// Release storage owned by this preserved page. + pub fn deinit(self: *PreservedPage) void { + switch (self.*) { + .borrowed => {}, + // The clone buffer came from the caller's allocator rather + // than Page's OS allocator, so it must not use Page.deinit. + .owned => |*owned| owned.alloc.free(owned.page.memory), + } + self.* = undefined; + } + }; + + /// Return read-only page contents without changing this node's storage. + /// + /// `alloc` is unused for a resident node. A compressed node uses it for an + /// exact-sized, page-aligned decode buffer owned by the returned value. + /// + /// The caller must call `PreservedPage.deinit` when finished. See + /// `PreservedPage` for the synchronization required by its borrowed + /// resident representation. + pub fn pagePreservingState( + self: *const Node, + alloc: Allocator, + ) Allocator.Error!PreservedPage { + return switch (self.data) { + .resident => |*page_| .{ .borrowed = page_ }, + .compressed => |*compressed| owned: { + const memory = try alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + compressed.page.memory.len, + ); + + const page_ = compressed.cloneBuf(memory) catch |err| { + // The encoded data was produced by our codec and remains + // immutable while compressed. Failure is internal + // corruption, matching the normal restoration boundary. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + alloc.free(memory); + log.err("failed to clone compressed page err={}", .{err}); + @panic("failed to clone compressed terminal page"); + }; + + break :owned .{ .owned = .{ + .page = page_, + .alloc = alloc, + } }; + }, + }; + } + /// Return the node's backing-memory representation without restoring it. pub inline fn storage(self: *const Node) Storage { return switch (self.data) { @@ -305,6 +388,11 @@ page_serial_min: u64, /// decommitted. It excludes encoded storage and unused preheated pool items. page_size: usize, +/// Continuation state for incremental page compression. This allows +/// compress(.incremental) to work. More details on all that there and +/// in the state struct. +page_compression: IncrementalCompressionState = .{}, + /// Maximum size of the page allocation in bytes. This only includes pages /// that are used ONLY for scrollback. If the active area is still partially /// in a page that also includes scrollback, then that page is not included. @@ -825,6 +913,9 @@ pub fn deinit(self: *PageList) void { pub fn reset(self: *PageList) void { defer self.assertIntegrity(); + // Reset always resets our compression state since we have new pages. + self.page_compression.reset(); + // Invalidate all external page refs to the previous list. The reset below // rebuilds the page list from the pools, so old untracked refs must be // rejected before any validation attempts to inspect their node pointers. @@ -1124,6 +1215,11 @@ pub const Resize = struct { pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void { defer self.assertIntegrity(); + // Resizing forces all nodes to be decompressed today so we need to + // reschedule compression. + // TODO(mitchellh): Deferred reflow on non-viewport/non-active pages. + self.page_compression.reset(); + if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected // upstream @@ -2697,6 +2793,12 @@ pub const Scroll = union(enum) { pub fn scroll(self: *PageList, behavior: Scroll) void { defer self.assertIntegrity(); + // Compression is deferred while viewing history. Restart traversal when + // crossing that boundary so any pages accessed there are reconsidered. + const was_active = self.viewport == .active; + defer if (was_active != (self.viewport == .active)) + self.page_compression.reset(); + // Special case no-scrollback mode to never allow scrolling. if (self.explicit_max_size == 0) { self.viewport = .active; @@ -3743,7 +3845,7 @@ const CompressionScratch = union(enum) { } }; -/// Caller-owned state for incremental compression. +/// PageList-owned state for incremental compression. /// /// The position is stored as a page serial rather than a node pointer so it /// remains safe when PageList operations destroy, replace, or reuse nodes @@ -3752,26 +3854,30 @@ const CompressionScratch = union(enum) { /// exact serial no longer exists in the cold prefix, the next step also /// restarts at the first page. /// -/// A state value belongs to one initialized PageList. In Ghostty's intended -/// runtime use, it will be stored alongside the Screen's PageList and naturally -/// share its lifetime. Reset, resize/reflow, pruning, and node reuse all keep -/// the same PageList, so they do not violate this rule. The state must be reset -/// only if unusual caller code preserves it while deinitializing and replacing -/// the entire PageList, such as assigning a fresh `init` or `clone` result into -/// the same field. Callers may also reset it to reconsider pages which were -/// restored or which failed an earlier attempt. -pub const IncrementalCompressionState = struct { +/// A completed pass is followed by another pass from the oldest cold page. +/// Compression becomes idle only when that verification pass compresses no +/// pages. This converges after pages are restored between incremental steps +/// without requiring consumers to maintain a separate attempt cursor. +const IncrementalCompressionState = struct { /// Serial of the last page inspected by the traversal. This is - /// intentionally an implementation detail; callers should use `reset` - /// rather than changing the continuation marker directly. + /// intentionally an implementation detail and is reset by PageList + /// operations which invalidate traversal progress. last_serial: ?u64 = null, /// First node serial which had not been allocated at the prior step. A /// node at or above this value before the saved marker requires a restart. next_serial: u64 = 0, + /// Whether this pass successfully compressed at least one page. + did_compress: bool = false, + + /// Whether this pass is verifying that the prior pass reached a fixed + /// point. A verification pass which does work starts another verification + /// pass; a verification pass with no work lets compression become idle. + verifying: bool = false, + /// Restart the next step at the first page in the list. - pub fn reset(self: *IncrementalCompressionState) void { + fn reset(self: *IncrementalCompressionState) void { self.* = .{}; } }; @@ -3784,10 +3890,11 @@ pub const IncrementalCompressionResult = enum { /// The viewport is displaying history, so compression is postponed. deferred, - /// More cold pages remain after this invocation's candidate-bounded work. + /// More cold pages or a verification pass remain after this invocation's + /// candidate-bounded work. pending, - /// The traversal reached the page containing the first active row. + /// A complete verification pass compressed zero pages. complete, }; @@ -3803,112 +3910,122 @@ const compressPage_tw = tripwire.module( error{DecommitFailed}, ); -/// Perform one candidate-bounded incremental cold-history compression step. +/// Compress eligible nodes, saving a significant amount of memory. /// -/// Eligibility is identical to `compress`: strict retained-mapping -/// reclamation must be available, the viewport must follow the active area, -/// and only complete pages before the active boundary are candidates. The -/// mixed history/active boundary page is never inspected or compressed. +/// Eligible nodes are complete pages before the active boundary. The boundary +/// page is excluded because it may contain both scrollback and active rows. /// -/// The continuation marker is relocated by serial on every call. This cursor -/// positioning is a metadata-only traversal; the eight-page candidate bound -/// applies after positioning. Since the state intentionally retains no node -/// pointer or tracked pin, positioning may traverse the cold prefix. Missing -/// serials restart at the first page, as does finding a node allocated since -/// the prior step before the marker. This remains safe without retaining a -/// potentially dangling node pointer. +/// Compression requires that we're running on a system that supports +/// reclaiming physical memory for virtual allocations and the viewport +/// must be in the active area. /// -/// Each step attempts at most one resident page and advances after every -/// outcome, including no savings, allocation failure, and failed decommit. An -/// unsuccessful page therefore cannot stall the traversal. Already-compressed -/// pages are skipped but count as inspected. Call -/// `IncrementalCompressionState.reset` when a new traversal should reconsider -/// restored or previously unsuccessful pages. -pub fn compressIncremental( +/// The mode specifies if this is an incremental or full compression. +/// Compression is SLOW (relatively), so incremental compressions during +/// idle periods are recommended. For incremental compressions, the result +/// specifies whether to continue incremental compressing or to stop until +/// an operation changes the state of the PageList (caller must determine +/// this). Full compression always returns `complete`, indicating that it has +/// no continuation to schedule rather than that every page was compressed. +pub fn compress( self: *PageList, - state: *IncrementalCompressionState, + mode: enum { incremental, full }, ) IncrementalCompressionResult { + return switch (mode) { + .incremental => self.compressIncremental(), + .full => full: { + self.compressFull(); + + // Full compression has no continuation. Discard any partial + // incremental cursor so later activity starts at the oldest page. + self.page_compression.reset(); + + break :full .complete; + }, + }; +} + +/// Perform one candidate-bounded incremental cold-history compression step. +fn compressIncremental(self: *PageList) IncrementalCompressionResult { + // If we can't reclaim virtual memory, compression is unsupported. if (!terminal_mem.canReclaim(.strict)) return .unsupported; + // If the viewport isn't in the active area, we don't do any compression. + // The user is likely scrolling and we don't want to cause a bunch of + // compress/decompress churn. Just wait, they have to go back down + // eventually, you'd think! if (self.viewport != .active) return .deferred; - const active_node = self.getTopLeft(.active).node; + // Get our compression state + const state = &self.page_compression; // Find the node following the exact continuation marker within the cold // prefix. A missing marker means the list changed between steps, so begin // again at the current first page. This lookup does not touch page memory. + const active_node = self.getTopLeft(.active).node; var current = self.pages.first.?; - if (state.last_serial) |last_serial| { - var found = false; - var inserted_before_marker = false; - var search = self.pages.first.?; - while (search != active_node) : (search = search.next.?) { - if (search.serial >= state.next_serial) - inserted_before_marker = true; - if (search.serial != last_serial) continue; - + if (state.last_serial) |last_serial| continuation: { + while (current != active_node) : (current = current.next.?) { // A newly allocated or replacement node appeared before the - // marker. Restart so that node cannot be skipped. Appends after - // the marker do not disturb progress through the existing prefix. - if (inserted_before_marker) { - state.last_serial = null; - current = self.pages.first.?; - } else { - current = search.next.?; - } - found = true; - break; + // marker. Restart immediately so that node cannot be skipped. + if (current.serial >= state.next_serial) break; + + // Not a match? Keep looking + if (current.serial != last_serial) continue; + + // Match! + current = current.next.?; + break :continuation; } - if (!found) { - state.last_serial = null; - current = self.pages.first.?; - } + // Not found or otherwise invalid. Reset + state.last_serial = null; + current = self.pages.first.?; } + + // Keep track of our next_serial state.next_serial = self.page_serial; + // We cap the number of pages we look at to do our best to + // time-bound the incremental compression. var inspected_pages: usize = 0; while (current != active_node and - inspected_pages < incremental_compression_max_inspected) + inspected_pages < incremental_compression_max_inspected) : (current = current.next.?) { - const node = current; - current = node.next.?; - state.last_serial = node.serial; + state.last_serial = current.serial; inspected_pages += 1; - if (node.isCompressed()) continue; + // If this page is already compressed, ignore it. + if (current.isCompressed()) continue; - // Compression is substantially more expensive than inspecting - // metadata, so one call attempts at most one resident page regardless - // of whether the attempt succeeds. - _ = self.compressPage(node); + // Compression is substantially more expensive even if it fails. + // So we just try it. + if (self.compressPage(current)) state.did_compress = true; + + // Breaking skips the while loop's continuation expression, so advance + // explicitly before checking whether we reached the active boundary. + current = current.next.?; break; } - return if (current == active_node) .complete else .pending; + // If we didn't reach our active node, then we still have work to do. + if (current != active_node) return .pending; + + // We reached our active node. So we're done, except that we always + // do one pass after the first success so we can recompress nodes that + // were possibly decompressed (e.g. by search, inspector, whatever). + if (!state.verifying or state.did_compress) { + state.* = .{ .verifying = true }; + return .pending; + } + + // Leave the state fresh while idle so later activity naturally begins at + // the oldest cold page, including pages restored after this pass. + state.reset(); + return .complete; } /// Compress every fully historical resident page which is currently cold. -/// -/// A page is fully historical only when it precedes the node containing the -/// first active row. The boundary node itself is excluded because a single -/// page can contain both scrollback and active rows, and compression discards -/// the complete backing mapping rather than an individual row range. -/// -/// The viewport must be following the active area. When the user is viewing -/// history, even pages outside the visible range are left alone so this pass -/// cannot race ahead of rendering and immediately trigger restoration work. -/// -/// This operation is deliberately stateless. Already-compressed pages are -/// skipped, while any resident page is attempted on each pass. Consequently, -/// a page restored by content access becomes eligible for recompression and a -/// page which previously failed opportunistically can be retried without a -/// per-node attempt flag. -/// -/// There are intentionally no production callers yet. This whole-history -/// operation exists so its policy, memory savings, and restoration costs can -/// be measured before choosing an incremental scheduling policy. -pub fn compress(self: *PageList) void { +fn compressFull(self: *PageList) void { // Avoid the codec, scratch allocation, and exact encoded allocation on a // target where strict retained-mapping reclamation cannot succeed. if (!terminal_mem.canReclaim(.strict)) return; @@ -3936,9 +4053,8 @@ pub fn compress(self: *PageList) void { /// Attempt to compress one resident page while retaining its raw mapping. /// /// Compression is opportunistic: every failure leaves the page resident and -/// usable. Candidate selection and retry policy belong to -/// `compress` and `compressIncremental`; this primitive only performs one -/// state transition. +/// usable. Candidate selection and retry policy belong to `compress`; this +/// primitive only performs one state transition. fn compressPage(self: *PageList, node: *List.Node) bool { // Recompression requires first restoring the raw page and is a policy // decision, so this primitive only accepts resident nodes. @@ -6267,34 +6383,85 @@ test "PageList incremental compression defers and completes" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; s.scroll(.top); - const deferred = s.compressIncremental(&state); + const deferred = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.deferred, deferred); const cold = s.pages.first.?; try testing.expect(!cold.isCompressed()); s.scroll(.active); - const complete = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, complete); + const pending = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, pending); try testing.expect(cold.isCompressed()); - // The exact continuation marker positions us directly at the active - // boundary on later calls. Positioning is not candidate inspection. - const repeated = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, repeated); + // Compression becomes idle only after a complete verification pass does + // no work. + const complete = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.complete, complete); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); - // Restoring a page behind the marker does not cause immediate churn. The - // caller explicitly resets when it wants a new pass to reconsider it. + // Completed state is fresh, so a later request naturally starts at the + // oldest page and recompresses restored content. _ = cold.page(); try testing.expect(!cold.isCompressed()); - const skipped = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, skipped); - - state.reset(); - const reconsidered = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, reconsidered); + const reconsidered = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, reconsidered); try testing.expect(cold.isCompressed()); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); +} + +test "PageList owns incremental compression state" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const dirty: IncrementalCompressionState = .{ + .last_serial = 42, + .next_serial = 43, + .did_compress = true, + .verifying = true, + }; + + s.page_compression = dirty; + s.scroll(.top); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + // Scrolling within history preserves progress, while crossing back to the + // active viewport resets it again. + s.page_compression = dirty; + s.scroll(.top); + try testing.expectEqual(dirty, s.page_compression); + s.scroll(.active); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + s.page_compression = dirty; + try s.resize(.{ .cols = 80, .rows = 24 }); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + s.page_compression = dirty; + s.reset(); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + s.page_compression = dirty; + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.full), + ); + try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + + s.page_compression = dirty; + var cloned = try s.clone(testing.allocator, .{ + .top = .{ .active = .{} }, + }); + defer cloned.deinit(); + try testing.expectEqual( + IncrementalCompressionState{}, + cloned.page_compression, + ); } test "PageList incremental compression bounds inspected pages" { @@ -6306,7 +6473,7 @@ test "PageList incremental compression bounds inspected pages" { // Precompress every candidate so the incremental pass exercises its // metadata-only skip budget without stopping at a resident attempt. - s.compress(); + _ = s.compress(.full); try testing.expectEqual( incremental_compression_max_inspected + 1, s.memoryStats().compressed_pages, @@ -6316,15 +6483,27 @@ test "PageList incremental compression bounds inspected pages" { for (1..incremental_compression_max_inspected) |_| expected_last = expected_last.next.?; - var state: IncrementalCompressionState = .{}; - const first = s.compressIncremental(&state); + const first = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, first); - try testing.expectEqual(expected_last.serial, state.last_serial.?); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); - expected_last = expected_last.next.?; - const second = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, second); - try testing.expectEqual(expected_last.serial, state.last_serial.?); + const second = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, second); + try testing.expect(s.page_compression.verifying); + try testing.expect(s.page_compression.last_serial == null); + + // The verification pass is bounded independently, too. + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); } test "PageList incremental compression advances after failure" { @@ -6339,15 +6518,14 @@ test "PageList incremental compression advances after failure" { var prng = std.Random.DefaultPrng.init(0x494E_4352_5041_5353); prng.random().bytes(first.page().memory); - var state: IncrementalCompressionState = .{}; - const failed = s.compressIncremental(&state); + const failed = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, failed); try testing.expect(!first.isCompressed()); // The unsuccessful first page does not stall the pass. The next step // continues at the following serial and compresses that page. - const complete = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, complete); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); try testing.expect(second.isCompressed()); } @@ -6366,8 +6544,7 @@ test "PageList incremental compression advances after allocation failure" { // next request therefore rejects the exact encoded allocation while the // source page and pass remain valid. failing.fail_index = failing.alloc_index; - var state: IncrementalCompressionState = .{}; - const failed = s.compressIncremental(&state); + const failed = s.compress(.incremental); try testing.expect(failing.has_induced_failure); try testing.expectEqual(IncrementalCompressionResult.pending, failed); try testing.expect(!first.isCompressed()); @@ -6375,8 +6552,8 @@ test "PageList incremental compression advances after allocation failure" { // Allow allocations again. The pass must continue with the following page // rather than retrying the failed candidate. failing.fail_index = std.math.maxInt(usize); - const complete = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, complete); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); try testing.expect(second.isCompressed()); } @@ -6390,16 +6567,15 @@ test "PageList incremental compression advances after decommit failure" { try s.growColdPagesForTest(2); tw.errorAlways(.decommit, error.DecommitFailed); - var state: IncrementalCompressionState = .{}; - const failed = s.compressIncremental(&state); + const failed = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, failed); try testing.expect(!s.pages.first.?.isCompressed()); try tw.end(.reset); // The failed candidate remains resident and the pass continues at the // next serial once reclamation is available again. - const complete = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, complete); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); try testing.expect(s.pages.first.?.next.?.isCompressed()); } @@ -6410,9 +6586,8 @@ test "PageList incremental compression restarts after replacement" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; - const initial = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, initial); + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); try testing.expect(s.pages.first.?.isCompressed()); const old = s.pages.first.?; @@ -6430,8 +6605,8 @@ test "PageList incremental compression restarts after replacement" { // The exact continuation serial disappeared with the old node. The pass // restarts at the first page and considers the oversized replacement. - const restarted = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, restarted); + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); try testing.expect(replacement.isCompressed()); } @@ -6442,17 +6617,15 @@ test "PageList incremental compression restarts after reset" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; - const initial = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, initial); + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); try testing.expect(s.pages.first.?.isCompressed()); - // Reset replaces every page and advances their serials. The stale marker - // must restart at the new first page rather than dereferencing old state. + // Reset replaces every page and clears the PageList-owned traversal. s.reset(); try s.growColdPagesForTest(1); - const restarted = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, restarted); + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); try testing.expect(s.pages.first.?.isCompressed()); } @@ -6463,9 +6636,8 @@ test "PageList incremental compression restarts after active boundary resize" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; - const initial = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, initial); + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); try testing.expect(s.pages.first.?.isCompressed()); const first = s.pages.first.?; @@ -6473,20 +6645,23 @@ test "PageList incremental compression restarts after active boundary resize" { try s.resize(.{ .rows = all_rows }); try testing.expectEqual(first, s.getTopLeft(.active).node); - // Restore the page while it is active. Because the continuation marker is - // no longer in the cold prefix, the next step resets itself and completes - // without reconsidering active contents. + // Restore the page while it is active. Resize reset the traversal, and + // active contents remain ineligible. _ = first.page(); - const active = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, active); + const active = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, active); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); // Shrinking the active area makes the page fully historical again. The - // marker was cleared above, so the next step can reclaim it. + // resize reset the PageList-owned cursor, so the next step can reclaim it. try s.resize(.{ .rows = 24 }); try s.growColdPagesForTest(1); try testing.expectEqual( - IncrementalCompressionResult.complete, - s.compressIncremental(&state), + IncrementalCompressionResult.pending, + s.compress(.incremental), ); try testing.expect(first.isCompressed()); } @@ -6503,9 +6678,8 @@ test "PageList incremental compression restarts after prune reuse" { defer s.deinit(); try s.growColdPagesForTest(1); - var state: IncrementalCompressionState = .{}; - const initial = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, initial); + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); try testing.expect(s.pages.first.?.isCompressed()); const reused = s.pages.first.?; @@ -6519,7 +6693,7 @@ test "PageList incremental compression restarts after prune reuse" { // Make the remaining old page fully historical. The continuation serial // disappeared when its node was recycled, so the pass safely restarts. try s.growColdPagesForTest(1); - _ = s.compressIncremental(&state); + _ = s.compress(.incremental); try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages); } @@ -6530,9 +6704,8 @@ test "PageList incremental compression restarts after earlier replacement" { defer s.deinit(); try s.growColdPagesForTest(3); - var state: IncrementalCompressionState = .{}; - _ = s.compressIncremental(&state); - _ = s.compressIncremental(&state); + _ = s.compress(.incremental); + _ = s.compress(.incremental); try testing.expect(s.pages.first.?.isCompressed()); try testing.expect(s.pages.first.?.next.?.isCompressed()); @@ -6548,7 +6721,7 @@ test "PageList incremental compression restarts after earlier replacement" { try testing.expect(replacement.serial != old_serial); try testing.expect(!replacement.isCompressed()); - _ = s.compressIncremental(&state); + _ = s.compress(.incremental); try testing.expect(replacement.isCompressed()); } @@ -6558,25 +6731,26 @@ test "PageList incremental compression keeps progress after tail growth" { var s = try init(testing.allocator, 80, 24, null); defer s.deinit(); try s.growColdPagesForTest(incremental_compression_max_inspected + 1); - s.compress(); + _ = s.compress(.full); var expected_last = s.pages.first.?; for (1..incremental_compression_max_inspected) |_| expected_last = expected_last.next.?; - var state: IncrementalCompressionState = .{}; - const first = s.compressIncremental(&state); + const first = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, first); - try testing.expectEqual(expected_last.serial, state.last_serial.?); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); // Allocate a new page at the active tail between steps. It is after the // continuation marker and must not restart progress through cold history. - expected_last = expected_last.next.?; const next_serial = s.page_serial; while (s.page_serial == next_serial) _ = try s.grow(); - const continued = s.compressIncremental(&state); - try testing.expectEqual(IncrementalCompressionResult.complete, continued); - try testing.expectEqual(expected_last.serial, state.last_serial.?); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.page_compression.verifying); } test "PageList memory stats do not restore compressed pages" { @@ -6600,7 +6774,7 @@ test "PageList memory stats do not restore compressed pages" { ); try testing.expectEqual(@as(usize, 0), before.estimatedSavings()); - s.compress(); + _ = s.compress(.full); const first = s.pages.first.?; try testing.expectEqual(Node.Storage.compressed, first.storage()); try testing.expect(first.pageIfResident() == null); @@ -6651,6 +6825,87 @@ test "PageList memory stats do not restore compressed pages" { ); } +test "PageList preserved page keeps compressed storage" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const resident = node.page(); + resident.dirty = true; + resident.getRowAndCell(3, 2).cell.* = .init('X'); + + // Resident nodes can be borrowed without allocating an unnecessary copy. + { + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + var preserved = try node.pagePreservingState(failing.allocator()); + defer preserved.deinit(); + switch (preserved) { + .borrowed => |page_| try testing.expectEqual(resident, page_), + .owned => try testing.expect(false), + } + try testing.expect(!failing.has_induced_failure); + } + + const expected = try alloc.dupe(u8, resident.memory); + defer alloc.free(expected); + const retained_ptr = resident.memory.ptr; + + try testing.expect(s.compressPage(node)); + const stats = s.memoryStats(); + const expected_encoded = try alloc.dupe(u8, node.data.compressed.encoded); + defer alloc.free(expected_encoded); + + // Test decommit simulates physical reclamation by clearing the retained + // mapping. A preserved page must decode elsewhere rather than restoring + // it. + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + + // Preserved-page allocation is opportunistic for callers. Failure leaves + // the node and its compressed representation untouched. + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + try testing.expectError( + error.OutOfMemory, + node.pagePreservingState(failing.allocator()), + ); + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + + var preserved = try node.pagePreservingState(alloc); + defer preserved.deinit(); + switch (preserved) { + .borrowed => try testing.expect(false), + .owned => {}, + } + const page_ = preserved.page(); + + try testing.expect(page_.memory.ptr != retained_ptr); + try testing.expectEqualSlices(u8, expected, page_.memory); + try testing.expect(page_.dirty); + try testing.expectEqual( + @as(u21, 'X'), + page_.getRowAndCell(3, 2).cell.content.codepoint, + ); + + // The node still owns the same compressed representation, and neither its + // storage accounting nor its discarded raw mapping changed while cloning. + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + try testing.expectEqual(retained_ptr, node.metadata().memory.ptr); + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + try testing.expectEqualSlices( + u8, + expected_encoded, + node.data.compressed.encoded, + ); +} + test "PageList memory stats include unused pool backing" { const testing = std.testing; @@ -6707,7 +6962,7 @@ test "PageList does not compress the mixed history and active page" { try testing.expectEqual(s.pages.first.?, active.node); try testing.expect(active.y > 0); - s.compress(); + _ = s.compress(.full); try testing.expect(!s.pages.first.?.isCompressed()); } @@ -6748,7 +7003,7 @@ test "PageList compresses only complete cold history pages" { const first_memory = first.page().memory.ptr; const page_size = s.page_size; - s.compress(); + _ = s.compress(.full); const memory = s.memoryStats(); try testing.expectEqual(expected_compressed, memory.compressed_pages); try testing.expectEqual(expected_raw_bytes, memory.decommitted_raw_bytes); @@ -6788,7 +7043,7 @@ test "PageList lazily restores compressed history made active by resize" { const memory_len = first.page().memory.len; const page_size = s.page_size; - s.compress(); + _ = s.compress(.full); try testing.expect(first.isCompressed()); // Pull all scrollback into the active area by making the viewport as tall @@ -6805,7 +7060,7 @@ test "PageList lazily restores compressed history made active by resize" { // The compression pass must not reconsider the node now that it is active. // Content access follows the normal page boundary, which recommits and // restores the retained mapping before returning the cell. - s.compress(); + _ = s.compress(.full); try testing.expect(first.isCompressed()); const cell = s.getCell(.{ .active = .{} }).?; try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint); @@ -6824,22 +7079,22 @@ test "PageList cold compression defers historical viewports and retries restored const page_size = s.page_size; s.scroll(.top); - s.compress(); + _ = s.compress(.full); try testing.expect(!s.pages.first.?.isCompressed()); s.scroll(.{ .row = 1 }); try testing.expect(s.viewport == .pin); - s.compress(); + _ = s.compress(.full); try testing.expect(!s.pages.first.?.isCompressed()); s.scroll(.active); - s.compress(); + _ = s.compress(.full); const initial = s.memoryStats(); try testing.expectEqual(@as(usize, 2), initial.compressed_pages); try testing.expectEqual(page_size, s.page_size); // A second pass skips the two compressed representations entirely. - s.compress(); + _ = s.compress(.full); try testing.expectEqual(initial, s.memoryStats()); // Content access restores one page. Because the policy is stateless, the @@ -6849,7 +7104,7 @@ test "PageList cold compression defers historical viewports and retries restored _ = first.page(); try testing.expect(!first.isCompressed()); - s.compress(); + _ = s.compress(.full); try testing.expectEqual(@as(usize, 2), s.memoryStats().compressed_pages); try testing.expectEqual(memory_ptr, first.metadata().memory.ptr); try testing.expectEqual(page_size, s.page_size); @@ -6868,7 +7123,7 @@ test "PageList cold compression continues after an incompressible page" { prng.random().bytes(first.page().memory); const page_size = s.page_size; - s.compress(); + _ = s.compress(.full); const memory = s.memoryStats(); try testing.expectEqual(@as(usize, 1), memory.compressed_pages); try testing.expect(!first.isCompressed()); @@ -6882,7 +7137,7 @@ test "PageList cold compression continues after an incompressible page" { // Failed resident candidates are deliberately retried on later passes, // while the successful page remains compressed and is skipped. - s.compress(); + _ = s.compress(.full); try testing.expectEqual(memory, s.memoryStats()); } diff --git a/src/terminal/compress/Page.zig b/src/terminal/compress/Page.zig index bc103bb66..b7ad80256 100644 --- a/src/terminal/compress/Page.zig +++ b/src/terminal/compress/Page.zig @@ -131,6 +131,15 @@ pub fn init( }; } +/// Free the encoded block. +/// +/// This intentionally does not free `page.memory`. The PageList node which +/// supplied the source page continues to own that pool or heap allocation. +pub fn deinit(self: *Page) void { + self.alloc.free(self.encoded); + self.* = undefined; +} + /// Restore the embedded terminal page into its retained resident mapping. /// /// The caller must recommit `page.memory` before calling this on platforms @@ -144,13 +153,25 @@ pub fn restore(self: *const Page) lz4.DecompressError!TerminalPage { return result; } -/// Free the encoded block. +/// Clone this page into caller-owned memory without restoring its mapping. /// -/// This intentionally does not free `page.memory`. The PageList node which -/// supplied the source page continues to own that pool or heap allocation. -pub fn deinit(self: *Page) void { - self.alloc.free(self.encoded); - self.* = undefined; +/// `memory` must be at least as large as the retained page mapping. Decoding +/// writes only to that buffer, so both the discarded contents of `page.memory` +/// and this value's encoded representation remain unchanged. The returned Page +/// borrows `memory`; the caller must keep it alive for the Page's lifetime and +/// release it directly rather than calling `TerminalPage.deinit`. +pub fn cloneBuf( + self: *const Page, + memory: []align(std.heap.page_size_min) u8, +) lz4.DecompressError!TerminalPage { + assert(memory.len >= self.page.memory.len); + + // Page internals are offsets into the backing buffer, so all metadata can + // be copied verbatim when paired with an equally laid-out mapping. + var result = self.page; + result.memory = memory[0..self.page.memory.len]; + _ = try lz4.decompress(self.encoded, result.memory); + return result; } test "compressed Page retained mapping round trip" { @@ -208,11 +229,31 @@ test "compressed Page retained mapping round trip" { try testing.expectEqual(memory_ptr, compressed.page.memory.ptr); try testing.expectEqual(memory_len, compressed.page.memory.len); try testing.expect(compressed.encoded.len < resident.memory.len); + const expected_encoded = try testing.allocator.dupe(u8, compressed.encoded); + defer testing.allocator.free(expected_encoded); // Virtual-memory operations belong to PageList, so clearing the contents // models a successful decommit: none of the resident bytes remain. @memset(resident.memory, 0); + // A clone decodes into independent storage for read-only consumers which + // must not change the compressed representation. In particular, it does + // not recommit or overwrite the retained source mapping. + const clone_memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + resident.memory.len, + ); + defer testing.allocator.free(clone_memory); + const cloned = try compressed.cloneBuf(clone_memory); + try testing.expect(cloned.memory.ptr != memory_ptr); + try testing.expect(std.mem.allEqual(u8, resident.memory, 0)); + try testing.expectEqualSlices(u8, expected_encoded, compressed.encoded); + try testing.expectEqualSlices(u8, expected, cloned.memory); + try testing.expectEqual(resident.size, cloned.size); + try testing.expect(cloned.dirty); + try cloned.verifyIntegrity(testing.allocator); + const restored = try compressed.restore(); try testing.expectEqual(memory_ptr, restored.memory.ptr); try testing.expectEqual(memory_len, restored.memory.len); @@ -336,6 +377,17 @@ test "compressed Page can retry after malformed encoded data" { } compressed.encoded = full_encoded[0..1]; compressed.encoded[0] = 0xF0; + + const clone_memory = try testing.allocator.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + resident.memory.len, + ); + defer testing.allocator.free(clone_memory); + try testing.expectError( + error.TruncatedInput, + compressed.cloneBuf(clone_memory), + ); try testing.expectError(error.TruncatedInput, compressed.restore()); compressed.encoded = full_encoded; From 0d015b2fcea547b698ef66c084d5946ec9c8f662 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Wed, 8 Jul 2026 21:37:00 -0700 Subject: [PATCH 08/18] terminal: preserve compressed pages during search Search previously used the normal page access boundary while formatting history and checking soft-wrap boundaries. Inspecting compressed history therefore restored its retained mapping and undid the memory reclamation. Format through preserved page snapshots and copy row counts and wrap state into the sliding window's owned metadata. Overlap decisions reuse the same snapshot, so compressed pages are decoded at most once per append and remain compressed after matching. Add a cross-page regression which searches compressed history and verifies both source nodes retain their compressed representation. --- src/terminal/search/active.zig | 8 +-- src/terminal/search/pagelist.zig | 73 ++++++++++++++++++++++- src/terminal/search/sliding_window.zig | 80 +++++++++++++++++++++++--- src/terminal/search/viewport.zig | 18 +++--- 4 files changed, 153 insertions(+), 26 deletions(-) diff --git a/src/terminal/search/active.zig b/src/terminal/search/active.zig index 345c842fa..aee21b3e3 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -79,15 +79,11 @@ pub const ActiveSearch = struct { // Next, add enough overlap to cover needle.len - 1 bytes (if it // exists) so we can cover the overlap. while (node_) |node| : (node_ = node.prev) { - // If the last row of this node isn't wrapped we can't overlap. - const row = node.page().getRow(node.rows() - 1); - if (!row.wrap) break; - // We could be more accurate here and count bytes since the // last wrap but its complicated and unlikely multiple pages // wrap so this should be fine. - const added = try self.window.append(node); - if (added >= self.window.needle.len - 1) break; + const appended = try self.window.appendIfWrapped(node) orelse break; + if (appended.content_len >= self.window.needle.len - 1) break; } // Return the last node we added to our window. diff --git a/src/terminal/search/pagelist.zig b/src/terminal/search/pagelist.zig index 9d98e5eaf..e8354d799 100644 --- a/src/terminal/search/pagelist.zig +++ b/src/terminal/search/pagelist.zig @@ -3,6 +3,7 @@ const Allocator = std.mem.Allocator; const testing = std.testing; const terminal = @import("../main.zig"); const point = terminal.point; +const size = terminal.size; const FlattenedHighlight = @import("../highlight.zig").Flattened; const Page = terminal.Page; const PageList = terminal.PageList; @@ -121,7 +122,7 @@ pub const PageListSearch = struct { // get our desired amount of data. var node_: ?*PageList.List.Node = self.pin.node.prev; while (node_) |node| : (node_ = node.prev) { - rem -|= try self.window.append(node); + rem -|= (try self.window.append(node)).content_len; // Move our tracked pin to the new node. self.pin.node = node; @@ -359,6 +360,76 @@ test "feed with match spanning page boundary" { try testing.expect(!try search.feed()); } +test "compressed history match spanning page boundary remains compressed" { + const alloc = testing.allocator; + var t: Terminal = try .init(alloc, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10 * 1024 * 1024, + }); + defer t.deinit(alloc); + + var stream = t.vtStream(); + defer stream.deinit(); + + const pages = &t.screens.active.pages; + const first = pages.pages.first.?; + const first_page_rows = first.capacity().rows; + + // Put half the needle on either side of a soft-wrapped page boundary. + for (0..first_page_rows - 1) |_| stream.nextSlice("\r\n"); + for (0..pages.cols - 2) |_| stream.nextSlice("x"); + stream.nextSlice("Test"); + const second = first.next.?; + try testing.expectEqual(second, pages.pages.last.?); + + // Move both matching pages completely into history. The third page holds + // the active area, making the first two eligible for compression. + while (pages.pages.last.? == second) stream.nextSlice("\r\n"); + for (0..pages.rows) |_| stream.nextSlice("\r\n"); + try testing.expect(pages.getTopLeft(.active).node != second); + + _ = pages.compress(.full); + try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); + const compressed = pages.memoryStats(); + try testing.expect(compressed.compressed_pages >= 2); + + var search: PageListSearch = try .init( + alloc, + "Test", + pages, + pages.pages.last.?, + ); + defer search.deinit(); + + // Drain and feed incrementally until the boundary match is available. The + // exact number of feeds depends on how much blank-page text the formatter + // trims, so bound progress by the number of pages rather than encoding + // that implementation detail in the test. + var found: ?FlattenedHighlight = null; + for (0..pages.totalPages() + 1) |_| { + if (search.next()) |match| { + found = match; + break; + } + if (!try search.feed()) break; + } + try testing.expect(found != null); + + const match = found.?.untracked(); + try testing.expectEqual(first, match.start.node); + try testing.expectEqual(second, match.end.node); + try testing.expectEqual(@as(size.CellCountInt, pages.cols - 2), match.start.x); + try testing.expectEqual(@as(size.CellCountInt, 1), match.end.x); + + // Search owns formatted text and coordinate maps, not page snapshots. The + // original raw mappings therefore remain discarded after matching. + try testing.expectEqual(compressed, pages.memoryStats()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, first.storage()); + try testing.expectEqual(PageList.List.Node.Storage.compressed, second.storage()); +} + test "feed with match spanning page boundary with newline" { const alloc = testing.allocator; var t: Terminal = try .init(alloc, .{ .cols = 80, .rows = 24 }); diff --git a/src/terminal/search/sliding_window.zig b/src/terminal/search/sliding_window.zig index 77b4d0ad0..ea039bf3a 100644 --- a/src/terminal/search/sliding_window.zig +++ b/src/terminal/search/sliding_window.zig @@ -89,6 +89,7 @@ pub const SlidingWindow = struct { const Meta = struct { node: *PageList.List.Node, serial: u64, + rows: size.CellCountInt, cell_map: std.ArrayList(point.Coordinate), pub fn deinit(self: *Meta, alloc: Allocator) void { @@ -96,6 +97,17 @@ pub const SlidingWindow = struct { } }; + /// Information copied from a page while appending it to the window. + /// + /// Both values remain safe after the node's preserved page is released. + /// In particular, callers use `last_row_wrapped` to decide whether an + /// adjacent page can contribute to a cross-page match without reading the + /// node again. + pub const AppendResult = struct { + content_len: usize, + last_row_wrapped: bool, + }; + pub fn init( alloc: Allocator, direction: Direction, @@ -309,6 +321,12 @@ pub const SlidingWindow = struct { self.chunk_buf.clearRetainingCapacity(); var result: terminal.highlight.Flattened = .empty; + // A reverse cross-page match needs the row count of the last meta in + // search order after the chunks themselves are reversed. Snapshot it + // while traversing Meta rather than dereferencing a PageList node after + // the terminal lock has been released. + var cross_page_end_rows: ?size.CellCountInt = null; + // Go through the meta nodes to find our start. const tl: struct { /// If non-null, we need to continue searching for the bottom-right. @@ -375,7 +393,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = @intCast(map.y), - .end = meta.node.rows(), + .end = meta.rows, }); break :tl .{ @@ -410,7 +428,7 @@ pub const SlidingWindow = struct { .node = meta.node, .serial = meta.serial, .start = 0, - .end = meta.node.rows(), + .end = meta.rows, }); meta_consumed += meta.cell_map.items.len; @@ -420,6 +438,7 @@ pub const SlidingWindow = struct { // We found it const map = meta.cell_map.items[meta_i]; result.bot_x = map.x; + cross_page_end_rows = meta.rows; self.chunk_buf.appendAssumeCapacity(.{ .node = meta.node, .serial = meta.serial, @@ -491,7 +510,7 @@ pub const SlidingWindow = struct { // order. assert(nodes.len >= 2); starts[0] = ends[0] - 1; - ends[0] = nodes[0].rows(); + ends[0] = cross_page_end_rows.?; ends[nodes.len - 1] = starts[nodes.len - 1] + 1; starts[nodes.len - 1] = 0; } else { @@ -526,11 +545,49 @@ pub const SlidingWindow = struct { pub fn append( self: *SlidingWindow, node: *PageList.List.Node, - ) Allocator.Error!usize { + ) Allocator.Error!AppendResult { + var preserved = try node.pagePreservingState(self.alloc); + defer preserved.deinit(); + + const page = preserved.page(); + const last_row_wrapped = page.getRow(page.size.rows - 1).wrap; + return self.appendPage(node, page, last_row_wrapped); + } + + /// Append a node only when its last row is soft wrapped. + /// + /// This acquires one preserved page for both the wrap check and formatting + /// so a compressed node is decoded at most once. It is used when loading + /// the overlap around an otherwise complete search region. + pub fn appendIfWrapped( + self: *SlidingWindow, + node: *PageList.List.Node, + ) Allocator.Error!?AppendResult { + var preserved = try node.pagePreservingState(self.alloc); + defer preserved.deinit(); + + const page = preserved.page(); + const last_row_wrapped = page.getRow(page.size.rows - 1).wrap; + if (!last_row_wrapped) return null; + return try self.appendPage(node, page, last_row_wrapped); + } + + /// Copy one preserved page into the window's owned search buffers. + /// + /// No pointer into `page` may escape this function: compressed preserved + /// pages own temporary decode storage, while resident values borrow node + /// memory. + fn appendPage( + self: *SlidingWindow, + node: *PageList.List.Node, + page: *const terminal.Page, + last_row_wrapped: bool, + ) Allocator.Error!AppendResult { // Initialize our metadata for the node. var meta: Meta = .{ .node = node, .serial = node.serial, + .rows = page.size.rows, .cell_map = .empty, }; errdefer meta.deinit(self.alloc); @@ -544,7 +601,7 @@ pub const SlidingWindow = struct { // Encode the page into the buffer. const formatter: PageFormatter = formatter: { - var formatter: PageFormatter = .init(meta.node.page(), .{ + var formatter: PageFormatter = .init(page, .{ .emit = .plain, .unwrap = true, }); @@ -563,8 +620,7 @@ pub const SlidingWindow = struct { // If the node we're adding isn't soft-wrapped, we add the // trailing newline. - const row = node.page().getRow(node.rows() - 1); - if (!row.wrap) { + if (!last_row_wrapped) { encoded.writer.writeByte('\n') catch return error.OutOfMemory; try meta.cell_map.append( self.alloc, @@ -580,7 +636,10 @@ pub const SlidingWindow = struct { const written = encoded.written(); if (written.len == 0) { self.assertIntegrity(); - return 0; + return .{ + .content_len = 0, + .last_row_wrapped = last_row_wrapped, + }; } // Get our written data. If we're doing a reverse search then we @@ -603,7 +662,10 @@ pub const SlidingWindow = struct { self.meta.appendAssumeCapacity(meta); self.assertIntegrity(); - return written.len; + return .{ + .content_len = written.len, + .last_row_wrapped = last_row_wrapped, + }; } /// Only for tests! diff --git a/src/terminal/search/viewport.zig b/src/terminal/search/viewport.zig index 67e8cabf2..a1458bbed 100644 --- a/src/terminal/search/viewport.zig +++ b/src/terminal/search/viewport.zig @@ -137,37 +137,35 @@ pub const ViewportSearch = struct { var node_ = fingerprint.nodes[0].prev; var added: usize = 0; while (node_) |node| : (node_ = node.prev) { - // If the last row of this node isn't wrapped we can't overlap. - const row = node.page().getRow(node.rows() - 1); - if (!row.wrap) break; - // We could be more accurate here and count bytes since the // last wrap but its complicated and unlikely multiple pages // wrap so this should be fine. - added += try self.window.append(node); + const appended = try self.window.appendIfWrapped(node) orelse break; + added += appended.content_len; if (added >= self.window.needle.len - 1) break; } // We can use our fingerprint nodes to initialize our sliding // window, since we already traversed the viewport once. + var end_append: SlidingWindow.AppendResult = undefined; for (fingerprint.nodes) |node| { - _ = try self.window.append(node); + end_append = try self.window.append(node); } // Add any trailing overlap as well. trailing: { const end: *PageList.List.Node = fingerprint.nodes[fingerprint.nodes.len - 1]; - if (!end.page().getRow(end.rows() - 1).wrap) break :trailing; + if (!end_append.last_row_wrapped) break :trailing; node_ = end.next; added = 0; while (node_) |node| : (node_ = node.next) { - added += try self.window.append(node); + const appended = try self.window.append(node); + added += appended.content_len; if (added >= self.window.needle.len - 1) break; // If this row doesn't wrap, then we can quit - const row = node.page().getRow(node.rows() - 1); - if (!row.wrap) break; + if (!appended.last_row_wrapped) break; } } From f8c217e557e5aee4275e29ba4fedc559d1c730f5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 07:03:17 -0700 Subject: [PATCH 09/18] terminal: track pending scrollback compression PageList previously kept only traversal position, so callers had no central signal for deciding when an incremental compression pass should run. Scheduling policy had to infer work from output and UI activity. Track compression dirtiness alongside the PageList continuation state. Growth preserves valid progress while marking work pending, and resize or viewport transitions restart from the oldest page. A no-work verification pass clears the state. Expose Terminal helpers which report whether compression is required and run compression against primary scrollback even while the alternate screen is active. Unsupported retained-memory targets report no work. --- src/terminal/PageList.zig | 112 +++++++++++++++++++++++++++----------- src/terminal/Terminal.zig | 48 ++++++++++++++++ 2 files changed, 129 insertions(+), 31 deletions(-) diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index e2b4da761..032efe4d3 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -913,7 +913,7 @@ pub fn deinit(self: *PageList) void { pub fn reset(self: *PageList) void { defer self.assertIntegrity(); - // Reset always resets our compression state since we have new pages. + // Reset discards all scrollback, so there is nothing left to compress. self.page_compression.reset(); // Invalidate all external page refs to the previous list. The reset below @@ -1219,6 +1219,7 @@ pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void { // reschedule compression. // TODO(mitchellh): Deferred reflow on non-viewport/non-active pages. self.page_compression.reset(); + self.page_compression.markDirty(); if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected @@ -2796,8 +2797,10 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { // Compression is deferred while viewing history. Restart traversal when // crossing that boundary so any pages accessed there are reconsidered. const was_active = self.viewport == .active; - defer if (was_active != (self.viewport == .active)) + defer if (was_active != (self.viewport == .active)) { self.page_compression.reset(); + if (self.viewport == .active) self.page_compression.markDirty(); + }; // Special case no-scrollback mode to never allow scrolling. if (self.explicit_max_size == 0) { @@ -3429,6 +3432,9 @@ pub fn maxSize(self: *const PageList) usize { pub fn grow(self: *PageList) Allocator.Error!?*List.Node { defer self.assertIntegrity(); + // Growing can move a complete page behind the active boundary. + self.page_compression.markDirty(); + const last = self.pages.last.?; if (last.capacity().rows > last.rows()) { // Fast path: we have capacity in the last page. @@ -3859,24 +3865,39 @@ const CompressionScratch = union(enum) { /// pages. This converges after pages are restored between incremental steps /// without requiring consumers to maintain a separate attempt cursor. const IncrementalCompressionState = struct { + flags: packed struct { + // Set to true when incremental compression should be run. It + // isn't guaranteed that incremental compression would produce + // results but some work was done that may require compression. + dirty: bool = false, + + /// Set after any page is compressed during the current traversal. + /// We always run incremental compression until we get a fully + /// no-compression pass (the verification pass). This lets us + /// recompress decompressed pages due to search, scrolling, etc. + did_compress: bool = false, + + /// Set after a traversal first reaches the active boundary. When this + /// verification traversal reaches the boundary, `did_compress` + /// determines whether to restart once more or clear the dirty state. + verifying: bool = false, + } = .{}, + /// Serial of the last page inspected by the traversal. This is /// intentionally an implementation detail and is reset by PageList - /// operations which invalidate traversal progress. + /// operations which restart traversal progress. last_serial: ?u64 = null, /// First node serial which had not been allocated at the prior step. A /// node at or above this value before the saved marker requires a restart. next_serial: u64 = 0, - /// Whether this pass successfully compressed at least one page. - did_compress: bool = false, + /// Schedule compression without disturbing valid traversal progress. + fn markDirty(self: *IncrementalCompressionState) void { + self.flags.dirty = true; + } - /// Whether this pass is verifying that the prior pass reached a fixed - /// point. A verification pass which does work starts another verification - /// pass; a verification pass with no work lets compression become idle. - verifying: bool = false, - - /// Restart the next step at the first page in the list. + /// Discard traversal state and leave no compression work pending. fn reset(self: *IncrementalCompressionState) void { self.* = .{}; } @@ -3921,11 +3942,11 @@ const compressPage_tw = tripwire.module( /// /// The mode specifies if this is an incremental or full compression. /// Compression is SLOW (relatively), so incremental compressions during -/// idle periods are recommended. For incremental compressions, the result -/// specifies whether to continue incremental compressing or to stop until -/// an operation changes the state of the PageList (caller must determine -/// this). Full compression always returns `complete`, indicating that it has -/// no continuation to schedule rather than that every page was compressed. +/// idle periods are recommended. For incremental compression, the result +/// specifies whether to continue immediately. PageList tracks mutations which +/// require a later pass in its compression state. Full compression always +/// returns `complete`, indicating that it has no continuation to schedule +/// rather than that every page was compressed. pub fn compress( self: *PageList, mode: enum { incremental, full }, @@ -3946,17 +3967,24 @@ pub fn compress( /// Perform one candidate-bounded incremental cold-history compression step. fn compressIncremental(self: *PageList) IncrementalCompressionResult { + const state = &self.page_compression; + // If we can't reclaim virtual memory, compression is unsupported. - if (!terminal_mem.canReclaim(.strict)) return .unsupported; + if (!terminal_mem.canReclaim(.strict)) { + state.reset(); + return .unsupported; + } // If the viewport isn't in the active area, we don't do any compression. // The user is likely scrolling and we don't want to cause a bunch of // compress/decompress churn. Just wait, they have to go back down // eventually, you'd think! - if (self.viewport != .active) return .deferred; + if (self.viewport != .active) { + state.reset(); + return .deferred; + } - // Get our compression state - const state = &self.page_compression; + if (!state.flags.dirty) return .complete; // Find the node following the exact continuation marker within the cold // prefix. A missing marker means the list changed between steps, so begin @@ -3999,7 +4027,7 @@ fn compressIncremental(self: *PageList) IncrementalCompressionResult { // Compression is substantially more expensive even if it fails. // So we just try it. - if (self.compressPage(current)) state.did_compress = true; + if (self.compressPage(current)) state.flags.did_compress = true; // Breaking skips the while loop's continuation expression, so advance // explicitly before checking whether we reached the active boundary. @@ -4013,8 +4041,11 @@ fn compressIncremental(self: *PageList) IncrementalCompressionResult { // We reached our active node. So we're done, except that we always // do one pass after the first success so we can recompress nodes that // were possibly decompressed (e.g. by search, inspector, whatever). - if (!state.verifying or state.did_compress) { - state.* = .{ .verifying = true }; + if (!state.flags.verifying or state.flags.did_compress) { + state.* = .{ .flags = .{ + .dirty = true, + .verifying = true, + } }; return .pending; } @@ -6382,14 +6413,17 @@ test "PageList incremental compression defers and completes" { var s = try init(testing.allocator, 80, 24, null); defer s.deinit(); try s.growColdPagesForTest(1); + try testing.expect(s.page_compression.flags.dirty); s.scroll(.top); + try testing.expect(!s.page_compression.flags.dirty); const deferred = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.deferred, deferred); const cold = s.pages.first.?; try testing.expect(!cold.isCompressed()); s.scroll(.active); + try testing.expect(s.page_compression.flags.dirty); const pending = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, pending); try testing.expect(cold.isCompressed()); @@ -6399,11 +6433,14 @@ test "PageList incremental compression defers and completes" { const complete = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.complete, complete); try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + try testing.expect(!s.page_compression.flags.dirty); - // Completed state is fresh, so a later request naturally starts at the - // oldest page and recompresses restored content. + // Resetting and marking the state dirty begins at the oldest page and + // recompresses restored content. _ = cold.page(); try testing.expect(!cold.isCompressed()); + s.page_compression.reset(); + s.page_compression.markDirty(); const reconsidered = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, reconsidered); try testing.expect(cold.isCompressed()); @@ -6420,10 +6457,13 @@ test "PageList owns incremental compression state" { defer s.deinit(); const dirty: IncrementalCompressionState = .{ + .flags = .{ + .dirty = true, + .did_compress = true, + .verifying = true, + }, .last_serial = 42, .next_serial = 43, - .did_compress = true, - .verifying = true, }; s.page_compression = dirty; @@ -6436,11 +6476,17 @@ test "PageList owns incremental compression state" { s.scroll(.top); try testing.expectEqual(dirty, s.page_compression); s.scroll(.active); - try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + try testing.expectEqual( + IncrementalCompressionState{ .flags = .{ .dirty = true } }, + s.page_compression, + ); s.page_compression = dirty; try s.resize(.{ .cols = 80, .rows = 24 }); - try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + try testing.expectEqual( + IncrementalCompressionState{ .flags = .{ .dirty = true } }, + s.page_compression, + ); s.page_compression = dirty; s.reset(); @@ -6474,6 +6520,8 @@ test "PageList incremental compression bounds inspected pages" { // Precompress every candidate so the incremental pass exercises its // metadata-only skip budget without stopping at a resident attempt. _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markDirty(); try testing.expectEqual( incremental_compression_max_inspected + 1, s.memoryStats().compressed_pages, @@ -6492,7 +6540,7 @@ test "PageList incremental compression bounds inspected pages" { const second = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, second); - try testing.expect(s.page_compression.verifying); + try testing.expect(s.page_compression.flags.verifying); try testing.expect(s.page_compression.last_serial == null); // The verification pass is bounded independently, too. @@ -6732,6 +6780,8 @@ test "PageList incremental compression keeps progress after tail growth" { defer s.deinit(); try s.growColdPagesForTest(incremental_compression_max_inspected + 1); _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markDirty(); var expected_last = s.pages.first.?; for (1..incremental_compression_max_inspected) |_| @@ -6750,7 +6800,7 @@ test "PageList incremental compression keeps progress after tail growth" { while (s.page_serial == next_serial) _ = try s.grow(); const continued = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, continued); - try testing.expect(s.page_compression.verifying); + try testing.expect(s.page_compression.flags.verifying); } test "PageList memory stats do not restore compressed pages" { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 05af1075e..65a227e06 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -28,8 +28,10 @@ const mouse = @import("mouse.zig"); const Stream = @import("stream_terminal.zig").Stream; const size = @import("size.zig"); +const terminal_mem = @import("mem.zig"); const pagepkg = @import("page.zig"); const style = @import("style.zig"); +const PageList = @import("PageList.zig"); const Screen = @import("Screen.zig"); const ScreenSet = @import("ScreenSet.zig"); const Page = pagepkg.Page; @@ -2293,6 +2295,52 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { }); } +/// Return whether a compression pass is worth running again. +/// +/// When this returns true, callers should schedule a `compress` +/// call at some point. We recommend doing incremental compression because +/// compression is CPU heavy and stalls IO processing (due to the terminal +/// being blocked). See `compress` for more details. +/// +/// It is up to the terminal what it decides to compress, but currently +/// we compress cold (non-viewed, non-editable) scrollback history on +/// the primary screen. +/// +/// Note that compression requires specific system features, namely +/// the ability to retain virtual memory allocations while discarding their +/// physical memory backings. If the system libghostty is running on +/// doesn't support that this will always return false and compression +/// does nothing. +pub inline fn compressionRequired(self: *const Terminal) bool { + if (comptime !terminal_mem.canReclaim(.strict)) return false; + return self.screens.get(.primary).?.pages.page_compression.flags.dirty; +} + +/// Compress cold memory to save resident memory space. +/// +/// This can be done in two modes: incremental or full. A full compression +/// does a full pass compressing everything it can before returning. This +/// is not recommended for interactive terminals because compression is +/// relatively slow and with large scrollbacks this can cause stalls. +/// +/// Incremental compression bounds itself on how much data it can look +/// up to compress and how much compression work it does before returning. +/// It is stateful (we maintain the state) and the return value tells callers +/// whether they should continue calling it in the future. +/// +/// Callers should schedule compression when it doesn't impact user +/// experience, for example during idle times. +pub fn compress( + self: *Terminal, + mode: enum { incremental, full }, +) PageList.IncrementalCompressionResult { + const pages = &self.screens.get(.primary).?.pages; + return switch (mode) { + .incremental => pages.compress(.incremental), + .full => pages.compress(.full), + }; +} + /// To be called before shifting a row (as in insertLines and deleteLines) /// /// Takes care of boundary conditions such as potentially split wide chars From 461562ca4ffe344dd1a6f7f24ab1f32fbb0fd448 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 07:28:31 -0700 Subject: [PATCH 10/18] terminal: enable idle scrollback compression Scrollback compression was available only through explicit PageList calls, so normal renderer-backed surfaces never reclaimed cold history. Debounce renderer wakeups with a one-shot timer and run bounded compression steps only when the terminal lock is immediately available. Timer state is isolated in the renderer and becomes idle once PageList reports that the pass is complete. Keep inspector reads representation-preserving by decoding compressed pages into temporary copies. Update the scrollback configuration and benchmark documentation to describe the production behavior and its memory accounting. --- src/benchmark/PageCompression.zig | 3 +- src/benchmark/ScrollbackCompression.zig | 7 +- src/config/Config.zig | 14 ++- src/inspector/widgets/pagelist.zig | 71 ++++++++--- src/renderer/Thread.zig | 157 ++++++++++++++++++++++++ 5 files changed, 227 insertions(+), 25 deletions(-) diff --git a/src/benchmark/PageCompression.zig b/src/benchmark/PageCompression.zig index 04dc7ef32..21153659e 100644 --- a/src/benchmark/PageCompression.zig +++ b/src/benchmark/PageCompression.zig @@ -4,7 +4,8 @@ //! This benchmark is intentionally independent of terminal page ownership and //! lifecycle. It treats its input as opaque bytes and calls only the standalone //! LZ4 block codec. In particular, it does not compress pages owned by a live -//! terminal and is not evidence that compression is enabled in production. +//! terminal or measure the scheduling and state transitions used by automatic +//! scrollback compression. //! //! ## Input //! diff --git a/src/benchmark/ScrollbackCompression.zig b/src/benchmark/ScrollbackCompression.zig index 3684a91b3..85ca62fcd 100644 --- a/src/benchmark/ScrollbackCompression.zig +++ b/src/benchmark/ScrollbackCompression.zig @@ -38,9 +38,10 @@ //! //! A fully historical page is a node strictly before the node containing the //! top of the active area. The boundary node is deliberately excluded because -//! it can contain both history and active rows. Compression is currently an -//! on-demand PageList operation only; this benchmark does not enable it in -//! normal terminal execution. +//! it can contain both history and active rows. Normal terminal execution +//! compresses these pages incrementally after output becomes idle. The +//! benchmark invokes PageList operations directly so its timed regions exclude +//! the production scheduler's idle delay and renderer-thread coordination. //! //! ## Examples //! diff --git a/src/config/Config.zig b/src/config/Config.zig index fa491e49c..77043647c 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1373,10 +1373,16 @@ input: RepeatableReadableIO = .{}, /// When this limit is reached, the oldest lines are removed from the /// scrollback. /// -/// Scrollback currently exists completely in memory. This means that the -/// larger this value, the larger potential memory usage. Scrollback is -/// allocated lazily up to this limit, so if you set this to a very large -/// value, it will not immediately consume a lot of memory. +/// Scrollback is stored in memory and allocated lazily up to this limit, so +/// setting a very large limit does not immediately consume that amount of +/// memory. On supported systems, Ghostty attempts to compress fully historical +/// pages while the terminal is idle. This can reduce physical memory usage, +/// depending on the contents of the scrollback. +/// +/// This limit always measures the uncompressed logical size of the terminal +/// pages. Compression does not allow Ghostty to retain more history than the +/// configured limit. Accessing compressed history restores it transparently +/// and may increase the terminal's physical memory usage again. /// /// This size is per terminal surface, not for the entire application. /// diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig index 4041ba332..fac6afbed 100644 --- a/src/inspector/widgets/pagelist.zig +++ b/src/inspector/widgets/pagelist.zig @@ -1,12 +1,24 @@ const std = @import("std"); const cimgui = @import("dcimgui"); const terminal = @import("../../terminal/main.zig"); +const pagepkg = @import("../../terminal/page.zig"); const stylepkg = @import("../../terminal/style.zig"); const widgets = @import("../widgets.zig"); const units = @import("../units.zig"); const PageList = terminal.PageList; +/// Cell pointers resolved against a Node.PreservedPage rather than the +/// PageList node. +/// Omitting the node makes it impossible for inspector helpers to +/// accidentally restore the original compressed page. +const InspectedCell = struct { + row: *pagepkg.Row, + cell: *pagepkg.Cell, + row_idx: terminal.size.CellCountInt, + col_idx: terminal.size.CellCountInt, +}; + /// PageList inspector widget. pub const Inspector = struct { pub const empty: Inspector = .{}; @@ -82,11 +94,18 @@ pub const Inspector = struct { })) continue; defer cimgui.c.ImGui_TreePop(); - // Opening a compressed entry is an explicit request for its - // contents, so restoration is appropriate here. Collapsed - // entries use only metadata and remain compressed. - const page = resident orelse page_node.page(); - widgets.page.inspector(page); + // Decode compressed contents into a temporary page. The + // original node stays compressed while its entry is open. + var preserved = page_node.pagePreservingState( + std.heap.page_allocator, + ) catch { + cimgui.c.ImGui_TextDisabled( + "(unable to copy compressed page)", + ); + continue; + }; + defer preserved.deinit(); + widgets.page.inspector(preserved.page()); } } } @@ -627,16 +646,35 @@ pub const CellChooser = struct { .history => terminal.Point{ .history = self.lookup_coord }, }; - const cell = pages.getCell(pt) orelse { + const pin = pages.pin(pt) orelse { cimgui.c.ImGui_TextDisabled("(cell out of range)"); return; }; - self.cell_info.draw(cell, pt); + // Cell pointers must come from the same page whose auxiliary tables + // we inspect. For compressed nodes this is an independent preserved + // page, leaving the PageList node compressed across inspector frames. + var preserved = pin.node.pagePreservingState( + std.heap.page_allocator, + ) catch { + cimgui.c.ImGui_TextDisabled("(unable to copy compressed page)"); + return; + }; + defer preserved.deinit(); + + const page = preserved.page(); + const rac = page.getRowAndCell(pin.x, pin.y); + const cell: InspectedCell = .{ + .row = rac.row, + .cell = rac.cell, + .row_idx = pin.y, + .col_idx = pin.x, + }; + + self.cell_info.draw(cell, pt, page); if (cell.cell.style_id != stylepkg.default_id) { cimgui.c.ImGui_SeparatorText("Style"); - const page = cell.node.page(); const style = page.styles.get( page.memory, cell.cell.style_id, @@ -646,12 +684,12 @@ pub const CellChooser = struct { if (cell.cell.hyperlink) { cimgui.c.ImGui_SeparatorText("Hyperlink"); - hyperlinkTable(cell); + hyperlinkTable(cell, page); } if (cell.cell.hasGrapheme()) { cimgui.c.ImGui_SeparatorText("Grapheme"); - graphemeTable(cell); + graphemeTable(cell, page); } } }; @@ -665,7 +703,7 @@ fn maxCoord( return br_point.coord(); } -fn hyperlinkTable(cell: PageList.Cell) void { +fn hyperlinkTable(cell: InspectedCell, page: *const terminal.Page) void { if (!cimgui.c.ImGui_BeginTable( "cell_hyperlink", 2, @@ -673,7 +711,6 @@ fn hyperlinkTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = cell.node.page(); const link_id = page.lookupHyperlink(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -720,7 +757,7 @@ fn hyperlinkTable(cell: PageList.Cell) void { cimgui.c.ImGui_Text("%d", refs); } -fn graphemeTable(cell: PageList.Cell) void { +fn graphemeTable(cell: InspectedCell, page: *const terminal.Page) void { if (!cimgui.c.ImGui_BeginTable( "cell_grapheme", 2, @@ -728,7 +765,6 @@ fn graphemeTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = cell.node.page(); const cps = page.lookupGrapheme(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -768,8 +804,9 @@ pub const CellInfo = struct { pub fn draw( _: *const CellInfo, - cell: PageList.Cell, + cell: InspectedCell, point: terminal.Point, + page: *const terminal.Page, ) void { if (!cimgui.c.ImGui_BeginTable( "cell_info", @@ -835,7 +872,7 @@ pub const CellInfo = struct { _ = cimgui.c.ImGui_TableSetColumnIndex(2); if (cimgui.c.ImGui_BeginListBox("##cell_grapheme", .{ .x = 0, .y = 0 })) { defer cimgui.c.ImGui_EndListBox(); - if (cell.node.page().lookupGrapheme(cell.cell)) |cps| { + if (page.lookupGrapheme(cell.cell)) |cps| { var buf: [96]u8 = undefined; for (cps) |cp| { const label = std.fmt.bufPrintZ(&buf, "U+{X}", .{cp}) catch "U+?"; @@ -933,7 +970,7 @@ pub const CellInfo = struct { widgets.helpMarker("OSC8 hyperlink ID associated with this cell."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - const link_id = cell.node.page().lookupHyperlink(cell.cell) orelse 0; + const link_id = page.lookupHyperlink(cell.cell) orelse 0; cimgui.c.ImGui_Text("id=%d", link_id); } } diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 488642199..bb07028ad 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -34,6 +34,91 @@ const must_draw_from_app_thread = /// the future if we want it configurable. pub const Mailbox = BlockingQueue(rendererpkg.Message, 64); +/// Schedules incremental terminal compression after renderer activity stops. +/// +/// This owns all renderer-specific compression state. The terminal decides +/// whether compression is required and performs the actual work; the renderer +/// only provides idle scheduling and avoids waiting for the terminal lock. +const Compression = struct { + const idle_interval = 250; + const step_interval = 1; + + timer: xev.Timer, + completion: xev.Completion = .{}, + reset_completion: xev.Completion = .{}, + + fn init() !Compression { + return .{ .timer = try xev.Timer.init() }; + } + + fn deinit(self: *Compression) void { + self.timer.deinit(); + } + + /// Start or postpone compression after a renderer wake. + fn wake(self: *Compression, thread: *Thread) void { + // An active timer already proves work was pending. Otherwise consult + // the terminal, without waiting if rendering or parsing holds its lock. + if (self.completion.state() != .active) { + if (thread.state.mutex.tryLock()) { + defer thread.state.mutex.unlock(); + if (!thread.state.terminal.compressionRequired()) return; + } + } + + self.schedule(thread, idle_interval); + } + + /// Start the one-shot timer, or move its deadline if it is already active. + fn schedule(self: *Compression, thread: *Thread, delay_ms: u64) void { + self.timer.reset( + &thread.loop, + &self.completion, + &self.reset_completion, + delay_ms, + Thread, + thread, + timerCallback, + ); + } + + fn timerCallback( + thread_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + result: xev.Timer.RunError!void, + ) xev.CallbackAction { + _ = result catch |err| switch (err) { + error.Canceled => return .disarm, + else => { + log.warn("error in compression timer err={}", .{err}); + return .disarm; + }, + }; + + const thread = thread_ orelse return .disarm; + const self = &thread.compression; + + if (step(thread.state)) |delay| self.schedule(thread, delay); + return .disarm; + } + + /// Try one bounded step without waiting for the terminal lock. The return + /// value is the delay before another attempt, or null when work is done. + fn step(state: *rendererpkg.State) ?u64 { + if (!state.mutex.tryLock()) return idle_interval; + defer state.mutex.unlock(); + + return switch (state.terminal.compress(.incremental)) { + .pending => step_interval, + .unsupported, + .deferred, + .complete, + => null, + }; + } +}; + /// Allocator used for some state alloc: std.mem.Allocator, @@ -72,6 +157,9 @@ cursor_h: xev.Timer, cursor_c: xev.Completion = .{}, cursor_c_cancel: xev.Completion = .{}, +/// Incremental scrollback compression scheduling. +compression: Compression, + /// The surface we're rendering to. surface: *apprt.Surface, @@ -158,6 +246,9 @@ pub fn init( var cursor_timer = try xev.Timer.init(); errdefer cursor_timer.deinit(); + var compression = try Compression.init(); + errdefer compression.deinit(); + // The mailbox for messaging this thread var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); @@ -172,6 +263,7 @@ pub fn init( .draw_h = draw_h, .draw_now = draw_now, .cursor_h = cursor_timer, + .compression = compression, .surface = surface, .renderer = renderer_impl, .state = state, @@ -189,6 +281,7 @@ pub fn deinit(self: *Thread) void { self.draw_h.deinit(); self.draw_now.deinit(); self.cursor_h.deinit(); + self.compression.deinit(); self.loop.deinit(); // Nothing can possibly access the mailbox anymore, destroy it. @@ -537,6 +630,10 @@ fn wakeupCallback( // Render immediately _ = renderCallback(t, undefined, undefined, {}); + // PageList mutations maintain their own compression dirty state. Checking + // it here covers output, resize, and viewport scrolling uniformly. + t.compression.wake(t); + // The below is not used anymore but if we ever want to introduce // a configuration to introduce a delay to coalesce renders, we can // use this. @@ -723,3 +820,63 @@ fn cursorBlinkInterval() u64 { return CURSOR_BLINK_INTERVAL; } + +test "compression timer advances primary history" { + const testing = std.testing; + const terminalpkg = @import("../terminal/main.zig"); + + var terminal = try terminalpkg.Terminal.init(testing.allocator, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10 * 1024 * 1024, + }); + defer terminal.deinit(testing.allocator); + + const primary = terminal.screens.get(.primary).?; + primary.cursorAbsolute(0, primary.pages.rows - 1); + while (primary.pages.pages.first.? == + primary.pages.getTopLeft(.active).node) + { + try primary.cursorDownScroll(); + } + + // Compression always targets primary history, even while an alternate + // screen is active. + _ = try terminal.screens.getInit(testing.allocator, .alternate, .{ + .cols = 80, + .rows = 24, + .max_scrollback = 0, + }); + terminal.screens.switchTo(.alternate); + + var mutex: std.Thread.Mutex = .{}; + var state: rendererpkg.State = .{ + .mutex = &mutex, + .terminal = &terminal, + }; + try testing.expect(terminal.compressionRequired()); + + // Contention postpones background work instead of blocking output. + mutex.lock(); + try testing.expectEqual( + @as(?u64, Compression.idle_interval), + Compression.step(&state), + ); + mutex.unlock(); + + try testing.expectEqual( + @as(?u64, Compression.step_interval), + Compression.step(&state), + ); + try testing.expectEqual( + @as(usize, 1), + primary.pages.memoryStats().compressed_pages, + ); + + // The no-work verification pass completes and leaves the timer disarmed. + try testing.expectEqual( + @as(?u64, null), + Compression.step(&state), + ); + try testing.expect(!terminal.compressionRequired()); +} From b9bb50c832378bf1a7f45538b4d117f3e4137ebf Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 08:26:20 -0700 Subject: [PATCH 11/18] terminal: optimize page compression codec The raw LZ4 codec previously kept one match candidate and extended and copied matches byte by byte. This limited compression quality and made page restoration substantially more expensive than the reference decoder. Pack two candidates into the existing 16 KiB table, accelerate long literal searches, and compare matching runs a word at a time. Decode short literals and common repeated patterns with fixed-width copies while retaining exact bounds checks for malformed blocks. This keeps the public API, workspace size, block format, and allocation-free dependency model unchanged. --- src/terminal/compress/lz4.zig | 446 ++++++++++++++++++++++++++++++---- 1 file changed, 394 insertions(+), 52 deletions(-) diff --git a/src/terminal/compress/lz4.zig b/src/terminal/compress/lz4.zig index 219e76f02..81cd3c57a 100644 --- a/src/terminal/compress/lz4.zig +++ b/src/terminal/compress/lz4.zig @@ -25,12 +25,14 @@ //! end of the input. The compressor observes these restrictions so its output //! can be consumed by optimized LZ4 decoders which copy in larger units. //! -//! Compression uses the standard fast LZ4 strategy: hash each four-byte input -//! sequence, remember only its most recent position, and test that one position -//! as a match candidate. This favors compression speed and a small, fixed -//! workspace over finding the best possible match. The implementation is -//! scalar Zig and allocates nothing; all input, output, and scratch memory is -//! supplied by the caller. +//! Compression uses the fast LZ4 strategy: hash each four-byte input sequence +//! and test recent positions as match candidates. Two 16-bit positions fit in +//! each hash-table entry because the format cannot refer further than 64 KiB +//! backwards. Retaining the displaced position recovers useful matches after +//! hash collisions without increasing the fixed 16 KiB workspace. Long runs +//! without matches gradually skip input positions, while matching runs are +//! extended a machine word at a time. The implementation allocates nothing; +//! all input, output, and scratch memory is supplied by the caller. //! //! Format reference: //! https://github.com/lz4/lz4/blob/dev/doc/lz4_Block_format.md @@ -60,8 +62,10 @@ const hash_log = 12; /// `hash_log` bits provide the table index. const hash_multiplier: u32 = 2_654_435_761; -/// Scratch memory used while compressing one block. Each entry stores an input -/// position plus one; zero therefore means that the hash has not been seen. +/// Scratch memory used while compressing one block. Each entry packs the low +/// 16 bits of the two most recent input positions for its hash. All-ones marks +/// an empty half; LZ4's 16-bit offset is enough to reconstruct the only useful +/// preceding address from the current position. /// The table is reset by every call to `compress` and can be reused afterwards. pub const HashTable = [1 << hash_log]u32; @@ -112,9 +116,8 @@ pub fn compress( ) CompressError!usize { if (input.len > max_input_size) return error.InputTooLarge; - // Zero is reserved as "no previous position". Actual positions are stored - // plus one so that a match at input offset zero remains representable. - @memset(table, 0); + // All-ones in either packed half means "no previous position". + @memset(table, std.math.maxInt(u32)); // `ip` is the current input position, `anchor` is the first literal not yet // emitted, and `op` is the next output position. A successful match emits @@ -123,6 +126,7 @@ pub fn compress( var op: usize = 0; var anchor: usize = 0; var ip: usize = 0; + var search_attempts: usize = 0; // LZ4's format leaves the final five input bytes as literals and starts // the final match at least twelve bytes before the end. This is not @@ -139,47 +143,50 @@ pub fn compress( // accepting the saved position as a match. const sequence = readU32(input, ip); const hash = hashSequence(sequence); - const previous_plus_one = table[hash]; - table[hash] = @intCast(ip + 1); + const candidates = table[hash]; + rememberPosition(table, hash, ip); - if (previous_plus_one == 0) { - ip += 1; + const match_pos_ = candidatePosition(ip, @truncate(candidates)) orelse + candidatePosition(ip, @truncate(candidates >> 16)); + if (match_pos_ == null) { + advanceSearch(&ip, anchor, &search_attempts); continue; } - var match_pos: usize = previous_plus_one - 1; + var match_pos = match_pos_.?; - // Offsets are encoded as u16 and zero is invalid. Since match_pos is - // always earlier than ip here, checking the distance also rules out a - // value that cannot be represented in the block. - if (ip - match_pos > std.math.maxInt(u16) or - readU32(input, match_pos) != sequence) - { - ip += 1; - continue; + if (readU32(input, match_pos) != sequence) { + const older = candidatePosition( + ip, + @truncate(candidates >> 16), + ); + if (older == null or + readU32(input, older.?) != sequence or + input[older.? + min_match] != input[ip + min_match]) + { + advanceSearch(&ip, anchor, &search_attempts); + continue; + } + match_pos = older.?; } - // Pull the match backwards into the current literal run. This is a - // cheap improvement that is particularly helpful around aligned cell - // records without requiring a hash chain. - while (ip > anchor and match_pos > 0 and - input[ip - 1] == input[match_pos - 1]) - { - ip -= 1; - match_pos -= 1; - } + // Pull the match backwards into the current literal run. This is + // particularly helpful around aligned cell records. As with forward + // extension, compare words before locating the first differing byte. + const match_begin = matchBegin(input, ip, match_pos, anchor); + ip = match_begin.position; + match_pos = match_begin.candidate; - // We already compared the first four bytes. Continue byte-by-byte up - // to the point where the required last five literals begin. - var match_end = ip + min_match; - var candidate_end = match_pos + min_match; + // We already compared the first four bytes. Continue up to the point + // where the required last five literals begin. `matchEnd` compares a + // machine word at a time before locating the first differing byte. const match_end_limit = input.len - last_literals; - while (match_end < match_end_limit and - input[match_end] == input[candidate_end]) - { - match_end += 1; - candidate_end += 1; - } + const match_end = matchEnd( + input, + ip + min_match, + match_pos + min_match, + match_end_limit, + ); try emitSequence( output, @@ -195,11 +202,16 @@ pub fn compress( // iteration will then seed `match_end` normally. if (match_end >= 2 and match_end - 2 + min_match <= input.len) { const seed = match_end - 2; - table[hashSequence(readU32(input, seed))] = @intCast(seed + 1); + rememberPosition( + table, + hashSequence(readU32(input, seed)), + seed, + ); } ip = match_end; anchor = ip; + search_attempts = 0; } // Whatever remains after the last match is the terminal literal-only @@ -239,7 +251,7 @@ pub fn decompress(input: []const u8, output: []u8) DecompressError!usize { if (literal_len > input.len - ip) return error.TruncatedInput; if (literal_len > output.len - op) return error.OutputTooSmall; - @memcpy(output[op..][0..literal_len], input[ip..][0..literal_len]); + copyLiterals(input, ip, output, op, literal_len); ip += literal_len; op += literal_len; @@ -265,12 +277,10 @@ pub fn decompress(input: []const u8, output: []u8) DecompressError!usize { ) catch return error.OutputTooSmall; if (match_len > output.len - op) return error.OutputTooSmall; - // Match copies are allowed to overlap. For an offset smaller than the - // match length, bytes written early in this loop become the source for - // later bytes. This is how a short pattern such as one space can expand - // into an arbitrarily long run. - const match_pos = op - offset; - for (0..match_len) |i| output[op + i] = output[match_pos + i]; + // Match copies may overlap, so this cannot always be one memcpy. + // `copyMatch` uses word copies where the offset permits them and + // expands the common one-, two-, and four-byte repeating patterns. + copyMatch(output, op, offset, match_len); op += match_len; } } @@ -383,10 +393,285 @@ fn decodeLength( } } +/// Read an unaligned little-endian integer at `position`. +inline fn readIntAt( + comptime Int: type, + input: []const u8, + position: usize, +) Int { + return std.mem.readInt( + Int, + input[position..][0..@sizeOf(Int)], + .little, + ); +} + +/// Write an unaligned little-endian integer at `position`. +inline fn writeIntAt( + comptime Int: type, + output: []u8, + position: usize, + value: Int, +) void { + std.mem.writeInt( + Int, + output[position..][0..@sizeOf(Int)], + value, + .little, + ); +} + +/// Copy one fixed-size integer between non-overlapping byte ranges. +inline fn copyIntAt( + comptime Int: type, + output: []u8, + output_position: usize, + input: []const u8, + input_position: usize, +) void { + writeIntAt( + Int, + output, + output_position, + readIntAt(Int, input, input_position), + ); +} + /// Read the four-byte sequence used for match finding. Callers only use this /// where at least four input bytes remain. inline fn readU32(input: []const u8, pos: usize) u32 { - return std.mem.readInt(u32, input[pos..][0..4], .little); + return readIntAt(u32, input, pos); +} + +/// Add a position to one hash slot and shift the previous newest position into +/// the fallback half. A position whose low bits are 0xFFFF conflicts with the +/// sentinel and is simply not stored. +inline fn rememberPosition(table: *HashTable, hash: usize, position: usize) void { + const low: u16 = @truncate(position); + if (low == std.math.maxInt(u16)) return; + table[hash] = (@as(u32, @truncate(table[hash])) << 16) | low; +} + +/// Recover the nearest preceding position from a stored low half. Modular +/// subtraction directly produces the LZ4 offset within the current window. +inline fn candidatePosition(ip: usize, stored: u16) ?usize { + if (stored == std.math.maxInt(u16)) return null; + const distance: u16 = @as(u16, @truncate(ip)) -% stored; + if (distance == 0) return null; + return ip - distance; +} + +/// Advance through a literal run. The first KiB inspects every byte without +/// maintaining a counter, which keeps ordinary terminal-page searches cheap. +/// Longer runs enable gradually increasing steps for incompressible data. +inline fn advanceSearch( + ip: *usize, + anchor: usize, + attempts: *usize, +) void { + if (attempts.* == 0) { + ip.* += 1; + if (ip.* - anchor == 1024) attempts.* = 1; + return; + } + + attempts.* += 1; + ip.* += 1 + attempts.* / 64; +} + +/// Extend a match backwards without crossing the current literal anchor or the +/// beginning of the candidate. The returned positions preserve their offset. +fn matchBegin( + input: []const u8, + position_: usize, + candidate_: usize, + anchor: usize, +) struct { position: usize, candidate: usize } { + var position = position_; + var candidate = candidate_; + + while (@min(position - anchor, candidate) >= @sizeOf(u64)) { + const position_word = position - @sizeOf(u64); + const candidate_word = candidate - @sizeOf(u64); + const difference = readIntAt(u64, input, position_word) ^ + readIntAt(u64, input, candidate_word); + if (difference != 0) { + const equal_bytes: usize = @intCast(@clz(difference) / 8); + position -= equal_bytes; + candidate -= equal_bytes; + return .{ .position = position, .candidate = candidate }; + } + + position = position_word; + candidate = candidate_word; + } + + while (position > anchor and candidate > 0 and + input[position - 1] == input[candidate - 1]) + { + position -= 1; + candidate -= 1; + } + return .{ .position = position, .candidate = candidate }; +} + +/// Return the first input position where two matching runs differ, or `limit` +/// when they remain equal. Both positions are known to have matched through +/// `min_match` before this is called. +fn matchEnd( + input: []const u8, + position_: usize, + candidate_: usize, + limit: usize, +) usize { + var position = position_; + var candidate = candidate_; + + // Reading as little endian makes the least-significant differing bit map + // to the earliest byte in memory on every target. Unaligned reads are + // lowered appropriately by Zig and require no target-specific intrinsics. + while (limit - position >= @sizeOf(u64)) { + const difference = readIntAt(u64, input, position) ^ + readIntAt(u64, input, candidate); + if (difference != 0) { + const equal_bytes: usize = @intCast(@ctz(difference) / 8); + return position + equal_bytes; + } + + position += @sizeOf(u64); + candidate += @sizeOf(u64); + } + + while (position < limit and input[position] == input[candidate]) { + position += 1; + candidate += 1; + } + return position; +} + +/// Copy one literal run. Most non-final sequences contain only a handful of +/// literals. When both buffers have eight accessible bytes, one fixed-width +/// copy is cheaper than a variable-size memcpy; bytes beyond the logical run +/// are overwritten immediately by the match. Final literals use the exact +/// copy because the encoded block ends directly after them. +fn copyLiterals( + input: []const u8, + ip: usize, + output: []u8, + op: usize, + literal_len: usize, +) void { + if (literal_len == 0) return; + + if (literal_len <= @sizeOf(u64) and + input.len - ip >= @sizeOf(u64) and + output.len - op >= @sizeOf(u64)) + { + copyIntAt(u64, output, op, input, ip); + return; + } + + @memcpy(output[op..][0..literal_len], input[ip..][0..literal_len]); +} + +/// Copy one decoded match from `offset` bytes behind `op`. +/// +/// Offsets of at least eight can be copied one word at a time even when the +/// complete match overlaps: every individual load still precedes its store by +/// a full word. One-, two-, and four-byte periods are expanded into a repeated +/// word. Other small offsets retain the required byte-wise propagation. +fn copyMatch(output: []u8, op: usize, offset: usize, match_len: usize) void { + const match_pos = op - offset; + const destination = output[op..][0..match_len]; + const can_overcopy = output.len - op - match_len >= 3; + + if (offset >= @sizeOf(u64)) { + var copied: usize = 0; + while (match_len - copied >= @sizeOf(u64)) { + copyIntAt( + u64, + output, + op + copied, + output, + match_pos + copied, + ); + copied += @sizeOf(u64); + } + + if (copied < match_len and can_overcopy) { + const remaining = match_len - copied; + if (remaining <= @sizeOf(u32)) { + copyIntAt( + u32, + output, + op + copied, + output, + match_pos + copied, + ); + } else { + copyIntAt( + u64, + output, + op + copied, + output, + match_pos + copied, + ); + } + } else { + while (copied < match_len) : (copied += 1) + destination[copied] = output[match_pos + copied]; + } + return; + } + + if (offset == 1 or offset == 2 or offset == 4) { + const pattern: u64 = switch (offset) { + 1 => @as(u64, output[match_pos]) * 0x0101_0101_0101_0101, + 2 => @as(u64, readIntAt(u16, output, match_pos)) * + 0x0001_0001_0001_0001, + 4 => @as(u64, readIntAt(u32, output, match_pos)) * + 0x0000_0001_0000_0001, + else => unreachable, + }; + + var copied: usize = 0; + while (match_len - copied >= @sizeOf(u64)) { + writeIntAt(u64, output, op + copied, pattern); + copied += @sizeOf(u64); + } + + if (copied < match_len and can_overcopy) { + const remaining = match_len - copied; + if (remaining <= @sizeOf(u32)) { + writeIntAt(u32, output, op + copied, @truncate(pattern)); + } else { + writeIntAt(u64, output, op + copied, pattern); + } + } else { + while (copied < match_len) : (copied += 1) { + destination[copied] = @truncate(pattern >> @intCast( + (copied % @sizeOf(u64)) * 8, + )); + } + } + return; + } + + if (offset >= @sizeOf(u32) and can_overcopy) { + var copied: usize = 0; + while (copied < match_len) : (copied += @sizeOf(u32)) { + copyIntAt( + u32, + output, + op + copied, + output, + match_pos + copied, + ); + } + return; + } + + for (0..match_len) |i| destination[i] = output[match_pos + i]; } /// Map a four-byte input sequence to its scratch-table slot. @@ -464,6 +749,63 @@ test "extended overlapping match compatibility vector" { try testing.expect(std.mem.allEqual(u8, &output, 'a')); } +test "short offset compatibility vectors" { + const testing = std.testing; + + // These blocks end immediately after their match. Besides covering the + // repeating-pattern paths, they verify that the decoder uses exact copies + // when the block does not provide the standard trailing-literal margin. + var offset_two: [6]u8 = undefined; + _ = try decompress(&.{ 0x20, 'a', 'b', 0x02, 0x00 }, &offset_two); + try testing.expectEqualStrings("ababab", &offset_two); + + var offset_three: [9]u8 = undefined; + _ = try decompress( + &.{ 0x32, 'a', 'b', 'c', 0x03, 0x00 }, + &offset_three, + ); + try testing.expectEqualStrings("abcabcabc", &offset_three); + + var offset_four: [8]u8 = undefined; + _ = try decompress( + &.{ 0x40, 'a', 'b', 'c', 'd', 0x04, 0x00 }, + &offset_four, + ); + try testing.expectEqualStrings("abcdabcd", &offset_four); +} + +test "bounded wild copies are overwritten by final literals" { + const testing = std.testing; + + // The first sequence's nine-byte match leaves the five final literals + // required by the LZ4 block format. Its logical one-byte tail is copied as + // a word and the following literal sequence overwrites the extra bytes. + var repeated_byte: [15]u8 = undefined; + _ = try decompress( + &.{ + 0x15, 'a', 0x01, 0x00, + 0x50, '1', '2', '3', + '4', '5', + }, + &repeated_byte, + ); + try testing.expectEqualStrings("aaaaaaaaaa12345", &repeated_byte); + + // Exercise the same bounded tail copy with a non-overlapping offset. + var word_offset: [22]u8 = undefined; + _ = try decompress( + &.{ + 0x85, 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 0x08, 0x00, + 0x50, '1', '2', '3', '4', '5', + }, + &word_offset, + ); + try testing.expectEqualStrings( + "abcdefghabcdefgha12345", + &word_offset, + ); +} + test "maximum match offset compatibility vector" { const testing = std.testing; const literal_len = std.math.maxInt(u16); From 181254d36a14e563cf81690b31b275ada841171c Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 09:16:56 -0700 Subject: [PATCH 12/18] terminal: debounce compression by page activity Compression scheduling previously postponed its idle timer after every renderer wake. The inspector redraw loop wakes the renderer continuously, so opening it could prevent pending scrollback compression from ever starting. Track compression-relevant PageList activity with a wrapping 48-bit token and restart the timer only when the composite Terminal token changes. This removes the separate dirty bit while reserving 16 bits for future Terminal-owned compression triggers. Expose target availability through the terminal package and leave renderer compression state undefined on unsupported targets so its timer is never initialized. --- src/renderer/Thread.zig | 247 ++++++++++++++++---------------------- src/terminal/PageList.zig | 115 +++++++++++------- src/terminal/Terminal.zig | 22 ++-- src/terminal/main.zig | 3 + 4 files changed, 189 insertions(+), 198 deletions(-) diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index bb07028ad..b0566f9d3 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -10,6 +10,7 @@ const internal_os = @import("../os/main.zig"); const rendererpkg = @import("../renderer.zig"); const apprt = @import("../apprt.zig"); const configpkg = @import("../config.zig"); +const terminalpkg = @import("../terminal/main.zig"); const BlockingQueue = @import("../datastruct/main.zig").BlockingQueue; const App = @import("../App.zig"); @@ -34,91 +35,6 @@ const must_draw_from_app_thread = /// the future if we want it configurable. pub const Mailbox = BlockingQueue(rendererpkg.Message, 64); -/// Schedules incremental terminal compression after renderer activity stops. -/// -/// This owns all renderer-specific compression state. The terminal decides -/// whether compression is required and performs the actual work; the renderer -/// only provides idle scheduling and avoids waiting for the terminal lock. -const Compression = struct { - const idle_interval = 250; - const step_interval = 1; - - timer: xev.Timer, - completion: xev.Completion = .{}, - reset_completion: xev.Completion = .{}, - - fn init() !Compression { - return .{ .timer = try xev.Timer.init() }; - } - - fn deinit(self: *Compression) void { - self.timer.deinit(); - } - - /// Start or postpone compression after a renderer wake. - fn wake(self: *Compression, thread: *Thread) void { - // An active timer already proves work was pending. Otherwise consult - // the terminal, without waiting if rendering or parsing holds its lock. - if (self.completion.state() != .active) { - if (thread.state.mutex.tryLock()) { - defer thread.state.mutex.unlock(); - if (!thread.state.terminal.compressionRequired()) return; - } - } - - self.schedule(thread, idle_interval); - } - - /// Start the one-shot timer, or move its deadline if it is already active. - fn schedule(self: *Compression, thread: *Thread, delay_ms: u64) void { - self.timer.reset( - &thread.loop, - &self.completion, - &self.reset_completion, - delay_ms, - Thread, - thread, - timerCallback, - ); - } - - fn timerCallback( - thread_: ?*Thread, - _: *xev.Loop, - _: *xev.Completion, - result: xev.Timer.RunError!void, - ) xev.CallbackAction { - _ = result catch |err| switch (err) { - error.Canceled => return .disarm, - else => { - log.warn("error in compression timer err={}", .{err}); - return .disarm; - }, - }; - - const thread = thread_ orelse return .disarm; - const self = &thread.compression; - - if (step(thread.state)) |delay| self.schedule(thread, delay); - return .disarm; - } - - /// Try one bounded step without waiting for the terminal lock. The return - /// value is the delay before another attempt, or null when work is done. - fn step(state: *rendererpkg.State) ?u64 { - if (!state.mutex.tryLock()) return idle_interval; - defer state.mutex.unlock(); - - return switch (state.terminal.compress(.incremental)) { - .pending => step_interval, - .unsupported, - .deferred, - .complete, - => null, - }; - } -}; - /// Allocator used for some state alloc: std.mem.Allocator, @@ -158,7 +74,7 @@ cursor_c: xev.Completion = .{}, cursor_c_cancel: xev.Completion = .{}, /// Incremental scrollback compression scheduling. -compression: Compression, +compression: Compression = undefined, /// The surface we're rendering to. surface: *apprt.Surface, @@ -246,14 +162,11 @@ pub fn init( var cursor_timer = try xev.Timer.init(); errdefer cursor_timer.deinit(); - var compression = try Compression.init(); - errdefer compression.deinit(); - // The mailbox for messaging this thread var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); - return .{ + var result: Thread = .{ .alloc = alloc, .config = .init(config), .loop = loop, @@ -263,13 +176,20 @@ pub fn init( .draw_h = draw_h, .draw_now = draw_now, .cursor_h = cursor_timer, - .compression = compression, .surface = surface, .renderer = renderer_impl, .state = state, .mailbox = mailbox, .app_mailbox = app_mailbox, }; + + // Only enable compression if we have it enabled... save some + // minor resources. + if (comptime terminalpkg.compression_enabled) { + result.compression = try .init(); + } + + return result; } /// Clean up the thread. This is only safe to call once the thread @@ -281,7 +201,8 @@ pub fn deinit(self: *Thread) void { self.draw_h.deinit(); self.draw_now.deinit(); self.cursor_h.deinit(); - self.compression.deinit(); + if (comptime terminalpkg.compression_enabled) + self.compression.deinit(); self.loop.deinit(); // Nothing can possibly access the mailbox anymore, destroy it. @@ -821,62 +742,102 @@ fn cursorBlinkInterval() u64 { return CURSOR_BLINK_INTERVAL; } -test "compression timer advances primary history" { - const testing = std.testing; - const terminalpkg = @import("../terminal/main.zig"); +/// Schedules incremental terminal compression after renderer activity stops. +/// +/// This owns all renderer-specific compression state. The terminal decides +/// when compression-relevant activity changes and performs the actual work; +/// the renderer only provides idle scheduling and avoids waiting for the +/// terminal lock. +const Compression = struct { + const idle_interval = 250; + const step_interval = 1; - var terminal = try terminalpkg.Terminal.init(testing.allocator, .{ - .cols = 80, - .rows = 24, - .max_scrollback = 10 * 1024 * 1024, - }); - defer terminal.deinit(testing.allocator); + timer: xev.Timer, + completion: xev.Completion = .{}, + reset_completion: xev.Completion = .{}, + activity: ?u64 = null, - const primary = terminal.screens.get(.primary).?; - primary.cursorAbsolute(0, primary.pages.rows - 1); - while (primary.pages.pages.first.? == - primary.pages.getTopLeft(.active).node) - { - try primary.cursorDownScroll(); + fn init() !Compression { + return .{ .timer = try xev.Timer.init() }; } - // Compression always targets primary history, even while an alternate - // screen is active. - _ = try terminal.screens.getInit(testing.allocator, .alternate, .{ - .cols = 80, - .rows = 24, - .max_scrollback = 0, - }); - terminal.screens.switchTo(.alternate); + fn deinit(self: *Compression) void { + self.timer.deinit(); + } - var mutex: std.Thread.Mutex = .{}; - var state: rendererpkg.State = .{ - .mutex = &mutex, - .terminal = &terminal, - }; - try testing.expect(terminal.compressionRequired()); + /// Start or postpone compression after a renderer wake. + fn wake(self: *Compression, thread: *Thread) void { + // If we have no compression then don't do anything. + if (comptime !terminalpkg.compression_enabled) return; - // Contention postpones background work instead of blocking output. - mutex.lock(); - try testing.expectEqual( - @as(?u64, Compression.idle_interval), - Compression.step(&state), - ); - mutex.unlock(); + // PageList activity, rather than a generic renderer wake, restarts the + // idle interval. In particular, the inspector wakes the renderer every + // frame without changing terminal contents and must not starve this + // timer indefinitely. + if (thread.state.mutex.tryLock()) { + defer thread.state.mutex.unlock(); + const activity = thread.state.terminal.compressionActivity(); + if (self.activity == activity) return; + self.activity = activity; + } - try testing.expectEqual( - @as(?u64, Compression.step_interval), - Compression.step(&state), - ); - try testing.expectEqual( - @as(usize, 1), - primary.pages.memoryStats().compressed_pages, - ); + // Contention may mean parsing is active. Scheduling is a harmless + // false positive when no compression work is actually pending. + self.schedule(thread, idle_interval); + } - // The no-work verification pass completes and leaves the timer disarmed. - try testing.expectEqual( - @as(?u64, null), - Compression.step(&state), - ); - try testing.expect(!terminal.compressionRequired()); -} + /// Start the one-shot timer, or move its deadline if it is already active. + fn schedule(self: *Compression, thread: *Thread, delay_ms: u64) void { + self.timer.reset( + &thread.loop, + &self.completion, + &self.reset_completion, + delay_ms, + Thread, + thread, + timerCallback, + ); + } + + fn timerCallback( + thread_: ?*Thread, + _: *xev.Loop, + _: *xev.Completion, + result: xev.Timer.RunError!void, + ) xev.CallbackAction { + _ = result catch |err| switch (err) { + error.Canceled => return .disarm, + else => { + log.warn("error in compression timer err={}", .{err}); + return .disarm; + }, + }; + + const thread = thread_ orelse return .disarm; + const self = &thread.compression; + + if (self.step(thread.state)) |delay| self.schedule(thread, delay); + return .disarm; + } + + /// Try one bounded step without waiting for the terminal lock. The return + /// value is the delay before another attempt, or null when work is done. + fn step(self: *Compression, state: *rendererpkg.State) ?u64 { + if (!state.mutex.tryLock()) return idle_interval; + defer state.mutex.unlock(); + + const activity = state.terminal.compressionActivity(); + if (self.activity != activity) { + self.activity = activity; + return idle_interval; + } + + return switch (state.terminal.compress(.incremental)) { + .pending => step_interval, + .unsupported, + .deferred, + .complete, + => null, + }; + } +}; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 032efe4d3..8f2f3c397 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -1219,7 +1219,7 @@ pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void { // reschedule compression. // TODO(mitchellh): Deferred reflow on non-viewport/non-active pages. self.page_compression.reset(); - self.page_compression.markDirty(); + self.page_compression.markActivity(); if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected @@ -2799,7 +2799,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { const was_active = self.viewport == .active; defer if (was_active != (self.viewport == .active)) { self.page_compression.reset(); - if (self.viewport == .active) self.page_compression.markDirty(); + if (self.viewport == .active) self.page_compression.markActivity(); }; // Special case no-scrollback mode to never allow scrolling. @@ -3433,7 +3433,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node { defer self.assertIntegrity(); // Growing can move a complete page behind the active boundary. - self.page_compression.markDirty(); + self.page_compression.markActivity(); const last = self.pages.last.?; if (last.capacity().rows > last.rows()) { @@ -3866,11 +3866,6 @@ const CompressionScratch = union(enum) { /// without requiring consumers to maintain a separate attempt cursor. const IncrementalCompressionState = struct { flags: packed struct { - // Set to true when incremental compression should be run. It - // isn't guaranteed that incremental compression would produce - // results but some work was done that may require compression. - dirty: bool = false, - /// Set after any page is compressed during the current traversal. /// We always run incremental compression until we get a fully /// no-compression pass (the verification pass). This lets us @@ -3879,10 +3874,24 @@ const IncrementalCompressionState = struct { /// Set after a traversal first reaches the active boundary. When this /// verification traversal reaches the boundary, `did_compress` - /// determines whether to restart once more or clear the dirty state. + /// determines whether to restart once more or finish the pass. verifying: bool = false, } = .{}, + /// Changes whenever PageList activity may affect compression work. + /// Callers use this value (via Terminal.compressionActivity) to + /// determine whether to recompress. + /// + /// The directionality of this doesn't matter. We overflow and + /// wrap when maxxed. The comparison of this value to your saved + /// value is all that matters. There is a possible edge case where you + /// don't compress, a full wraparound happens, and you get the same value, + /// but its so unlikely. + /// + /// This is 48-bits so that 16-bits can be reserved for Terminal to + /// add extra state. + activity_serial: u48 = 0, + /// Serial of the last page inspected by the traversal. This is /// intentionally an implementation detail and is reset by PageList /// operations which restart traversal progress. @@ -3892,14 +3901,15 @@ const IncrementalCompressionState = struct { /// node at or above this value before the saved marker requires a restart. next_serial: u64 = 0, - /// Schedule compression without disturbing valid traversal progress. - fn markDirty(self: *IncrementalCompressionState) void { - self.flags.dirty = true; + /// Record activity without disturbing valid traversal progress. + fn markActivity(self: *IncrementalCompressionState) void { + self.activity_serial +%= 1; } - /// Discard traversal state and leave no compression work pending. + /// Discard traversal progress without changing the activity token. fn reset(self: *IncrementalCompressionState) void { - self.* = .{}; + const activity_serial: u48 = self.activity_serial; + self.* = .{ .activity_serial = activity_serial }; } }; @@ -3984,8 +3994,6 @@ fn compressIncremental(self: *PageList) IncrementalCompressionResult { return .deferred; } - if (!state.flags.dirty) return .complete; - // Find the node following the exact continuation marker within the cold // prefix. A missing marker means the list changed between steps, so begin // again at the current first page. This lookup does not touch page memory. @@ -4042,10 +4050,11 @@ fn compressIncremental(self: *PageList) IncrementalCompressionResult { // do one pass after the first success so we can recompress nodes that // were possibly decompressed (e.g. by search, inspector, whatever). if (!state.flags.verifying or state.flags.did_compress) { - state.* = .{ .flags = .{ - .dirty = true, - .verifying = true, - } }; + const activity_serial: u48 = state.activity_serial; + state.* = .{ + .flags = .{ .verifying = true }, + .activity_serial = activity_serial, + }; return .pending; } @@ -6413,17 +6422,22 @@ test "PageList incremental compression defers and completes" { var s = try init(testing.allocator, 80, 24, null); defer s.deinit(); try s.growColdPagesForTest(1); - try testing.expect(s.page_compression.flags.dirty); + const initial_activity = s.page_compression.activity_serial; + try testing.expect(initial_activity > 0); s.scroll(.top); - try testing.expect(!s.page_compression.flags.dirty); + try testing.expectEqual( + initial_activity, + s.page_compression.activity_serial, + ); const deferred = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.deferred, deferred); const cold = s.pages.first.?; try testing.expect(!cold.isCompressed()); s.scroll(.active); - try testing.expect(s.page_compression.flags.dirty); + const active_activity = s.page_compression.activity_serial; + try testing.expect(active_activity != initial_activity); const pending = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, pending); try testing.expect(cold.isCompressed()); @@ -6432,15 +6446,21 @@ test "PageList incremental compression defers and completes" { // no work. const complete = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.complete, complete); - try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); - try testing.expect(!s.page_compression.flags.dirty); + try testing.expect(!s.page_compression.flags.did_compress); + try testing.expect(!s.page_compression.flags.verifying); + try testing.expectEqual( + active_activity, + s.page_compression.activity_serial, + ); + try testing.expectEqual(@as(?u64, null), s.page_compression.last_serial); + try testing.expectEqual(@as(u64, 0), s.page_compression.next_serial); - // Resetting and marking the state dirty begins at the oldest page and + // Resetting and recording activity begins at the oldest page and // recompresses restored content. _ = cold.page(); try testing.expect(!cold.isCompressed()); s.page_compression.reset(); - s.page_compression.markDirty(); + s.page_compression.markActivity(); const reconsidered = s.compress(.incremental); try testing.expectEqual(IncrementalCompressionResult.pending, reconsidered); try testing.expect(cold.isCompressed()); @@ -6456,50 +6476,59 @@ test "PageList owns incremental compression state" { var s = try init(testing.allocator, 80, 24, null); defer s.deinit(); - const dirty: IncrementalCompressionState = .{ + const state: IncrementalCompressionState = .{ .flags = .{ - .dirty = true, .did_compress = true, .verifying = true, }, + .activity_serial = 42, .last_serial = 42, .next_serial = 43, }; - s.page_compression = dirty; + s.page_compression = state; s.scroll(.top); - try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); // Scrolling within history preserves progress, while crossing back to the // active viewport resets it again. - s.page_compression = dirty; + s.page_compression = state; s.scroll(.top); - try testing.expectEqual(dirty, s.page_compression); + try testing.expectEqual(state, s.page_compression); s.scroll(.active); try testing.expectEqual( - IncrementalCompressionState{ .flags = .{ .dirty = true } }, + IncrementalCompressionState{ .activity_serial = 43 }, s.page_compression, ); - s.page_compression = dirty; + s.page_compression = state; try s.resize(.{ .cols = 80, .rows = 24 }); try testing.expectEqual( - IncrementalCompressionState{ .flags = .{ .dirty = true } }, + IncrementalCompressionState{ .activity_serial = 43 }, s.page_compression, ); - s.page_compression = dirty; + s.page_compression = state; s.reset(); - try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); - s.page_compression = dirty; + s.page_compression = state; try testing.expectEqual( IncrementalCompressionResult.complete, s.compress(.full), ); - try testing.expectEqual(IncrementalCompressionState{}, s.page_compression); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); - s.page_compression = dirty; + s.page_compression = state; var cloned = try s.clone(testing.allocator, .{ .top = .{ .active = .{} }, }); @@ -6521,7 +6550,7 @@ test "PageList incremental compression bounds inspected pages" { // metadata-only skip budget without stopping at a resident attempt. _ = s.compress(.full); s.page_compression.reset(); - s.page_compression.markDirty(); + s.page_compression.markActivity(); try testing.expectEqual( incremental_compression_max_inspected + 1, s.memoryStats().compressed_pages, @@ -6781,7 +6810,7 @@ test "PageList incremental compression keeps progress after tail growth" { try s.growColdPagesForTest(incremental_compression_max_inspected + 1); _ = s.compress(.full); s.page_compression.reset(); - s.page_compression.markDirty(); + s.page_compression.markActivity(); var expected_last = s.pages.first.?; for (1..incremental_compression_max_inspected) |_| diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 65a227e06..2996f365e 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -28,7 +28,6 @@ const mouse = @import("mouse.zig"); const Stream = @import("stream_terminal.zig").Stream; const size = @import("size.zig"); -const terminal_mem = @import("mem.zig"); const pagepkg = @import("page.zig"); const style = @import("style.zig"); const PageList = @import("PageList.zig"); @@ -2295,12 +2294,11 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { }); } -/// Return whether a compression pass is worth running again. +/// Return the current compression activity value. /// -/// When this returns true, callers should schedule a `compress` -/// call at some point. We recommend doing incremental compression because -/// compression is CPU heavy and stalls IO processing (due to the terminal -/// being blocked). See `compress` for more details. +/// Callers should schedule a `compress` call whenever this value changes. The +/// direction of the change has no meaning; this is an opaque change token +/// rather than a monotonic sequence exposed by Terminal. /// /// It is up to the terminal what it decides to compress, but currently /// we compress cold (non-viewed, non-editable) scrollback history on @@ -2308,12 +2306,12 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { /// /// Note that compression requires specific system features, namely /// the ability to retain virtual memory allocations while discarding their -/// physical memory backings. If the system libghostty is running on -/// doesn't support that this will always return false and compression -/// does nothing. -pub inline fn compressionRequired(self: *const Terminal) bool { - if (comptime !terminal_mem.canReclaim(.strict)) return false; - return self.screens.get(.primary).?.pages.page_compression.flags.dirty; +/// physical memory backings. Callers must still use `compress` to determine +/// whether compression is supported on the current target. +pub fn compressionActivity(self: *const Terminal) u64 { + const state = &self.screens.get(.primary).?.pages.page_compression; + // For now we don't use the extra 16 bits. + return @as(u64, state.activity_serial); } /// Compress cold memory to save resident memory space. diff --git a/src/terminal/main.zig b/src/terminal/main.zig index 0a2e7434f..c5b27fa9b 100644 --- a/src/terminal/main.zig +++ b/src/terminal/main.zig @@ -75,6 +75,9 @@ pub const Attribute = sgr.Attribute; pub const Options = @import("build_options.zig").Options; pub const options = @import("terminal_options"); +/// Whether this target supports terminal page compression. +pub const compression_enabled = @import("mem.zig").canReclaim(.strict); + /// This is set to true when we're building the C library. pub const c_api = if (options.c_abi) @import("c/main.zig") else void; From 95685afd26813b5ad93c912199f39e6295a919a3 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 09:31:07 -0700 Subject: [PATCH 13/18] terminal: compress offscreen scrollback history Compression previously stopped whenever the viewport left the active area, leaving all scrollback resident while a user viewed history. Traverse complete historical pages through a metadata-only iterator which skips the contiguous visible range. Restart incremental traversal after every viewport movement so pages become eligible once they leave view, while visible pages remain resident for immediate redraw. Add a PageList-only drain mode for tests and benchmarks, and update scrollback documentation to describe the offscreen eligibility rule. --- src/benchmark/ScrollbackCompression.zig | 33 +-- src/config/Config.zig | 4 +- src/renderer/Thread.zig | 1 - src/terminal/PageList.zig | 330 ++++++++++++++---------- src/terminal/Terminal.zig | 8 +- 5 files changed, 206 insertions(+), 170 deletions(-) diff --git a/src/benchmark/ScrollbackCompression.zig b/src/benchmark/ScrollbackCompression.zig index 85ca62fcd..40ec20f48 100644 --- a/src/benchmark/ScrollbackCompression.zig +++ b/src/benchmark/ScrollbackCompression.zig @@ -26,7 +26,7 @@ //! the common process and setup baseline for the other modes. //! * `compress` times one complete `compress` invocation. //! * `incremental` reaches the same final representation through -//! `compress(.incremental)`. It drains the candidate-bounded steps and final +//! `compress(.drain)`. It drains the candidate-bounded steps and final //! no-work verification pass in the timed region, making cursor and repeated //! traversal overhead directly comparable with `compress`. //! * `restore` compresses cold history during setup, outside the timed region, @@ -38,10 +38,12 @@ //! //! A fully historical page is a node strictly before the node containing the //! top of the active area. The boundary node is deliberately excluded because -//! it can contain both history and active rows. Normal terminal execution -//! compresses these pages incrementally after output becomes idle. The -//! benchmark invokes PageList operations directly so its timed regions exclude -//! the production scheduler's idle delay and renderer-thread coordination. +//! it can contain both history and active rows. Pages intersecting the current +//! viewport are also excluded so visible contents remain resident. Normal +//! terminal execution compresses eligible pages incrementally after activity +//! becomes idle. The benchmark invokes PageList operations directly so its +//! timed regions exclude the production scheduler's idle delay and +//! renderer-thread coordination. //! //! ## Examples //! @@ -218,27 +220,10 @@ fn stepCompress(ptr: *anyopaque) Benchmark.Error!void { fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); - self.drainIncrementalCompression(); + _ = self.pages().compress(.drain); std.mem.doNotOptimizeAway(&self.terminal); } -/// Drain incremental compression through its no-work verification pass. -/// -/// Each PageList step has a bounded candidate-inspection budget. The benchmark -/// loops until no more immediate work is available so its timed result can be -/// compared with the existing monolithic `compress` mode. -fn drainIncrementalCompression(self: *ScrollbackCompression) void { - while (true) { - switch (self.pages().compress(.incremental)) { - .pending => continue, - .unsupported, - .deferred, - .complete, - => return, - } - } -} - fn stepRestore(ptr: *anyopaque) Benchmark.Error!void { const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); std.mem.doNotOptimizeAway(self.visitColdPages()); @@ -345,7 +330,7 @@ test "ScrollbackCompression drains incremental compression steps" { defer stream.deinit(); for (0..256) |_| stream.nextSlice("aaaa\r\n"); - impl.drainIncrementalCompression(); + _ = impl.pages().compress(.drain); const incremental = impl.pages().memoryStats(); try testing.expect(incremental.compressed_pages > 0); diff --git a/src/config/Config.zig b/src/config/Config.zig index 77043647c..681dfe6f0 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1376,8 +1376,8 @@ input: RepeatableReadableIO = .{}, /// Scrollback is stored in memory and allocated lazily up to this limit, so /// setting a very large limit does not immediately consume that amount of /// memory. On supported systems, Ghostty attempts to compress fully historical -/// pages while the terminal is idle. This can reduce physical memory usage, -/// depending on the contents of the scrollback. +/// pages which are not currently visible while the terminal is idle. This can +/// reduce physical memory usage, depending on the contents of the scrollback. /// /// This limit always measures the uncompressed logical size of the terminal /// pages. Compression does not allow Ghostty to retain more history than the diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index b0566f9d3..da8fefc09 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -835,7 +835,6 @@ const Compression = struct { return switch (state.terminal.compress(.incremental)) { .pending => step_interval, .unsupported, - .deferred, .complete, => null, }; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 8f2f3c397..c160f490e 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -2794,20 +2794,20 @@ pub const Scroll = union(enum) { pub fn scroll(self: *PageList, behavior: Scroll) void { defer self.assertIntegrity(); - // Compression is deferred while viewing history. Restart traversal when - // crossing that boundary so any pages accessed there are reconsidered. - const was_active = self.viewport == .active; - defer if (was_active != (self.viewport == .active)) { - self.page_compression.reset(); - if (self.viewport == .active) self.page_compression.markActivity(); - }; - // Special case no-scrollback mode to never allow scrolling. if (self.explicit_max_size == 0) { self.viewport = .active; return; } + // Moving the viewport changes which historical pages are visible. Restart + // traversal so pages which leave the viewport are reconsidered after the + // renderer's idle delay. False positives from clamped scrolling are cheap. + defer { + self.page_compression.reset(); + self.page_compression.markActivity(); + } + switch (behavior) { .active => self.viewport = .active, .top => self.viewport = .top, @@ -3918,9 +3918,6 @@ pub const IncrementalCompressionResult = enum { /// Strict retained-mapping reclamation is unavailable on this target. unsupported, - /// The viewport is displaying history, so compression is postponed. - deferred, - /// More cold pages or a verification pass remain after this invocation's /// candidate-bounded work. pending, @@ -3929,6 +3926,55 @@ pub const IncrementalCompressionResult = enum { complete, }; +/// Iterate complete historical pages which do not intersect the viewport. +/// +/// All boundaries come from PageList pins and Page metadata. Advancing this +/// iterator never restores a compressed page or reads its backing memory. +const CompressionIterator = struct { + current: *List.Node, + active: *List.Node, + viewport_first: *List.Node, + viewport_last: *List.Node, + + fn init(self: *const PageList) CompressionIterator { + return .{ + .current = self.pages.first.?, + .active = self.getTopLeft(.active).node, + .viewport_first = self.getTopLeft(.viewport).node, + .viewport_last = self.getBottomRight(.viewport).?.node, + }; + } + + fn next(self: *CompressionIterator) ?*List.Node { + while (self.current != self.active) { + // The viewport is a contiguous node range. Once traversal reaches + // its first node, advance through the complete visible range and + // resume at the next offscreen page. + if (self.current == self.viewport_first) { + while (self.current != self.active and + self.current != self.viewport_last) + { + self.current = self.current.next.?; + } + + if (self.current == self.active) return null; + self.current = self.current.next.?; + continue; + } + + const node = self.current; + self.current = node.next.?; + return node; + } + + return null; + } + + fn done(self: *const CompressionIterator) bool { + return self.current == self.active; + } +}; + /// Bound candidate inspection independently from compression work. Skipping /// an already-compressed page is cheap, but still counts toward this limit. const incremental_compression_max_inspected = 8; @@ -3943,26 +3989,36 @@ const compressPage_tw = tripwire.module( /// Compress eligible nodes, saving a significant amount of memory. /// -/// Eligible nodes are complete pages before the active boundary. The boundary -/// page is excluded because it may contain both scrollback and active rows. +/// Eligible nodes are complete pages before the active boundary which do not +/// intersect the viewport. The boundary page is excluded because it may +/// contain both scrollback and active rows; visible pages remain resident for +/// immediate redraw and scrolling. /// -/// Compression requires that we're running on a system that supports -/// reclaiming physical memory for virtual allocations and the viewport -/// must be in the active area. +/// Compression requires a system that supports reclaiming physical memory for +/// virtual allocations while retaining their address ranges. /// -/// The mode specifies if this is an incremental or full compression. -/// Compression is SLOW (relatively), so incremental compressions during -/// idle periods are recommended. For incremental compression, the result -/// specifies whether to continue immediately. PageList tracks mutations which -/// require a later pass in its compression state. Full compression always -/// returns `complete`, indicating that it has no continuation to schedule -/// rather than that every page was compressed. +/// Compression is SLOW (relatively), so incremental compression during idle +/// periods is recommended. Incremental mode performs one bounded step and its +/// result specifies whether to continue immediately. Drain mode performs +/// incremental steps until the pass and its verification pass finish. Full +/// mode visits every currently eligible node once without using incremental +/// state. +/// +/// PageList tracks mutations which require a later incremental pass in its +/// compression state. Full compression always returns `complete`, indicating +/// that it has no continuation to schedule rather than that every page was +/// compressed. pub fn compress( self: *PageList, - mode: enum { incremental, full }, + mode: enum { incremental, drain, full }, ) IncrementalCompressionResult { return switch (mode) { .incremental => self.compressIncremental(), + .drain => while (true) switch (self.compressIncremental()) { + .pending => continue, + .complete => break .complete, + .unsupported => break .unsupported, + }, .full => full: { self.compressFull(); @@ -3985,37 +4041,26 @@ fn compressIncremental(self: *PageList) IncrementalCompressionResult { return .unsupported; } - // If the viewport isn't in the active area, we don't do any compression. - // The user is likely scrolling and we don't want to cause a bunch of - // compress/decompress churn. Just wait, they have to go back down - // eventually, you'd think! - if (self.viewport != .active) { - state.reset(); - return .deferred; - } - // Find the node following the exact continuation marker within the cold // prefix. A missing marker means the list changed between steps, so begin // again at the current first page. This lookup does not touch page memory. - const active_node = self.getTopLeft(.active).node; - var current = self.pages.first.?; + var it: CompressionIterator = .init(self); if (state.last_serial) |last_serial| continuation: { - while (current != active_node) : (current = current.next.?) { + while (it.next()) |node| { // A newly allocated or replacement node appeared before the // marker. Restart immediately so that node cannot be skipped. - if (current.serial >= state.next_serial) break; + if (node.serial >= state.next_serial) break; // Not a match? Keep looking - if (current.serial != last_serial) continue; + if (node.serial != last_serial) continue; - // Match! - current = current.next.?; + // Match! The iterator already points to the next offscreen node. break :continuation; } // Not found or otherwise invalid. Reset state.last_serial = null; - current = self.pages.first.?; + it = .init(self); } // Keep track of our next_serial @@ -4024,27 +4069,23 @@ fn compressIncremental(self: *PageList) IncrementalCompressionResult { // We cap the number of pages we look at to do our best to // time-bound the incremental compression. var inspected_pages: usize = 0; - while (current != active_node and - inspected_pages < incremental_compression_max_inspected) : (current = current.next.?) - { - state.last_serial = current.serial; + while (inspected_pages < incremental_compression_max_inspected) { + const node = it.next() orelse break; + + state.last_serial = node.serial; inspected_pages += 1; // If this page is already compressed, ignore it. - if (current.isCompressed()) continue; + if (node.isCompressed()) continue; // Compression is substantially more expensive even if it fails. // So we just try it. - if (self.compressPage(current)) state.flags.did_compress = true; - - // Breaking skips the while loop's continuation expression, so advance - // explicitly before checking whether we reached the active boundary. - current = current.next.?; + if (self.compressPage(node)) state.flags.did_compress = true; break; } // If we didn't reach our active node, then we still have work to do. - if (current != active_node) return .pending; + if (!it.done()) return .pending; // We reached our active node. So we're done, except that we always // do one pass after the first success so we can recompress nodes that @@ -4070,17 +4111,8 @@ fn compressFull(self: *PageList) void { // target where strict retained-mapping reclamation cannot succeed. if (!terminal_mem.canReclaim(.strict)) return; - // Rendering a historical viewport may need any history page. Deferring - // the complete pass avoids compressing data that is about to be restored. - if (self.viewport != .active) return; - - // The active top is metadata-only and does not restore its page. Every - // node strictly before it is fully historical; the boundary node may - // contain a historical prefix followed by active rows. - const active_node = self.getTopLeft(.active).node; - var current = self.pages.first; - while (current) |node| : (current = node.next) { - if (node == active_node) break; + var it: CompressionIterator = .init(self); + while (it.next()) |node| { // Don't restore an already-compressed page just to recompress it. if (node.isCompressed()) continue; @@ -6416,58 +6448,57 @@ fn growColdPagesForTest(self: *PageList, count: usize) !void { } } -test "PageList incremental compression defers and completes" { +test "PageList incremental compression skips visible history" { const testing = std.testing; var s = try init(testing.allocator, 80, 24, null); defer s.deinit(); - try s.growColdPagesForTest(1); + try s.growColdPagesForTest(3); + const initial_activity = s.page_compression.activity_serial; try testing.expect(initial_activity > 0); s.scroll(.top); - try testing.expectEqual( - initial_activity, - s.page_compression.activity_serial, - ); - const deferred = s.compress(.incremental); - try testing.expectEqual(IncrementalCompressionResult.deferred, deferred); - const cold = s.pages.first.?; - try testing.expect(!cold.isCompressed()); - - s.scroll(.active); - const active_activity = s.page_compression.activity_serial; - try testing.expect(active_activity != initial_activity); - const pending = s.compress(.incremental); - try testing.expectEqual(IncrementalCompressionResult.pending, pending); - try testing.expect(cold.isCompressed()); - - // Compression becomes idle only after a complete verification pass does - // no work. - const complete = s.compress(.incremental); - try testing.expectEqual(IncrementalCompressionResult.complete, complete); - try testing.expect(!s.page_compression.flags.did_compress); - try testing.expect(!s.page_compression.flags.verifying); - try testing.expectEqual( - active_activity, - s.page_compression.activity_serial, - ); - try testing.expectEqual(@as(?u64, null), s.page_compression.last_serial); - try testing.expectEqual(@as(u64, 0), s.page_compression.next_serial); - - // Resetting and recording activity begins at the oldest page and - // recompresses restored content. - _ = cold.page(); - try testing.expect(!cold.isCompressed()); - s.page_compression.reset(); - s.page_compression.markActivity(); - const reconsidered = s.compress(.incremental); - try testing.expectEqual(IncrementalCompressionResult.pending, reconsidered); - try testing.expect(cold.isCompressed()); + const top_activity = s.page_compression.activity_serial; + try testing.expect(top_activity != initial_activity); try testing.expectEqual( IncrementalCompressionResult.complete, - s.compress(.incremental), + s.compress(.drain), ); + + const first = s.pages.first.?; + const second = first.next.?; + try testing.expectEqual(first, s.getTopLeft(.viewport).node); + try testing.expectEqual(first, s.getBottomRight(.viewport).?.node); + try testing.expect(!first.isCompressed()); + + var eligible: CompressionIterator = .init(&s); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); + + // Move the viewport to the start of the second page. Rendering it restores + // that page, while the first page which just left view becomes eligible. + s.scroll(.{ .row = first.rows() }); + try testing.expectEqual(second, s.getTopLeft(.viewport).node); + _ = second.page(); + try testing.expect(!second.isCompressed()); + _ = s.compress(.drain); + try testing.expect(first.isCompressed()); + try testing.expect(!second.isCompressed()); + + // Returning to the active area makes every complete historical page + // eligible again, including the page which was just visible. + s.scroll(.active); + _ = s.compress(.drain); + try testing.expect(second.isCompressed()); + try testing.expect(!s.page_compression.flags.did_compress); + try testing.expect(!s.page_compression.flags.verifying); + try testing.expectEqual(@as(?u64, null), s.page_compression.last_serial); + try testing.expectEqual(@as(u64, 0), s.page_compression.next_serial); } test "PageList owns incremental compression state" { @@ -6489,15 +6520,20 @@ test "PageList owns incremental compression state" { s.page_compression = state; s.scroll(.top); try testing.expectEqual( - IncrementalCompressionState{ .activity_serial = 42 }, + IncrementalCompressionState{ .activity_serial = 43 }, s.page_compression, ); - // Scrolling within history preserves progress, while crossing back to the - // active viewport resets it again. + // Every scroll restarts traversal, even if clamping leaves the viewport in + // the same place. Missing an eligible page is worse than a no-op pass. s.page_compression = state; s.scroll(.top); - try testing.expectEqual(state, s.page_compression); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; s.scroll(.active); try testing.expectEqual( IncrementalCompressionState{ .activity_serial = 43 }, @@ -7149,44 +7185,60 @@ test "PageList lazily restores compressed history made active by resize" { try testing.expectEqual(page_size, s.page_size); } -test "PageList cold compression defers historical viewports and retries restored pages" { +test "PageList full and incremental compression skip a spanning viewport" { const testing = std.testing; - var s = try init(testing.allocator, 80, 24, null); - defer s.deinit(); - try s.growColdPagesForTest(2); + var full = try init(testing.allocator, 80, 24, null); + defer full.deinit(); + try full.growColdPagesForTest(3); - const page_size = s.page_size; - s.scroll(.top); - _ = s.compress(.full); - try testing.expect(!s.pages.first.?.isCompressed()); + var incremental = try init(testing.allocator, 80, 24, null); + defer incremental.deinit(); + try incremental.growColdPagesForTest(3); - s.scroll(.{ .row = 1 }); - try testing.expect(s.viewport == .pin); - _ = s.compress(.full); - try testing.expect(!s.pages.first.?.isCompressed()); + // Start near the end of the first page so the viewport intersects both + // the first and second historical page mappings. + const first = full.pages.first.?; + const overlap_rows: usize = full.rows / 2; + const viewport_row: usize = first.rows() - overlap_rows; + full.scroll(.{ .row = viewport_row }); + incremental.scroll(.{ .row = viewport_row }); + try testing.expect( + full.getTopLeft(.viewport).node != + full.getBottomRight(.viewport).?.node, + ); + const second = first.next.?; + try testing.expectEqual(first, full.getTopLeft(.viewport).node); + try testing.expectEqual(second, full.getBottomRight(.viewport).?.node); - s.scroll(.active); - _ = s.compress(.full); - const initial = s.memoryStats(); - try testing.expectEqual(@as(usize, 2), initial.compressed_pages); - try testing.expectEqual(page_size, s.page_size); - - // A second pass skips the two compressed representations entirely. - _ = s.compress(.full); - try testing.expectEqual(initial, s.memoryStats()); - - // Content access restores one page. Because the policy is stateless, the - // next explicit pass sees that resident page and compresses it again. - const first = s.pages.first.?; - const memory_ptr = first.metadata().memory.ptr; - _ = first.page(); + _ = full.compress(.full); + _ = incremental.compress(.drain); + try testing.expectEqual(full.memoryStats(), incremental.memoryStats()); try testing.expect(!first.isCompressed()); + try testing.expect(!second.isCompressed()); - _ = s.compress(.full); - try testing.expectEqual(@as(usize, 2), s.memoryStats().compressed_pages); - try testing.expectEqual(memory_ptr, first.metadata().memory.ptr); - try testing.expectEqual(page_size, s.page_size); + const full_active = full.getTopLeft(.active).node; + const incremental_active = incremental.getTopLeft(.active).node; + var full_node = full.pages.first.?; + var incremental_node = incremental.pages.first.?; + while (full_node != full_active) { + try testing.expectEqual( + full_node.isCompressed(), + incremental_node.isCompressed(), + ); + + full_node = full_node.next.?; + incremental_node = incremental_node.next.?; + } + try testing.expectEqual(incremental_active, incremental_node); + + var eligible: CompressionIterator = .init(&full); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); } test "PageList cold compression continues after an incompressible page" { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index 2996f365e..dfe2a1536 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2316,10 +2316,10 @@ pub fn compressionActivity(self: *const Terminal) u64 { /// Compress cold memory to save resident memory space. /// -/// This can be done in two modes: incremental or full. A full compression -/// does a full pass compressing everything it can before returning. This -/// is not recommended for interactive terminals because compression is -/// relatively slow and with large scrollbacks this can cause stalls. +/// Full compression does a full pass compressing everything it can before +/// returning. This is not recommended for interactive terminals because +/// compression is relatively slow and with large scrollbacks this can cause +/// stalls. /// /// Incremental compression bounds itself on how much data it can look /// up to compress and how much compression work it does before returning. From 0fb89f4ffebabd7ea868f75a93f14a41ff65764a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 09:38:43 -0700 Subject: [PATCH 14/18] terminal: configure scrollback compression Idle compression was always enabled on supported renderer-backed surfaces and the default logical scrollback limit remained sized for fully resident history. Add a default-on scrollback-compression option and make renderer scheduling honor it across config reloads. Existing compressed pages remain encoded when the option is disabled, while reenabling it starts a fresh idle pass. Raise the default logical scrollback limit from 10 MB to 50 MB and document typical physical-memory savings, content-dependent behavior, and retained virtual address usage. --- src/config/Config.zig | 33 +++++++++++++++++++++++++++++---- src/renderer/Thread.zig | 20 ++++++++++++++++++-- 2 files changed, 47 insertions(+), 6 deletions(-) diff --git a/src/config/Config.zig b/src/config/Config.zig index 681dfe6f0..d6a038d9d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1375,9 +1375,10 @@ input: RepeatableReadableIO = .{}, /// /// Scrollback is stored in memory and allocated lazily up to this limit, so /// setting a very large limit does not immediately consume that amount of -/// memory. On supported systems, Ghostty attempts to compress fully historical -/// pages which are not currently visible while the terminal is idle. This can -/// reduce physical memory usage, depending on the contents of the scrollback. +/// memory. On supported systems with scrollback compression enabled, Ghostty +/// attempts to compress fully historical pages which are not currently visible +/// while the terminal is idle. This can reduce physical memory usage, depending +/// on the contents of the scrollback. /// /// This limit always measures the uncompressed logical size of the terminal /// pages. Compression does not allow Ghostty to retain more history than the @@ -1390,7 +1391,31 @@ input: RepeatableReadableIO = .{}, /// This is a future planned feature. /// /// This can be changed at runtime but will only affect new terminal surfaces. -@"scrollback-limit": usize = 10_000_000, // 10MB +@"scrollback-limit": usize = 50_000_000, // 50MB + +/// Whether to compress scrollback pages while the terminal is idle. +/// +/// Ghostty does its best to only compress when idle and decompress +/// as needed. This means that compression doesn't lower IO throughput. +/// We recommend you keep it on. +/// +/// The scrollback limit remains an uncompressed logical limit regardless of +/// this setting, so disabling compression can increase physical memory usage +/// but does not change how much history is retained. +/// +/// Text-heavy terminal history generally compresses to approximately 10% to +/// 30% of its uncompressed page memory, corresponding to a 70% to 90% reduction +/// in physical memory for pages which are compressed. Compression savings are +/// content-dependent. +/// +/// Note that the way Ghostty works is that we compress and discard the +/// physical/resident memory but we retain virtual mappings. You will not +/// see a decrease in virtual memory usage, but you will see a decrease +/// in physical/memory usage. +/// +/// Changing this at runtime affects future compression work. Pages which are +/// already compressed remain compressed until their contents are accessed. +@"scrollback-compression": bool = true, /// Control when the scrollbar is shown to scroll the scrollback buffer. /// diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index da8fefc09..7f93e9493 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -115,10 +115,12 @@ flags: packed struct { pub const DerivedConfig = struct { custom_shader_animation: configpkg.CustomShaderAnimation, + scrollback_compression: bool, pub fn init(config: *const configpkg.Config) DerivedConfig { return .{ .custom_shader_animation = config.@"custom-shader-animation", + .scrollback_compression = config.@"scrollback-compression", }; } }; @@ -506,6 +508,16 @@ fn drainMailbox(self: *Thread) !void { } fn changeConfig(self: *Thread, config: *const DerivedConfig) !void { + // A newly enabled scheduler must reconsider existing history even when no + // terminal activity occurred while compression was disabled. + if (comptime terminalpkg.compression_enabled) { + if (!self.config.scrollback_compression and + config.scrollback_compression) + { + self.compression.activity = null; + } + } + self.config = config.*; } @@ -769,6 +781,7 @@ const Compression = struct { fn wake(self: *Compression, thread: *Thread) void { // If we have no compression then don't do anything. if (comptime !terminalpkg.compression_enabled) return; + if (!thread.config.scrollback_compression) return; // PageList activity, rather than a generic renderer wake, restarts the // idle interval. In particular, the inspector wakes the renderer every @@ -816,13 +829,16 @@ const Compression = struct { const thread = thread_ orelse return .disarm; const self = &thread.compression; - if (self.step(thread.state)) |delay| self.schedule(thread, delay); + if (self.step(thread)) |delay| self.schedule(thread, delay); return .disarm; } /// Try one bounded step without waiting for the terminal lock. The return /// value is the delay before another attempt, or null when work is done. - fn step(self: *Compression, state: *rendererpkg.State) ?u64 { + fn step(self: *Compression, thread: *Thread) ?u64 { + if (!thread.config.scrollback_compression) return null; + + const state = thread.state; if (!state.mutex.tryLock()) return idle_interval; defer state.mutex.unlock(); From 6d5dda40db6e6fbde98064e0bd6ec3d2de29c928 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 09:44:57 -0700 Subject: [PATCH 15/18] inspector: clarify page compression memory The PageList overview mixed structural state with a long list of exact byte counters, making the compression result difficult to interpret. Keep the overview focused on grid and scrollback structure, and add a dedicated compression section before scrollbar details. Present page states, uncompressed size, encoded ratio, resident estimate, and savings using readable units and scoped help text. --- src/inspector/widgets/pagelist.zig | 241 +++++++++++++++++------------ 1 file changed, 146 insertions(+), 95 deletions(-) diff --git a/src/inspector/widgets/pagelist.zig b/src/inspector/widgets/pagelist.zig index fac6afbed..7e0012896 100644 --- a/src/inspector/widgets/pagelist.zig +++ b/src/inspector/widgets/pagelist.zig @@ -4,7 +4,6 @@ const terminal = @import("../../terminal/main.zig"); const pagepkg = @import("../../terminal/page.zig"); const stylepkg = @import("../../terminal/style.zig"); const widgets = @import("../widgets.zig"); -const units = @import("../units.zig"); const PageList = terminal.PageList; @@ -37,6 +36,13 @@ pub const Inspector = struct { summaryTable(pages); } + if (cimgui.c.ImGui_CollapsingHeader( + "Page Compression", + cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, + )) { + compressionTable(pages); + } + if (cimgui.c.ImGui_CollapsingHeader( "Scrollbar & Regions", cimgui.c.ImGuiTreeNodeFlags_DefaultOpen, @@ -112,8 +118,6 @@ pub const Inspector = struct { }; fn summaryTable(pages: *const PageList) void { - const memory = pages.memoryStats(); - if (!cimgui.c.ImGui_BeginTable( "pagelist_summary", 3, @@ -139,17 +143,6 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(2); cimgui.c.ImGui_Text("%zu", pages.totalPages()); - summaryCountRow( - "Resident Pages", - "Pages whose raw backing memory is currently resident.", - memory.resident_pages, - ); - summaryCountRow( - "Compressed Pages", - "Pages retained as encoded data with their raw backing memory discarded.", - memory.compressed_pages, - ); - cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); cimgui.c.ImGui_Text("Total Rows"); @@ -158,57 +151,19 @@ fn summaryTable(pages: *const PageList) void { _ = cimgui.c.ImGui_TableSetColumnIndex(2); cimgui.c.ImGui_Text("%zu", pages.total_rows); - summaryBytesRow( - "Logical Page Bytes", - "Bytes counted against the scrollback limit. Compression does not change this value.", - pages.page_size, - ); - summaryBytesRow( - "Raw Mapping Bytes", - "Total initialized raw page mappings, including discarded mappings retained for restoration.", - memory.raw_bytes, - ); - summaryBytesRow( - "Resident Raw Bytes", - "Raw page mapping bytes which have not been discarded.", - memory.resident_raw_bytes, - ); - summaryBytesRow( - "Decommitted Raw Bytes", - "Raw page mapping bytes discarded while their virtual addresses remain reserved.", - memory.decommitted_raw_bytes, - ); - summaryBytesRow( - "Encoded Bytes", - "Exact encoded storage retained for compressed pages.", - memory.encoded_bytes, - ); - summaryBytesRow( - "Estimated Resident Bytes", - "Resident raw allocation backing, including unused pool-item tails, plus encoded storage. Allocator and representation overhead are excluded.", - memory.estimatedResidentBytes(), - ); - summaryBytesRow( - "Estimated Savings", - "Decommitted raw mapping bytes minus their replacement encoded storage.", - memory.estimatedSavings(), - ); - cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Max Size"); + cimgui.c.ImGui_Text("Scrollback Limit"); _ = cimgui.c.ImGui_TableSetColumnIndex(1); widgets.helpMarker( - \\Maximum bytes before pages must be evicated. The total - \\used bytes may be higher due to minimum individual page - \\sizes but the next allocation that would exceed this limit - \\will evict pages from the front of the list to free up space. + \\Maximum uncompressed logical page memory before the oldest + \\history is evicted. Minimum page allocation sizes can make + \\current usage temporarily exceed this value. ); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text( - "%zu KiB", - units.toKibiBytes(pages.maxSize()), - ); + var limit_buf: [64]u8 = undefined; + const limit = formatBytes(&limit_buf, pages.maxSize()); + cimgui.c.ImGui_TextUnformatted(limit.ptr); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -217,46 +172,142 @@ fn summaryTable(pages: *const PageList) void { widgets.helpMarker("Current viewport anchoring mode."); _ = cimgui.c.ImGui_TableSetColumnIndex(2); cimgui.c.ImGui_Text("%s", @tagName(pages.viewport).ptr); - - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("Tracked Pins"); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker("Number of pins tracked for automatic updates."); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%zu", pages.countTrackedPins()); } -fn summaryCountRow( - label: [:0]const u8, - help: [:0]const u8, - value: usize, -) void { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("%s", label.ptr); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker(help); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%zu", value); -} +fn compressionTable(pages: *const PageList) void { + const memory = pages.memoryStats(); -fn summaryBytesRow( - label: [:0]const u8, - help: [:0]const u8, - bytes: usize, -) void { - cimgui.c.ImGui_TableNextRow(); - _ = cimgui.c.ImGui_TableSetColumnIndex(0); - cimgui.c.ImGui_Text("%s", label.ptr); - _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker(help); - _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text( - "%zu bytes (%zu KiB)", - bytes, - units.toKibiBytes(bytes), + if (!cimgui.c.ImGui_BeginTable( + "pagelist_compression", + 3, + cimgui.c.ImGuiTableFlags_BordersInnerV | + cimgui.c.ImGuiTableFlags_RowBg | + cimgui.c.ImGuiTableFlags_SizingFixedFit, + )) return; + defer cimgui.c.ImGui_EndTable(); + + compressionTextRow( + "Platform Support", + "Whether this target can discard physical page memory while retaining " ++ + "its virtual address range. The scrollback-compression setting may " ++ + "still disable automatic compression on a supported platform.", + if (terminal.compression_enabled) "supported" else "unsupported", ); + + var state_buf: [96]u8 = undefined; + const state = std.fmt.bufPrintZ( + &state_buf, + "{d} compressed, {d} resident", + .{ memory.compressed_pages, memory.resident_pages }, + ) catch unreachable; + compressionTextRow( + "Page States", + "Compressed pages retain an encoded allocation while their raw mapping " ++ + "is decommitted. Resident pages still have physical raw backing. " ++ + "Active, visible, and recently changed pages are expected to be resident.", + state, + ); + + var raw_buf: [64]u8 = undefined; + compressionTextRow( + "Uncompressed Size", + "Total size of all raw page mappings. This is the approximate page " ++ + "backing memory required if no pages were compressed and is also " ++ + "the virtual address space retained across compression.", + formatBytes(&raw_buf, memory.raw_bytes), + ); + + var encoded_buf: [64]u8 = undefined; + var compressed_raw_buf: [64]u8 = undefined; + var storage_buf: [160]u8 = undefined; + const storage = if (memory.decommitted_raw_bytes > 0) storage: { + const ratio = percentage( + memory.encoded_bytes, + memory.decommitted_raw_bytes, + ); + break :storage std.fmt.bufPrintZ( + &storage_buf, + "{s} encoded / {s} raw ({d:.1}%)", + .{ + formatBytes(&encoded_buf, memory.encoded_bytes), + formatBytes( + &compressed_raw_buf, + memory.decommitted_raw_bytes, + ), + ratio, + }, + ) catch unreachable; + } else "none"; + compressionTextRow( + "Compressed Storage", + "Encoded allocation size compared with the original raw size of only " ++ + "the compressed pages. The percentage is the compression ratio, " ++ + "so smaller is better. Raw physical pages have been discarded.", + storage, + ); + + var resident_buf: [64]u8 = undefined; + compressionTextRow( + "Estimated Resident", + "Physical page backing estimated to remain after compression: resident " ++ + "raw allocations, including unused pool tails, plus encoded storage. " ++ + "Node and allocator metadata and unrelated terminal memory are excluded.", + formatBytes(&resident_buf, memory.estimatedResidentBytes()), + ); + + var savings_bytes_buf: [64]u8 = undefined; + var savings_buf: [128]u8 = undefined; + const savings = memory.estimatedSavings(); + const savings_text = std.fmt.bufPrintZ( + &savings_buf, + "{s} ({d:.1}% of raw page memory)", + .{ + formatBytes(&savings_bytes_buf, savings), + percentage(savings, memory.raw_bytes), + }, + ) catch unreachable; + compressionTextRow( + "Estimated Savings", + "Physical page backing avoided across the complete PageList. This is " ++ + "decommitted raw memory minus replacement encoded storage and is " ++ + "not a measurement of total process RSS.", + savings_text, + ); +} + +fn compressionTextRow( + label: [:0]const u8, + help: [:0]const u8, + value: [:0]const u8, +) void { + cimgui.c.ImGui_TableNextRow(); + _ = cimgui.c.ImGui_TableSetColumnIndex(0); + cimgui.c.ImGui_Text("%s", label.ptr); + _ = cimgui.c.ImGui_TableSetColumnIndex(1); + widgets.helpMarker(help); + _ = cimgui.c.ImGui_TableSetColumnIndex(2); + cimgui.c.ImGui_TextUnformatted(value.ptr); +} + +fn formatBytes(buf: []u8, bytes: usize) [:0]const u8 { + if (bytes >= 1024 * 1024) { + const value: f64 = @as(f64, @floatFromInt(bytes)) / (1024 * 1024); + return std.fmt.bufPrintZ(buf, "{d:.2} MiB", .{value}) catch unreachable; + } + + if (bytes >= 1024) { + const value: f64 = @as(f64, @floatFromInt(bytes)) / 1024; + return std.fmt.bufPrintZ(buf, "{d:.1} KiB", .{value}) catch unreachable; + } + + return std.fmt.bufPrintZ(buf, "{d} B", .{bytes}) catch unreachable; +} + +fn percentage(numerator: usize, denominator: usize) f64 { + if (denominator == 0) return 0; + return 100 * + @as(f64, @floatFromInt(numerator)) / + @as(f64, @floatFromInt(denominator)); } fn scrollbarInfo(pages: *PageList) void { From 172f15da3b904633f798d99cb6e43acd78cbdc79 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 10:11:07 -0700 Subject: [PATCH 16/18] terminal: expose compression through libghostty-vt Scrollback compression scheduling was only available to Zig callers that used Terminal directly, leaving C embedders unable to drive the same idle compression policy. Define ABI-aware mode and result enums on Terminal and export activity and compression operations through the C API. Keep scheduling caller-owned, validate C inputs, and document the incremental contract with a complete example. Report unsupported reclamation consistently for full passes so callers can disable compression on targets that cannot retain decommitted mappings. --- example/c-vt-compression/README.md | 17 ++++ example/c-vt-compression/build.zig | 32 ++++++ example/c-vt-compression/build.zig.zon | 14 +++ example/c-vt-compression/src/main.c | 72 +++++++++++++ include/ghostty/vt.h | 6 ++ include/ghostty/vt/terminal.h | 96 +++++++++++++++++ src/lib_vt.zig | 2 + src/terminal/PageList.zig | 17 ++-- src/terminal/Terminal.zig | 31 +++++- src/terminal/c/main.zig | 2 + src/terminal/c/terminal.zig | 136 +++++++++++++++++++++++++ 11 files changed, 415 insertions(+), 10 deletions(-) create mode 100644 example/c-vt-compression/README.md create mode 100644 example/c-vt-compression/build.zig create mode 100644 example/c-vt-compression/build.zig.zon create mode 100644 example/c-vt-compression/src/main.c diff --git a/example/c-vt-compression/README.md b/example/c-vt-compression/README.md new file mode 100644 index 000000000..a269faf2e --- /dev/null +++ b/example/c-vt-compression/README.md @@ -0,0 +1,17 @@ +# Example: Scrollback Compression in C + +This example shows how a libghostty-vt embedding application can track +compression-relevant terminal activity and perform incremental scrollback +compression after its own idle delay. + +libghostty-vt does not create a timer or background thread. The embedding +application remains responsible for scheduling compression and serializing it +with other access to the terminal. + +## Usage + +Run the example: + +```shell-session +zig build run +``` diff --git a/example/c-vt-compression/build.zig b/example/c-vt-compression/build.zig new file mode 100644 index 000000000..ab918ffc8 --- /dev/null +++ b/example/c-vt-compression/build.zig @@ -0,0 +1,32 @@ +const std = @import("std"); + +pub fn build(b: *std.Build) void { + const target = b.standardTargetOptions(.{}); + const optimize = b.standardOptimizeOption(.{}); + + const run_step = b.step("run", "Run the app"); + + const exe_mod = b.createModule(.{ + .target = target, + .optimize = optimize, + }); + exe_mod.addCSourceFiles(.{ + .root = b.path("src"), + .files = &.{"main.c"}, + }); + + if (b.lazyDependency("ghostty", .{})) |dep| { + exe_mod.linkLibrary(dep.artifact("ghostty-vt")); + } + + const exe = b.addExecutable(.{ + .name = "c_vt_compression", + .root_module = exe_mod, + }); + b.installArtifact(exe); + + const run_cmd = b.addRunArtifact(exe); + run_cmd.step.dependOn(b.getInstallStep()); + if (b.args) |args| run_cmd.addArgs(args); + run_step.dependOn(&run_cmd.step); +} diff --git a/example/c-vt-compression/build.zig.zon b/example/c-vt-compression/build.zig.zon new file mode 100644 index 000000000..d102fd67b --- /dev/null +++ b/example/c-vt-compression/build.zig.zon @@ -0,0 +1,14 @@ +.{ + .name = .c_vt_compression, + .version = "0.0.0", + .fingerprint = 0x3d527aa68a4cf8d, + .minimum_zig_version = "0.15.1", + .dependencies = .{ + .ghostty = .{ .path = "../../" }, + }, + .paths = .{ + "build.zig", + "build.zig.zon", + "src", + }, +} diff --git a/example/c-vt-compression/src/main.c b/example/c-vt-compression/src/main.c new file mode 100644 index 000000000..f3269b410 --- /dev/null +++ b/example/c-vt-compression/src/main.c @@ -0,0 +1,72 @@ +#include +#include +#include +#include +#include + +//! [compression-idle-step] +// Perform one step after the application's idle timer fires. Returning true +// asks the application to schedule another step while the terminal is idle. +static bool compression_idle_step(GhosttyTerminal terminal) { + GhosttyTerminalCompressionResult compression_result; + GhosttyResult result = ghostty_terminal_compress( + terminal, + GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL, + &compression_result); + assert(result == GHOSTTY_SUCCESS); + + switch (compression_result) { + case GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING: + return true; + case GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE: + case GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED: + return false; + default: + assert(false); + return false; + } +} +//! [compression-idle-step] + +int main(void) { + GhosttyTerminal terminal; + GhosttyTerminalOptions opts = { + .cols = 80, + .rows = 24, + .max_scrollback = 10 * 1024 * 1024, + }; + GhosttyResult result = ghostty_terminal_new(NULL, &terminal, opts); + assert(result == GHOSTTY_SUCCESS); + + //! [compression-activity] + uint64_t compression_activity; + result = ghostty_terminal_compression_activity( + terminal, + &compression_activity); + assert(result == GHOSTTY_SUCCESS); + + // Terminal mutations may change the token. When it changes, restart the + // application's idle timer rather than compressing on the output path. + const char *line = "repeated and compressible terminal history\r\n"; + for (size_t i = 0; i < 4000; i++) { + ghostty_terminal_vt_write( + terminal, + (const uint8_t *)line, + strlen(line)); + } + + uint64_t new_activity; + result = ghostty_terminal_compression_activity(terminal, &new_activity); + assert(result == GHOSTTY_SUCCESS); + if (new_activity != compression_activity) { + compression_activity = new_activity; + // Restart the application's compression idle timer here. + } + //! [compression-activity] + + // Simulate the idle timer and its short pending-work continuations. + while (compression_idle_step(terminal)) {} + + ghostty_terminal_free(terminal); + return 0; +} diff --git a/include/ghostty/vt.h b/include/ghostty/vt.h index b566c730a..5606f1690 100644 --- a/include/ghostty/vt.h +++ b/include/ghostty/vt.h @@ -56,6 +56,7 @@ * - @ref c-vt-formatter/src/main.c - Terminal formatter example * - @ref c-vt-grid-traverse/src/main.c - Grid traversal example using grid refs * - @ref c-vt-grid-ref-tracked/src/main.c - Tracked grid ref example + * - @ref c-vt-compression/src/main.c - Idle scrollback compression example * */ @@ -105,6 +106,11 @@ * detect when it loses its value, and move it to a new point. */ +/** @example c-vt-compression/src/main.c + * This example demonstrates how to schedule incremental scrollback compression + * after compression-relevant terminal activity becomes idle. + */ + /** @example c-vt-selection-gesture/src/main.c * This example demonstrates how to use synthetic selection gesture events to * derive drag and deep-press selection snapshots. diff --git a/include/ghostty/vt/terminal.h b/include/ghostty/vt/terminal.h index b03653129..b67e4c6de 100644 --- a/include/ghostty/vt/terminal.h +++ b/include/ghostty/vt/terminal.h @@ -40,6 +40,17 @@ extern "C" { * @snippet c-vt-stream/src/main.c vt-stream-init * @snippet c-vt-stream/src/main.c vt-stream-write * + * ## Scrollback Compression + * + * Scrollback compression is caller-driven. The terminal exposes an opaque + * activity token so an embedding application can restart an idle timer only + * when compression-relevant state changes. Once idle, call incremental + * compression until it no longer reports pending work. libghostty-vt does not + * create a timer or background thread. + * + * @snippet c-vt-compression/src/main.c compression-activity + * @snippet c-vt-compression/src/main.c compression-idle-step + * * ## Effects * * By default, the terminal sequence processing with ghostty_terminal_vt_write() @@ -177,6 +188,37 @@ typedef struct { // future options. } GhosttyTerminalOptions; +/** + * Amount of compression work to perform before returning. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Perform one bounded compression step suitable for idle scheduling. */ + GHOSTTY_TERMINAL_COMPRESSION_MODE_INCREMENTAL = 0, + + /** Synchronously inspect every currently eligible page. */ + GHOSTTY_TERMINAL_COMPRESSION_MODE_FULL = 1, + GHOSTTY_TERMINAL_COMPRESSION_MODE_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCompressionMode; + +/** + * Scheduling result from terminal compression. + * + * @ingroup terminal + */ +typedef enum GHOSTTY_ENUM_TYPED { + /** Retained-mapping reclamation is unavailable on this target. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_UNSUPPORTED = 0, + + /** More incremental compression work remains. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_PENDING = 1, + + /** The pass has no continuation to schedule. */ + GHOSTTY_TERMINAL_COMPRESSION_RESULT_COMPLETE = 2, + GHOSTTY_TERMINAL_COMPRESSION_RESULT_MAX_VALUE = GHOSTTY_ENUM_MAX_VALUE, +} GhosttyTerminalCompressionResult; + /** * Scroll viewport behavior tag. * @@ -1158,6 +1200,60 @@ GHOSTTY_API void ghostty_terminal_vt_write(GhosttyTerminal terminal, GHOSTTY_API void ghostty_terminal_scroll_viewport(GhosttyTerminal terminal, GhosttyTerminalScrollViewport behavior); +/** + * Return the current compression activity token. + * + * The token is opaque and only equality comparisons are meaningful. An + * embedding application should cache it and restart its compression idle + * delay whenever the value changes. The value may wrap and changes in either + * direction have the same meaning. + * + * This function only observes terminal state. It does not perform or schedule + * compression. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param[out] out_activity Receives the current activity token + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument + * is NULL + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_compression_activity( + GhosttyTerminal terminal, + uint64_t* out_activity); + +/** + * Compress eligible terminal scrollback. + * + * Incremental mode performs bounded work suitable for an idle callback. A + * pending result means the application should invoke another step while the + * terminal remains idle. A complete result means no continuation is needed + * until ghostty_terminal_compression_activity() changes. Full mode performs + * one synchronous scan and can stall on large scrollback buffers. + * + * Compression is opportunistic. Complete means the pass has finished, not + * that every page was compressed: pages may be unprofitable or encounter an + * allocation or reclamation failure. Compression changes only the terminal's + * storage representation and never its logical contents or scrollback limit. + * Accessing compressed history restores it transparently. + * + * This function is not thread-safe with other operations on the same + * terminal. The caller must serialize it with writes, rendering, searches, + * and other terminal access. + * + * @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE) + * @param mode The amount of compression work to perform + * @param[out] out_result Receives the compression scheduling result + * @return GHOSTTY_SUCCESS on success, or GHOSTTY_INVALID_VALUE if an argument + * or mode is invalid + * + * @ingroup terminal + */ +GHOSTTY_API GhosttyResult ghostty_terminal_compress( + GhosttyTerminal terminal, + GhosttyTerminalCompressionMode mode, + GhosttyTerminalCompressionResult* out_result); + /** * Get the current value of a terminal mode. * diff --git a/src/lib_vt.zig b/src/lib_vt.zig index ab13254df..e01cdbb89 100644 --- a/src/lib_vt.zig +++ b/src/lib_vt.zig @@ -261,6 +261,8 @@ comptime { @export(&c.terminal_set, .{ .name = "ghostty_terminal_set" }); @export(&c.terminal_vt_write, .{ .name = "ghostty_terminal_vt_write" }); @export(&c.terminal_scroll_viewport, .{ .name = "ghostty_terminal_scroll_viewport" }); + @export(&c.terminal_compression_activity, .{ .name = "ghostty_terminal_compression_activity" }); + @export(&c.terminal_compress, .{ .name = "ghostty_terminal_compress" }); @export(&c.terminal_mode_get, .{ .name = "ghostty_terminal_mode_get" }); @export(&c.terminal_mode_set, .{ .name = "ghostty_terminal_mode_set" }); @export(&c.terminal_get, .{ .name = "ghostty_terminal_get" }); diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index c160f490e..167654d61 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -4005,9 +4005,9 @@ const compressPage_tw = tripwire.module( /// state. /// /// PageList tracks mutations which require a later incremental pass in its -/// compression state. Full compression always returns `complete`, indicating -/// that it has no continuation to schedule rather than that every page was -/// compressed. +/// compression state. On supported targets, full compression returns +/// `complete`, indicating that it has no continuation to schedule rather than +/// that every page was compressed. pub fn compress( self: *PageList, mode: enum { incremental, drain, full }, @@ -4020,6 +4020,13 @@ pub fn compress( .unsupported => break .unsupported, }, .full => full: { + // Match incremental mode's unsupported result. Full compression + // has no useful work to perform without strict reclamation. + if (!terminal_mem.canReclaim(.strict)) { + self.page_compression.reset(); + break :full .unsupported; + } + self.compressFull(); // Full compression has no continuation. Discard any partial @@ -4107,10 +4114,6 @@ fn compressIncremental(self: *PageList) IncrementalCompressionResult { /// Compress every fully historical resident page which is currently cold. fn compressFull(self: *PageList) void { - // Avoid the codec, scratch allocation, and exact encoded allocation on a - // target where strict retained-mapping reclamation cannot succeed. - if (!terminal_mem.canReclaim(.strict)) return; - var it: CompressionIterator = .init(self); while (it.next()) |node| { diff --git a/src/terminal/Terminal.zig b/src/terminal/Terminal.zig index dfe2a1536..b9363684d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -2314,6 +2314,25 @@ pub fn compressionActivity(self: *const Terminal) u64 { return @as(u64, state.activity_serial); } +/// The amount of compression work performed by `compress` before returning. +/// +/// The declaration order is part of the libghostty-vt C ABI. Removed values +/// must leave a `null` hole so later values retain their integer values. +pub const CompressionMode = lib.Enum(lib.target, &.{ + "incremental", + "full", +}); + +/// The scheduling result of a `compress` call. +/// +/// The declaration order is part of the libghostty-vt C ABI. Removed values +/// must leave a `null` hole so later values retain their integer values. +pub const CompressionResult = lib.Enum(lib.target, &.{ + "unsupported", + "pending", + "complete", +}); + /// Compress cold memory to save resident memory space. /// /// Full compression does a full pass compressing everything it can before @@ -2330,13 +2349,19 @@ pub fn compressionActivity(self: *const Terminal) u64 { /// experience, for example during idle times. pub fn compress( self: *Terminal, - mode: enum { incremental, full }, -) PageList.IncrementalCompressionResult { + mode: CompressionMode, +) CompressionResult { const pages = &self.screens.get(.primary).?.pages; - return switch (mode) { + const result = switch (mode) { .incremental => pages.compress(.incremental), .full => pages.compress(.full), }; + + return switch (result) { + .unsupported => .unsupported, + .pending => .pending, + .complete => .complete, + }; } /// To be called before shifting a row (as in insertLines and deleteLines) diff --git a/src/terminal/c/main.zig b/src/terminal/c/main.zig index ae91565e0..37dc57684 100644 --- a/src/terminal/c/main.zig +++ b/src/terminal/c/main.zig @@ -183,6 +183,8 @@ pub const terminal_resize = terminal.resize; pub const terminal_set = terminal.set; pub const terminal_vt_write = terminal.vt_write; pub const terminal_scroll_viewport = terminal.scroll_viewport; +pub const terminal_compression_activity = terminal.compression_activity; +pub const terminal_compress = terminal.compress; pub const terminal_mode_get = terminal.mode_get; pub const terminal_mode_set = terminal.mode_set; pub const terminal_get = terminal.get; diff --git a/src/terminal/c/terminal.zig b/src/terminal/c/terminal.zig index a2a75147f..439b2e001 100644 --- a/src/terminal/c/terminal.zig +++ b/src/terminal/c/terminal.zig @@ -223,6 +223,12 @@ const Effects = struct { /// C: GhosttyTerminal pub const Terminal = ?*TerminalWrapper; +/// C: GhosttyTerminalCompressionMode +pub const CompressionMode = ZigTerminal.CompressionMode; + +/// C: GhosttyTerminalCompressionResult +pub const CompressionResult = ZigTerminal.CompressionResult; + pub fn zigTerminal(terminal_: Terminal) ?*ZigTerminal { return (terminal_ orelse return null).terminal; } @@ -315,6 +321,30 @@ pub fn vt_write( wrapper.stream.nextSlice(ptr[0..len]); } +pub fn compression_activity( + terminal_: Terminal, + out_activity_: ?*u64, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const out_activity = out_activity_ orelse return .invalid_value; + out_activity.* = t.compressionActivity(); + return .success; +} + +pub fn compress( + terminal_: Terminal, + mode_: c_int, + out_result_: ?*CompressionResult, +) callconv(lib.calling_conv) Result { + const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal; + const out_result = out_result_ orelse return .invalid_value; + const mode = std.meta.intToEnum(CompressionMode, mode_) catch + return .invalid_value; + + out_result.* = t.compress(mode); + return .success; +} + /// C: GhosttyTerminalOption pub const Option = enum(c_int) { userdata = 0, @@ -1099,6 +1129,112 @@ test "scroll_viewport null" { scroll_viewport(null, .{ .tag = .row, .value = .{ .row = 1 } }); } +test "compression invalid arguments" { + var activity: u64 = undefined; + var compression_result: CompressionResult = undefined; + + try testing.expectEqual( + Result.invalid_value, + compression_activity(null, &activity), + ); + try testing.expectEqual( + Result.invalid_value, + compress(null, @intFromEnum(CompressionMode.incremental), &compression_result), + ); + + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000_000, + }, + )); + defer free(t); + + try testing.expectEqual( + Result.invalid_value, + compression_activity(t, null), + ); + try testing.expectEqual( + Result.invalid_value, + compress(t, @intFromEnum(CompressionMode.incremental), null), + ); + try testing.expectEqual( + Result.invalid_value, + compress(t, -1, &compression_result), + ); +} + +test "compression activity and incremental scheduling" { + var t: Terminal = null; + try testing.expectEqual(Result.success, new( + &lib.alloc.test_allocator, + &t, + .{ + .cols = 80, + .rows = 24, + .max_scrollback = 10_000_000, + }, + )); + defer free(t); + + var initial_activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &initial_activity), + ); + + const line = "repeated and compressible terminal history\r\n"; + const repeat = 4_000; + const input = try testing.allocator.alloc(u8, line.len * repeat); + defer testing.allocator.free(input); + for (0..repeat) |i| + @memcpy(input[i * line.len ..][0..line.len], line); + vt_write(t, input.ptr, input.len); + + var activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &activity), + ); + try testing.expect(activity != initial_activity); + + var compression_result: CompressionResult = undefined; + for (0..1_000) |_| { + try testing.expectEqual( + Result.success, + compress( + t, + @intFromEnum(CompressionMode.incremental), + &compression_result, + ), + ); + + switch (compression_result) { + .pending => continue, + .complete => break, + .unsupported => unreachable, + } + } else return error.TestUnexpectedResult; + + // Compression changes storage representation, not the activity token. + var final_activity: u64 = undefined; + try testing.expectEqual( + Result.success, + compression_activity(t, &final_activity), + ); + try testing.expectEqual(activity, final_activity); + + try testing.expectEqual( + Result.success, + compress(t, @intFromEnum(CompressionMode.full), &compression_result), + ); + try testing.expectEqual(CompressionResult.complete, compression_result); +} + test "reset" { var t: Terminal = null; try testing.expectEqual(Result.success, new( From 25e62456914392344bc6d7e3bbd9fb5a3a50fbc5 Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 10:47:31 -0700 Subject: [PATCH 17/18] renderer: avoid starving scrollback compression Inspector rendering can hold the terminal mutex while waking the renderer. When the compression scheduler failed to acquire that mutex, it treated every wake as possible terminal activity and restarted the idle timer. Frequent inspector frames could therefore postpone compression indefinitely until another interaction changed the timing. Keep an existing compression deadline when a wake encounters lock contention. The timer callback already rechecks both terminal activity and lock availability, while the first contended wake still arms a timer when none is active. --- src/renderer/Thread.zig | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/src/renderer/Thread.zig b/src/renderer/Thread.zig index 7f93e9493..e397a55a1 100644 --- a/src/renderer/Thread.zig +++ b/src/renderer/Thread.zig @@ -792,10 +792,17 @@ const Compression = struct { const activity = thread.state.terminal.compressionActivity(); if (self.activity == activity) return; self.activity = activity; + } else if (self.completion.state() == .active) { + // Contention doesn't prove that compression-relevant activity + // changed. Keep an existing deadline so frequent inspector frames + // cannot postpone compression forever. The timer rechecks both the + // activity token and lock availability before doing any work. + return; } // Contention may mean parsing is active. Scheduling is a harmless - // false positive when no compression work is actually pending. + // false positive when no compression work is actually pending, but is + // necessary when no timer is already active. self.schedule(thread, idle_interval); } From 9a4bd2120a56073435c45c5249144817b400019a Mon Sep 17 00:00:00 2001 From: Mitchell Hashimoto Date: Thu, 9 Jul 2026 11:48:46 -0700 Subject: [PATCH 18/18] terminal: optimize LZ4 decoding and add differential tests The block decoder previously copied literals through variable-length memcpy calls and expanded every match with word loops that carried an overcopy fallback in each branch. Real page blocks decode as millions of tiny sequences, so per-sequence overhead dominated restore time. Decode short literal runs and in-token matches with blind fixed-width copies whose margin checks subsume the exact bounds checks they replace. Expand small repeating periods into pattern-word stores, copy distant long matches with one exact memcpy, and propagate the rare non-power-of-two short offsets bytewise. Page corpora restore 13% to 19% faster and text around twice as fast, while compressor output stays byte-for-byte unchanged. Replace the fuzz test with a differential property suite which round-trips generated inputs, validates blocks with an independent format walker, rejects wrong-size outputs, and decodes corrupted and truncated blocks. A light version runs as a normal unit test; the exhaustive version runs when GHOSTTY_LZ4_SLOW is set. An AGENTS.md records the benchmarking, testing, and verification workflow for this directory. --- src/terminal/compress/AGENTS.md | 90 ++++ src/terminal/compress/lz4.zig | 425 +++++++++--------- src/terminal/compress/lz4_differential.zig | 493 +++++++++++++++++++++ 3 files changed, 782 insertions(+), 226 deletions(-) create mode 100644 src/terminal/compress/AGENTS.md create mode 100644 src/terminal/compress/lz4_differential.zig diff --git a/src/terminal/compress/AGENTS.md b/src/terminal/compress/AGENTS.md new file mode 100644 index 000000000..172891d98 --- /dev/null +++ b/src/terminal/compress/AGENTS.md @@ -0,0 +1,90 @@ +# Terminal Compression + +Guidance for the codecs and the compressed page representation +(`Page.zig`) in this directory. These compress terminal page backing +memory (`terminal.Page`). + +## Priorities + +When making tradeoffs, in order: + +1. **Compression ratio on page-shaped data.** Encoded bytes are retained + scrollback memory, and raw `terminal.Page` backing memory is the only + thing we actually compress. Ratio on text files or synthetic data is a + secondary signal. +2. **Decompression throughput.** Pages are compressed once when they go + cold but restored on demand (scrollback access, search, inspection), so + restore latency is felt directly. +3. **Compression throughput.** Runs on idle pages in the background; being + fast is nice, being slow is tolerable. + +## Testing + +- Targeted tests: `zig build test -Dtest-filter=` +- Prefer `zig build test-lib-vt -Dtest-filter=` when practical; + this code ships in libghostty-vt. +- Codecs must keep building for `wasm32-freestanding` (libghostty-vt): + no libc, no `src/simd` (Highway) dependencies. Verify with + `zig build -Demit-lib-vt -Dtarget=wasm32-freestanding -Doptimize=ReleaseSmall`. +- Every codec needs a differential property suite: round-trip identity, + an independent format walker, wrong-size output rejection, and + corruption/truncation decoding. Keep a light version in normal unit + tests and gate the exhaustive version behind an environment variable so + the default test suite stays fast. + +## Verifying Correctness + +- Decoders must be memory-safe for arbitrary input bytes. Every blind or + wide copy needs a stated margin argument bounding it by the output + buffer; keep those arguments in comments next to the code. +- Writing scratch bytes past a copy's logical end is safe only inside the + output buffer, because in-order decoding rewrites them before any match + can read them back. Do not weaken the exact-size output contract. +- When a change should not alter compressor output, prove it: compare + encoded sizes (or a sequence-count fingerprint) on the same corpus + before and after. Ratio drift is a functional change, not noise. + +## Benchmarking + +- Use `ghostty-bench +page-compression` (see `src/benchmark/AGENTS.md` + for the general workflow). Modes: `compress`, `decompress`, `store`, + and `report` for ratio. +- Build: `zig build -Demit-bench -Doptimize=ReleaseFast -Demit-macos-app=false` +- The most representative corpus is a raw dump of real page backing + memory, chunked at the page size (400 KiB on ReleaseFast targets). + Supplement with a text corpus and random bytes for worst cases, but + weigh page corpora highest per the priorities above. Keep corpora + outside the repository and reuse identical files across comparisons. +- `ghostty-bench +scrollback-compression` measures the PageList + transitions around the codec rather than the codec itself. +- For fast iteration, keep codecs dependent only on `std` so a standalone + harness can build them directly with `zig build-exe -O ReleaseFast` and + time the codec in-process (report min-of-N, verify round-trips). +- Measure one change at a time and re-measure the final state; run-to-run + noise is a few percent, so re-run before believing small deltas. + +## Performance Notes + +- Real page data decodes as millions of tiny operations (in LZ4: mostly + zero literals plus a 4-18 byte match). Per-item overhead dominates, so + branch-light fast paths with blind fixed-size copies win. +- Wide copies are the only SIMD that pays here. Vectorized compares and + other wide-stride tricks measured as net losses because matches are + short; prefer the simple word loop unless a measurement on page corpora + says otherwise. +- `@memcpy` beats stride loops only for long copies (roughly 64 bytes and + up); call overhead loses below that. + +## LZ4 Specific + +- The codec is `lz4.zig`, an allocation-free raw block (not frame) + implementation. Blocks do not carry their decoded size; callers supply + an exact-size output buffer. +- Tests: `zig build test -Dtest-filter=lz4`. The differential suite is + `lz4_differential.zig`; run the exhaustive version for any codec + change: + `GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential"` +- The compressor must keep the standard format restrictions (final five + bytes literal, matches start at least twelve bytes before the end) so + blocks stay consumable by optimized external decoders. The differential + walker checks this. diff --git a/src/terminal/compress/lz4.zig b/src/terminal/compress/lz4.zig index 81cd3c57a..a6b07763b 100644 --- a/src/terminal/compress/lz4.zig +++ b/src/terminal/compress/lz4.zig @@ -37,6 +37,8 @@ //! Format reference: //! https://github.com/lz4/lz4/blob/dev/doc/lz4_Block_format.md const std = @import("std"); +const assert = std.debug.assert; +const testing = std.testing; /// Maximum input accepted by the reference LZ4 block API. Keeping the same /// limit means `compressBound` fits in the integer sizes used by LZ4 callers @@ -125,93 +127,101 @@ pub fn compress( // positions to the end of that match. var op: usize = 0; var anchor: usize = 0; - var ip: usize = 0; - var search_attempts: usize = 0; // LZ4's format leaves the final five input bytes as literals and starts // the final match at least twelve bytes before the end. This is not // required by our safe decoder, but makes blocks compatible with fast - // decoders that rely on the standard format restrictions. - const search_end = if (input.len >= match_find_limit) - input.len - match_find_limit - else - 0; - - while (input.len >= match_find_limit and ip <= search_end) { - // Hash the next four bytes and replace the table entry immediately. - // Hash collisions are expected, so equality is checked below before - // accepting the saved position as a match. - const sequence = readU32(input, ip); - const hash = hashSequence(sequence); - const candidates = table[hash]; - rememberPosition(table, hash, ip); - - const match_pos_ = candidatePosition(ip, @truncate(candidates)) orelse - candidatePosition(ip, @truncate(candidates >> 16)); - if (match_pos_ == null) { - advanceSearch(&ip, anchor, &search_attempts); - continue; - } - - var match_pos = match_pos_.?; - - if (readU32(input, match_pos) != sequence) { - const older = candidatePosition( - ip, - @truncate(candidates >> 16), - ); - if (older == null or - readU32(input, older.?) != sequence or - input[older.? + min_match] != input[ip + min_match]) - { - advanceSearch(&ip, anchor, &search_attempts); - continue; - } - match_pos = older.?; - } - - // Pull the match backwards into the current literal run. This is - // particularly helpful around aligned cell records. As with forward - // extension, compare words before locating the first differing byte. - const match_begin = matchBegin(input, ip, match_pos, anchor); - ip = match_begin.position; - match_pos = match_begin.candidate; - - // We already compared the first four bytes. Continue up to the point - // where the required last five literals begin. `matchEnd` compares a - // machine word at a time before locating the first differing byte. + // decoders that rely on the standard format restrictions. Inputs too + // short for any match are emitted below as one literal-only sequence. + if (input.len >= match_find_limit) { + const search_end = input.len - match_find_limit; const match_end_limit = input.len - last_literals; - const match_end = matchEnd( - input, - ip + min_match, - match_pos + min_match, - match_end_limit, - ); + var ip: usize = 0; + var search_attempts: usize = 0; - try emitSequence( - output, - &op, - input[anchor..ip], - @intCast(ip - match_pos), - match_end - ip, - ); + search: while (ip <= search_end) { + // Hash the next four bytes and replace the table entry + // immediately. Hash collisions are expected, so equality is + // checked below before accepting a saved position as a match. + const sequence = readU32(input, ip); + const hash = hashSequence(sequence); + const candidates = table[hash]; + rememberPosition(table, hash, ip); - // The main loop jumps over the matched bytes rather than hashing every - // position within them. Seed one position near the end so an adjacent - // repeated record can still refer back into this match. The next loop - // iteration will then seed `match_end` normally. - if (match_end >= 2 and match_end - 2 + min_match <= input.len) { - const seed = match_end - 2; - rememberPosition( - table, - hashSequence(readU32(input, seed)), - seed, + var match_pos = candidatePosition(ip, @truncate(candidates)) orelse + candidatePosition(ip, @truncate(candidates >> 16)) orelse + { + advanceSearch(&ip, anchor, &search_attempts); + continue :search; + }; + + if (readU32(input, match_pos) != sequence) { + // The nearest candidate collided. Fall back to the older + // one, which must additionally match one byte beyond the + // minimum so collision-prone minimum-length matches from + // the stale half are not emitted. + match_pos = older: { + if (candidatePosition( + ip, + @truncate(candidates >> 16), + )) |older| { + if (readU32(input, older) == sequence and + input[older + min_match] == input[ip + min_match]) + { + break :older older; + } + } + + advanceSearch(&ip, anchor, &search_attempts); + continue :search; + }; + } + + // Pull the match backwards into the current literal run. This is + // particularly helpful around aligned cell records. As with + // forward extension, compare words before locating the first + // differing byte. + const match_begin = matchBegin(input, ip, match_pos, anchor); + ip = match_begin.position; + match_pos = match_begin.candidate; + + // We already compared the first four bytes. Continue up to the + // point where the required last five literals begin. `matchEnd` + // compares a machine word at a time before locating the first + // differing byte. + const match_end = matchEnd( + input, + ip + min_match, + match_pos + min_match, + match_end_limit, ); - } - ip = match_end; - anchor = ip; - search_attempts = 0; + try emitSequence( + output, + &op, + input[anchor..ip], + @intCast(ip - match_pos), + match_end - ip, + ); + + // The main loop jumps over the matched bytes rather than hashing + // every position within them. Seed one position near the end so + // an adjacent repeated record can still refer back into this + // match. The next loop iteration will then seed `match_end` + // normally. + if (match_end >= 2 and match_end - 2 + min_match <= input.len) { + const seed = match_end - 2; + rememberPosition( + table, + hashSequence(readU32(input, seed)), + seed, + ); + } + + ip = match_end; + anchor = ip; + search_attempts = 0; + } } // Whatever remains after the last match is the terminal literal-only @@ -229,6 +239,17 @@ pub fn compress( pub fn decompress(input: []const u8, output: []u8) DecompressError!usize { // `ip` and `op` always identify the next unread input byte and the next // unwritten output byte respectively. + // + // The decoder is written around one observation: almost every sequence + // in real blocks has a short literal run and a short match. Both fast + // paths below copy a fixed number of bytes blindly and let the length + // arithmetic sort out how many of them were meaningful. Writing past a + // run's logical end is safe within the output buffer because decoding + // is strictly in order: every byte past `op` is either rewritten by a + // later copy before anything can read it, or lies beyond the block's + // final length and is never part of the result. The margin conditions + // on the fast paths also subsume the exact bounds checks they replace, + // which keeps decoding of malformed blocks memory-safe. var ip: usize = 0; var op: usize = 0; @@ -245,13 +266,28 @@ pub fn decompress(input: []const u8, output: []u8) DecompressError!usize { ip += 1; // The high nibble and any extension bytes describe the literal run. - // Bounds are checked before slicing so malformed blocks never cause a - // partial read or write. - const literal_len = try decodeLength(input, &ip, token >> 4); - if (literal_len > input.len - ip) return error.TruncatedInput; - if (literal_len > output.len - op) return error.OutputTooSmall; + // The literals are copied as a side effect of computing the length. + const literal_len: usize = len: { + const nibble: usize = token >> 4; + if (nibble != 15 and + @min(input.len - ip, output.len - op) >= 16) + { + // A run below the extension threshold is at most 14 bytes, + // so with a 16-byte margin on both buffers one wide copy + // covers it. + copyIntAt(u128, output, op, input, ip); + break :len nibble; + } - copyLiterals(input, ip, output, op, literal_len); + // Extended or margin-poor runs take the checked path. Bounds are + // verified before copying so malformed blocks never cause a + // partial read or write. + const len = try decodeLength(input, &ip, nibble); + if (len > input.len - ip) return error.TruncatedInput; + if (len > output.len - op) return error.OutputTooSmall; + @memcpy(output[op..][0..len], input[ip..][0..len]); + break :len len; + }; ip += literal_len; op += literal_len; @@ -263,13 +299,29 @@ pub fn decompress(input: []const u8, output: []u8) DecompressError!usize { } if (input.len - ip < 2) return error.TruncatedInput; - const offset = std.mem.readInt(u16, input[ip..][0..2], .little); + const offset = readIntAt(u16, input, ip); ip += 2; if (offset == 0 or offset > op) return error.InvalidOffset; // The token stores the match length minus the four-byte minimum. As // with literals, a low nibble of 15 is extended by following bytes. - const encoded_match_len = try decodeLength(input, &ip, token & 0x0F); + const match_nibble: usize = token & 0x0F; + + // A match whose length fits its nibble spans at most 18 bytes, so + // three blind copies always cover it. They are overlap-safe when the + // offset is at least a word: each load lies a full word behind the + // store which could observe it, so repeating patterns propagate + // correctly. + if (match_nibble != 15 and offset >= 8 and output.len - op >= 18) { + const match = op - offset; + copyIntAt(u64, output, op, output, match); + copyIntAt(u64, output, op + 8, output, match + 8); + copyIntAt(u16, output, op + 16, output, match + 16); + op += match_nibble + min_match; + continue; + } + + const encoded_match_len = try decodeLength(input, &ip, match_nibble); const match_len = std.math.add( usize, encoded_match_len, @@ -278,8 +330,8 @@ pub fn decompress(input: []const u8, output: []u8) DecompressError!usize { if (match_len > output.len - op) return error.OutputTooSmall; // Match copies may overlap, so this cannot always be one memcpy. - // `copyMatch` uses word copies where the offset permits them and - // expands the common one-, two-, and four-byte repeating patterns. + // `copyMatch` expands the common small repeating periods into wide + // stores and uses word copies for larger offsets. copyMatch(output, op, offset, match_len); op += match_len; } @@ -298,8 +350,8 @@ fn emitSequence( offset: u16, match_len: usize, ) CompressError!void { - std.debug.assert(match_len >= min_match); - std.debug.assert(offset > 0); + assert(match_len >= min_match); + assert(offset > 0); const encoded_match_len = match_len - min_match; @@ -325,7 +377,7 @@ fn emitSequence( // Match length extensions follow the offset because this is where the // decoder expects them in an LZ4 sequence. - std.mem.writeInt(u16, output[op.*..][0..2], offset, .little); + writeIntAt(u16, output, op.*, offset); op.* += 2; if (encoded_match_len >= 15) writeLength(output, op, encoded_match_len - 15); @@ -378,7 +430,7 @@ fn writeLength(output: []u8, op: *usize, length_: usize) void { fn decodeLength( input: []const u8, ip: *usize, - nibble: u8, + nibble: usize, ) DecompressError!usize { var length: usize = nibble; if (nibble != 15) return length; @@ -549,129 +601,69 @@ fn matchEnd( return position; } -/// Copy one literal run. Most non-final sequences contain only a handful of -/// literals. When both buffers have eight accessible bytes, one fixed-width -/// copy is cheaper than a variable-size memcpy; bytes beyond the logical run -/// are overwritten immediately by the match. Final literals use the exact -/// copy because the encoded block ends directly after them. -fn copyLiterals( - input: []const u8, - ip: usize, - output: []u8, - op: usize, - literal_len: usize, -) void { - if (literal_len == 0) return; - - if (literal_len <= @sizeOf(u64) and - input.len - ip >= @sizeOf(u64) and - output.len - op >= @sizeOf(u64)) - { - copyIntAt(u64, output, op, input, ip); - return; - } - - @memcpy(output[op..][0..literal_len], input[ip..][0..literal_len]); -} - /// Copy one decoded match from `offset` bytes behind `op`. /// -/// Offsets of at least eight can be copied one word at a time even when the -/// complete match overlaps: every individual load still precedes its store by -/// a full word. One-, two-, and four-byte periods are expanded into a repeated -/// word. Other small offsets retain the required byte-wise propagation. -fn copyMatch(output: []u8, op: usize, offset: usize, match_len: usize) void { - const match_pos = op - offset; - const destination = output[op..][0..match_len]; - const can_overcopy = output.len - op - match_len >= 3; +/// The caller has validated that the match fits: `op + match_len` never +/// exceeds `output.len`. Wide copies may write a few scratch bytes past the +/// match's logical end; as described in `decompress`, that is safe anywhere +/// the write stays inside the output buffer. Every path therefore bounds its +/// wide stores by both the match end and the buffer end, and the bytewise +/// loop at the bottom finishes whatever remains. +fn copyMatch(output: []u8, op_: usize, offset: usize, match_len: usize) void { + var op = op_; + const end = op_ + match_len; - if (offset >= @sizeOf(u64)) { - var copied: usize = 0; - while (match_len - copied >= @sizeOf(u64)) { - copyIntAt( - u64, - output, - op + copied, - output, - match_pos + copied, - ); - copied += @sizeOf(u64); - } - - if (copied < match_len and can_overcopy) { - const remaining = match_len - copied; - if (remaining <= @sizeOf(u32)) { - copyIntAt( - u32, - output, - op + copied, - output, - match_pos + copied, - ); - } else { - copyIntAt( - u64, - output, - op + copied, - output, - match_pos + copied, - ); - } - } else { - while (copied < match_len) : (copied += 1) - destination[copied] = output[match_pos + copied]; - } + // A source which ends behind the copy can never overlap it. Long + // distant matches are common in structured pages (repeated rows and + // whole blank regions), and one exact memcpy moves them in cache-line + // units. Shorter matches are not worth the call overhead. + if (offset >= match_len and match_len >= 64) { + @memcpy( + output[op..][0..match_len], + output[op - offset ..][0..match_len], + ); return; } - if (offset == 1 or offset == 2 or offset == 4) { - const pattern: u64 = switch (offset) { - 1 => @as(u64, output[match_pos]) * 0x0101_0101_0101_0101, - 2 => @as(u64, readIntAt(u16, output, match_pos)) * - 0x0001_0001_0001_0001, - 4 => @as(u64, readIntAt(u32, output, match_pos)) * - 0x0000_0001_0000_0001, - else => unreachable, - }; + switch (offset) { + // A period which divides the word size expands into one repeated + // pattern word. Long runs (blank lines, repeated cells) then become + // independent stores, with no load waiting on a preceding store. + // Stores advance by whole words from `op`, which preserves the + // pattern's phase. + 1, 2, 4, 8 => { + const pattern: u64 = switch (offset) { + 1 => @as(u64, output[op - 1]) * 0x0101_0101_0101_0101, + 2 => @as(u64, readIntAt(u16, output, op - 2)) * + 0x0001_0001_0001_0001, + 4 => @as(u64, readIntAt(u32, output, op - 4)) * + 0x0000_0001_0000_0001, + 8 => readIntAt(u64, output, op - 8), + else => unreachable, + }; - var copied: usize = 0; - while (match_len - copied >= @sizeOf(u64)) { - writeIntAt(u64, output, op + copied, pattern); - copied += @sizeOf(u64); - } + const limit = @min(end, output.len -| 7); + while (op < limit) : (op += 8) + writeIntAt(u64, output, op, pattern); + }, - if (copied < match_len and can_overcopy) { - const remaining = match_len - copied; - if (remaining <= @sizeOf(u32)) { - writeIntAt(u32, output, op + copied, @truncate(pattern)); - } else { - writeIntAt(u64, output, op + copied, pattern); - } - } else { - while (copied < match_len) : (copied += 1) { - destination[copied] = @truncate(pattern >> @intCast( - (copied % @sizeOf(u64)) * 8, - )); - } - } - return; + // Wide copies are overlap-safe for the remaining offsets of at + // least a copy unit: each load lies a full unit behind the store + // which could observe it. Offsets 3, 5, 6, and 7 fall through to + // the bytewise loop; they are rare in real data and word tricks + // for them cost more in complexity than they return. + else => if (offset >= 16) { + const limit = @min(end, output.len -| 15); + while (op < limit) : (op += 16) + copyIntAt(u128, output, op, output, op - offset); + } else if (offset >= 8) { + const limit = @min(end, output.len -| 7); + while (op < limit) : (op += 8) + copyIntAt(u64, output, op, output, op - offset); + }, } - if (offset >= @sizeOf(u32) and can_overcopy) { - var copied: usize = 0; - while (copied < match_len) : (copied += @sizeOf(u32)) { - copyIntAt( - u32, - output, - op + copied, - output, - match_pos + copied, - ); - } - return; - } - - for (0..match_len) |i| destination[i] = output[match_pos + i]; + while (op < end) : (op += 1) output[op] = output[op - offset]; } /// Map a four-byte input sequence to its scratch-table slot. @@ -681,7 +673,6 @@ inline fn hashSequence(sequence: u32) usize { /// Shared round-trip assertion used by the corpus-style tests below. fn expectRoundTrip(input: []const u8) !void { - const testing = std.testing; const bound = try compressBound(input.len); const encoded = try testing.allocator.alloc(u8, bound); defer testing.allocator.free(encoded); @@ -698,15 +689,12 @@ fn expectRoundTrip(input: []const u8) !void { } test "compressBound" { - const testing = std.testing; try testing.expectEqual(@as(usize, 16), try compressBound(0)); try testing.expectEqual(@as(usize, 272), try compressBound(255)); try testing.expectError(error.InputTooLarge, compressBound(max_input_size + 1)); } test "literal-only compatibility vectors" { - const testing = std.testing; - var empty: [0]u8 = .{}; try testing.expectEqual(@as(usize, 0), try decompress(&.{0}, &empty)); @@ -727,7 +715,6 @@ test "literal-only compatibility vectors" { } test "overlapping match compatibility vector" { - const testing = std.testing; // One literal 'a', followed by a four-byte match at distance one. var output: [5]u8 = undefined; try testing.expectEqual(@as(usize, 5), try decompress( @@ -738,7 +725,6 @@ test "overlapping match compatibility vector" { } test "extended overlapping match compatibility vector" { - const testing = std.testing; // One literal followed by a 274-byte match. The match extension is // encoded as 255 + 0 after the low token nibble's initial 15 bytes. var output: [275]u8 = undefined; @@ -750,7 +736,6 @@ test "extended overlapping match compatibility vector" { } test "short offset compatibility vectors" { - const testing = std.testing; // These blocks end immediately after their match. Besides covering the // repeating-pattern paths, they verify that the decoder uses exact copies @@ -775,7 +760,6 @@ test "short offset compatibility vectors" { } test "bounded wild copies are overwritten by final literals" { - const testing = std.testing; // The first sequence's nine-byte match leaves the five final literals // required by the LZ4 block format. Its logical one-byte tail is copied as @@ -807,7 +791,6 @@ test "bounded wild copies are overwritten by final literals" { } test "maximum match offset compatibility vector" { - const testing = std.testing; const literal_len = std.math.maxInt(u16); const extension_len = (literal_len - 15) / 255 + 1; const encoded = try testing.allocator.alloc( @@ -834,7 +817,6 @@ test "maximum match offset compatibility vector" { } test "round trips boundary-sized inputs" { - const testing = std.testing; const lengths = [_]usize{ 0, 1, 3, 4, 5, 12, 15, 16, 19, 20, 254, 255, 256, 269, 270, 271, 510, 511, @@ -850,7 +832,6 @@ test "round trips boundary-sized inputs" { } test "round trips compressible page-sized inputs" { - const testing = std.testing; const page_len = 400 * 1024; const zeros = try testing.allocator.alloc(u8, page_len); @@ -869,7 +850,6 @@ test "round trips compressible page-sized inputs" { } test "round trips deterministic random inputs" { - const testing = std.testing; var prng = std.Random.DefaultPrng.init(0x4C5A_3401); const random = prng.random(); @@ -883,7 +863,6 @@ test "round trips deterministic random inputs" { } test "compress reports short output" { - const testing = std.testing; const input = "a terminal page needs enough output space"; var table: HashTable = undefined; var output: [4]u8 = undefined; @@ -894,7 +873,6 @@ test "compress reports short output" { } test "decompress rejects malformed blocks" { - const testing = std.testing; var output: [32]u8 = undefined; try testing.expectError(error.TruncatedInput, decompress(&.{0xF0}, &output)); @@ -908,11 +886,6 @@ test "decompress rejects malformed blocks" { try testing.expectError(error.OutputSizeMismatch, decompress(&.{0}, output[0..1])); } -test "fuzz decompressor safety" { - return std.testing.fuzz({}, fuzzDecompress, .{}); -} - -fn fuzzDecompress(_: void, input: []const u8) !void { - var output: [4096]u8 = undefined; - _ = decompress(input, &output) catch {}; +test { + _ = @import("lz4_differential.zig"); } diff --git a/src/terminal/compress/lz4_differential.zig b/src/terminal/compress/lz4_differential.zig new file mode 100644 index 000000000..bbf3b2765 --- /dev/null +++ b/src/terminal/compress/lz4_differential.zig @@ -0,0 +1,493 @@ +//! Differential and property tests for the LZ4 block codec. +//! +//! Every generated input must compress into a block which: +//! +//! 1. fits within `compressBound`, +//! 2. is structurally valid LZ4 with the stricter guarantees our +//! compressor documents (final five bytes are literals, matches start +//! at least twelve bytes before the end), verified by an independent +//! walker which shares no code with the codec, +//! 3. decompresses to exactly the original bytes, and +//! 4. is rejected when decompressed into a buffer of the wrong size. +//! +//! Valid blocks are additionally mutated (bit flips, splices, truncations) +//! and fed to the decompressor, which must fail cleanly or succeed, but +//! never read or write out of bounds. The unit-test build enables runtime +//! safety, so any out-of-bounds slice access fails the test. +//! +//! The light suite below runs as a normal unit test and finishes quickly. +//! The exhaustive suite multiplies the same properties across far more +//! sizes, periods, seeds, and mutations; it is slow and therefore skipped +//! unless the environment variable `GHOSTTY_LZ4_SLOW` is set: +//! +//! GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential" +const std = @import("std"); +const testing = std.testing; +const Allocator = std.mem.Allocator; +const lz4 = @import("lz4.zig"); + +/// Number of trailing block bytes which must be literals, mirroring the +/// documented guarantee of the compressor. Kept as an independent constant +/// so a codec regression cannot silently weaken the check. +const last_literals = 5; + +/// A match may not begin in the final twelve bytes. See `last_literals`. +const match_find_limit = 12; + +/// Sizes around every encoding boundary: token nibble limits (14/15), +/// minimum match and find limits (4/12), length-extension steps (15 + 255k), +/// and power-of-two neighborhoods. +const boundary_sizes = [_]usize{ + 0, 1, 2, 3, 4, 5, 6, 7, + 8, 9, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 20, 31, 32, 33, 63, + 64, 65, 254, 255, 256, 269, 270, 271, + 272, 1023, 1024, 4095, 4096, 4097, +}; + +/// Input generators exercising distinct codec behaviors. Every generator is +/// deterministic for a given random state. +const Generator = enum { + /// Uniform random bytes; largely incompressible. + random_bytes, + + /// Runs of one repeated byte with random lengths; period-one matches. + runs, + + /// One repeating pattern; exercises a fixed match period end to end. + periodic, + + /// Eight-byte records with random small payloads and zero padding, with + /// some records repeated; resembles terminal cell memory. + cells, + + /// Dictionary words with separators; text-like literal/match mix. + words, + + /// Mostly zeros with scattered random bytes; long matches with isolated + /// literals. + sparse, + + /// Random segments of all other generators; exercises transitions. + mixed, + + fn fill(gen: Generator, random: std.Random, buf: []u8) void { + switch (gen) { + .random_bytes => random.bytes(buf), + + .runs => { + var i: usize = 0; + while (i < buf.len) { + const run = @min( + random.intRangeAtMost(usize, 1, 300), + buf.len - i, + ); + @memset(buf[i..][0..run], random.int(u8)); + i += run; + } + }, + + .periodic => fillPeriodic( + random, + buf, + random.intRangeAtMost(usize, 1, 40), + ), + + .cells => { + var i: usize = 0; + while (i + 8 <= buf.len) : (i += 8) { + const cell = buf[i..][0..8]; + if (i >= 8 and random.boolean()) { + // Repeat one of the last 256 records. + const back = 8 * random.intRangeAtMost( + usize, + 1, + @min(i / 8, 256), + ); + cell.* = buf[i - back ..][0..8].*; + } else { + @memset(cell, 0); + cell[0] = ' ' + random.uintLessThan(u8, 95); + cell[1] = random.uintLessThan(u8, 4); + } + } + random.bytes(buf[i..]); + }, + + .words => { + const words = [_][]const u8{ + "the", "terminal", "page", "compress", + "row", "cell", "style", "zig", + "lz4", "block", "offset", "match", + "a", "of", "and", "literal", + "0x00", "0xFF", " ", "\r\n", + "-----", "=", "pub fn", "const", + }; + var i: usize = 0; + while (i < buf.len) { + const word = words[random.uintLessThan(usize, words.len)]; + const n = @min(word.len, buf.len - i); + @memcpy(buf[i..][0..n], word[0..n]); + i += n; + if (i < buf.len) { + buf[i] = if (random.boolean()) ' ' else '\n'; + i += 1; + } + } + }, + + .sparse => { + @memset(buf, 0); + if (buf.len == 0) return; + for (0..buf.len / 32 + 1) |_| { + const at = random.uintLessThan(usize, buf.len); + buf[at] = random.int(u8); + } + }, + + .mixed => { + var i: usize = 0; + while (i < buf.len) { + const segment = @min( + random.intRangeAtMost(usize, 1, 2048), + buf.len - i, + ); + const sub = random.enumValue(Generator); + if (sub != .mixed) sub.fill(random, buf[i..][0..segment]); + i += segment; + } + }, + } + } +}; + +/// Fill `buf` with one repeating pattern of the given period. +fn fillPeriodic(random: std.Random, buf: []u8, period: usize) void { + if (buf.len == 0) return; + const head = @min(period, buf.len); + random.bytes(buf[0..head]); + for (head..buf.len) |i| buf[i] = buf[i - period]; +} + +/// Reusable buffers sized for the largest input a suite generates. +const Workspace = struct { + input: []u8, + encoded: []u8, + decoded: []u8, + table: *lz4.HashTable, + + fn init(alloc: Allocator, max_input: usize) !Workspace { + const input = try alloc.alloc(u8, max_input); + errdefer alloc.free(input); + const encoded = try alloc.alloc(u8, try lz4.compressBound(max_input)); + errdefer alloc.free(encoded); + // One extra byte so wrong-size decompression can be tested above + // the exact length as well as below it. + const decoded = try alloc.alloc(u8, max_input + 1); + errdefer alloc.free(decoded); + const table = try alloc.create(lz4.HashTable); + return .{ + .input = input, + .encoded = encoded, + .decoded = decoded, + .table = table, + }; + } + + fn deinit(ws: *Workspace, alloc: Allocator) void { + alloc.free(ws.input); + alloc.free(ws.encoded); + alloc.free(ws.decoded); + alloc.destroy(ws.table); + ws.* = undefined; + } +}; + +/// Compress one input and verify every property promised by the codec. +/// Returns the encoded length so callers can reuse the encoded block. +fn expectCodecProperties(ws: *Workspace, input: []const u8) !usize { + const encoded_len = try lz4.compress(input, ws.encoded, ws.table); + try testing.expect(encoded_len <= try lz4.compressBound(input.len)); + const encoded = ws.encoded[0..encoded_len]; + + try expectValidBlock(encoded, input.len); + + // Exact-size decompression must reproduce the input bit for bit. The + // output is poisoned first so unwritten bytes cannot pass as correct. + const output = ws.decoded[0..input.len]; + @memset(output, 0xAA); + try testing.expectEqual(input.len, try lz4.decompress(encoded, output)); + try testing.expectEqualSlices(u8, input, output); + + // The exact-size contract must reject both smaller and larger buffers. + if (input.len > 0) { + try testing.expectError( + error.OutputTooSmall, + lz4.decompress(encoded, ws.decoded[0 .. input.len - 1]), + ); + } + try testing.expectError( + error.OutputSizeMismatch, + lz4.decompress(encoded, ws.decoded[0 .. input.len + 1]), + ); + + return encoded_len; +} + +/// Structurally validate one encoded block against the LZ4 block format and +/// the stricter guarantees documented by our compressor. This deliberately +/// reimplements the format rather than reusing codec internals. +fn expectValidBlock(encoded: []const u8, raw_len: usize) !void { + var ip: usize = 0; + var op: usize = 0; + var last_match_end: usize = 0; + + while (true) { + // Every sequence, including the final one, starts with a token. + try testing.expect(ip < encoded.len); + const token = encoded[ip]; + ip += 1; + + var literal_len: usize = token >> 4; + if (literal_len == 15) literal_len += try readExtension(encoded, &ip); + try testing.expect(encoded.len - ip >= literal_len); + ip += literal_len; + op += literal_len; + + // The final sequence contains only literals and ends the block. + if (ip == encoded.len) { + try testing.expectEqual(raw_len, op); + if (last_match_end > 0) + try testing.expect(raw_len - last_match_end >= last_literals); + return; + } + + try testing.expect(encoded.len - ip >= 2); + const offset = std.mem.readInt(u16, encoded[ip..][0..2], .little); + ip += 2; + try testing.expect(offset >= 1); + try testing.expect(offset <= op); + + // Our compressor starts matches at least `match_find_limit` bytes + // before the end and never lets one run into the final literals. + try testing.expect(op + match_find_limit <= raw_len); + var match_len: usize = (token & 0x0F) + 4; + if (token & 0x0F == 15) match_len += try readExtension(encoded, &ip); + op += match_len; + try testing.expect(op + last_literals <= raw_len); + last_match_end = op; + } +} + +/// Read one length extension: bytes of 255 accumulate until a terminator +/// below 255, which is included in the sum. +fn readExtension(encoded: []const u8, ip: *usize) !usize { + var total: usize = 0; + while (true) { + try testing.expect(ip.* < encoded.len); + const value = encoded[ip.*]; + ip.* += 1; + total += value; + if (value != 255) return total; + } +} + +/// Decode deterministic corruptions of a valid block. Any result is +/// acceptable except memory unsafety, which the safety-checked test build +/// turns into a failure. Mutations may still form a valid block, so output +/// contents are intentionally not asserted. +fn expectMutationSafety( + ws: *Workspace, + random: std.Random, + encoded_len: usize, + raw_len: usize, + mutations: usize, +) !void { + const original = try testing.allocator.dupe(u8, ws.encoded[0..encoded_len]); + defer testing.allocator.free(original); + + for (0..mutations) |_| { + const block = ws.encoded[0..encoded_len]; + @memcpy(block, original); + + switch (random.uintLessThan(u8, 4)) { + // Flip up to eight random bits. + 0 => for (0..random.intRangeAtMost(usize, 1, 8)) |_| { + const at = random.uintLessThan(usize, block.len); + block[at] ^= @as(u8, 1) << random.int(u3); + }, + // Overwrite one random byte. Token and length bytes are the + // most interesting targets and small blocks are mostly tokens. + 1 => block[random.uintLessThan(usize, block.len)] = + random.int(u8), + // Splice random garbage over a random span. + 2 => { + const at = random.uintLessThan(usize, block.len); + const span = @min( + random.intRangeAtMost(usize, 1, 16), + block.len - at, + ); + random.bytes(block[at..][0..span]); + }, + // Decode a random prefix of the intact block. + else => {}, + } + + const len = if (random.boolean()) + random.uintAtMost(usize, block.len) + else + block.len; + _ = lz4.decompress(block[0..len], ws.decoded[0..raw_len]) catch {}; + } +} + +/// Workload knobs shared by the light and exhaustive suites. +const Budget = struct { + /// Upper bound and step of the contiguous small-size sweep applied to + /// every generator. + sweep_max: usize, + sweep_step: usize, + + /// Number and maximum size of random-parameter inputs. + random_inputs: usize, + random_max: usize, + + /// Explicit match periods checked with `fillPeriodic`. + periods: []const usize, + period_len: usize, + + /// Number of corrupted decode attempts per mutation base block. + mutations: usize, + + fn maxInput(budget: Budget) usize { + return @max( + budget.random_max, + @max(budget.period_len, boundary_sizes[boundary_sizes.len - 1]), + ); + } +}; + +const light_budget: Budget = .{ + .sweep_max = 96, + .sweep_step = 1, + .random_inputs = 24, + .random_max = 32 * 1024, + // One period per copy strategy: byte propagation (3), pattern words + // (1/2/4/8), word strides (9), wide strides (17), plus the 64 KiB + // window edge cases. + .periods = &.{ 1, 2, 3, 4, 8, 9, 17, 65534, 65535, 65536, 65537 }, + .period_len = 160 * 1024, + .mutations = 64, +}; + +const exhaustive_budget: Budget = .{ + .sweep_max = 2048, + .sweep_step = 1, + .random_inputs = 512, + .random_max = 512 * 1024, + .periods = &.{ + 1, 2, 3, 4, 5, 6, 7, 8, + 9, 10, 11, 12, 13, 14, 15, 16, + 17, 18, 19, 23, 24, 31, 32, 33, + 48, 63, 64, 65, 127, 128, 255, 256, + 257, 4095, 4096, 32768, 65533, 65534, 65535, 65536, + 65537, 65538, + }, + .period_len = 320 * 1024, + .mutations = 4096, +}; + +fn runSuite(budget: Budget, seed: u64) !void { + var prng = std.Random.DefaultPrng.init(seed); + const random = prng.random(); + + var ws: Workspace = try .init(testing.allocator, budget.maxInput()); + defer ws.deinit(testing.allocator); + + // Every generator across every boundary size and the small-size sweep. + // Small inputs hit the literal-only format edges: below the minimum + // match sizes, around nibble limits, and around extension steps. + inline for (@typeInfo(Generator).@"enum".fields) |field| { + const gen: Generator = @enumFromInt(field.value); + + for (boundary_sizes) |size| { + const input = ws.input[0..size]; + gen.fill(random, input); + _ = try expectCodecProperties(&ws, input); + } + + var size: usize = 0; + while (size <= budget.sweep_max) : (size += budget.sweep_step) { + const input = ws.input[0..size]; + gen.fill(random, input); + _ = try expectCodecProperties(&ws, input); + } + } + + // Random generator and size pairs, biased toward interesting sizes by + // squaring so both tiny and large inputs appear. + for (0..budget.random_inputs) |_| { + const gen = random.enumValue(Generator); + const scale = random.float(f64); + const size: usize = @intFromFloat( + scale * scale * @as(f64, @floatFromInt(budget.random_max)), + ); + const input = ws.input[0..size]; + gen.fill(random, input); + const encoded_len = try expectCodecProperties(&ws, input); + + // Reuse a handful of these encodings as corruption bases. + try expectMutationSafety( + &ws, + random, + encoded_len, + input.len, + budget.mutations / budget.random_inputs + 1, + ); + } + + // Directed match periods, including the 64 KiB window edge where the + // compressor's 16-bit position arithmetic wraps. + for (budget.periods) |period| { + const input = ws.input[0..budget.period_len]; + fillPeriodic(random, input, period); + _ = try expectCodecProperties(&ws, input); + } + + // Dedicated corruption run over a text-like block, plus an exhaustive + // truncation sweep: every prefix of a valid block must decode cleanly + // or fail cleanly. + { + const input = ws.input[0..@min(16 * 1024, budget.random_max)]; + Generator.words.fill(random, input); + const encoded_len = try expectCodecProperties(&ws, input); + try expectMutationSafety( + &ws, + random, + encoded_len, + input.len, + budget.mutations, + ); + + for (0..encoded_len) |prefix| { + _ = lz4.decompress( + ws.encoded[0..prefix], + ws.decoded[0..input.len], + ) catch {}; + } + } +} + +test "lz4 differential light" { + try runSuite(light_budget, 0x4C5A_3403); +} + +test "lz4 differential exhaustive" { + // Slow. Enable explicitly, ideally together with a test filter: + // GHOSTTY_LZ4_SLOW=1 zig build test -Dtest-filter="lz4 differential" + if (!std.process.hasEnvVarConstant("GHOSTTY_LZ4_SLOW")) + return error.SkipZigTest; + + // Several independent seeds; the suite is deterministic per seed. + for (0..4) |seed| try runSuite(exhaustive_budget, 0x4C5A_4000 + seed); +}