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/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/benchmark/PageCompression.zig b/src/benchmark/PageCompression.zig new file mode 100644 index 000000000..21153659e --- /dev/null +++ b/src/benchmark/PageCompression.zig @@ -0,0 +1,519 @@ +//! 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 or measure the scheduling and state transitions used by automatic +//! scrollback compression. +//! +//! ## 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. +//! * `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 +//! 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. `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. +//! +//! ## 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 +//! +//! 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=store --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 compress = @import("../terminal/compress.zig"); +const CompressedPage = compress.Page; +const lz4 = compress.lz4; + +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, + +/// 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 = &.{}, + +/// 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, + + /// 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. + 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, + + /// Compress and retain exact-sized encoded blocks in a bounded ring. + store, + + /// 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, + .store => stepStore, + .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; + 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(); + + 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 == .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 { + 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; + + 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 = &.{}; + + 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); + } + } +} + +/// 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 { + 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); +} + +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/benchmark/ScrollbackCompression.zig b/src/benchmark/ScrollbackCompression.zig new file mode 100644 index 000000000..40ec20f48 --- /dev/null +++ b/src/benchmark/ScrollbackCompression.zig @@ -0,0 +1,343 @@ +//! 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 `compress` invocation. +//! * `incremental` reaches the same final representation through +//! `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, +//! 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 compressed page count, +//! 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. 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 +//! +//! 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=incremental --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, + +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, + + /// Compress every eligible page through bounded resumable steps. + incremental, + + /// 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, + .incremental => stepIncremental, + .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.loadCorpus() catch |err| { + log.warn("failed to prepare scrollback compression benchmark err={}", .{err}); + return error.BenchmarkFailed; + }; + + if (self.opts.mode == .restore) { + _ = self.pages().compress(.full); + } +} + +/// 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.pages().compress(.full); + std.mem.doNotOptimizeAway(&self.terminal); +} + +fn stepIncremental(ptr: *anyopaque) Benchmark.Error!void { + const self: *ScrollbackCompression = @ptrCast(@alignCast(ptr)); + _ = self.pages().compress(.drain); + std.mem.doNotOptimizeAway(&self.terminal); +} + +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.pages().compress(.full); + const memory = self.pages().memoryStats(); + + std.debug.print( + "scrollback-compression compressed={d} raw={d} " ++ + "encoded={d} ratio={d:.2}% savings={d}\n", + .{ + memory.compressed_pages, + memory.decommitted_raw_bytes, + memory.encoded_bytes, + percentage( + memory.encoded_bytes, + memory.decommitted_raw_bytes, + ), + memory.estimatedSavings(), + }, + ); +} + +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.pages().compress(.full); + 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. + _ = impl.pages().compress(.full); + 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.pages().compress(.drain); + 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(.full); + const monolithic = impl.pages().memoryStats(); + try testing.expectEqual(monolithic, incremental); +} diff --git a/src/benchmark/cli.zig b/src/benchmark/cli.zig index 13f070774..b48a35b4b 100644 --- a/src/benchmark/cli.zig +++ b/src/benchmark/cli.zig @@ -8,6 +8,8 @@ const cli = @import("../cli.zig"); pub const Action = enum { @"codepoint-width", @"grapheme-break", + @"page-compression", + @"scrollback-compression", @"screen-clone", @"terminal-parser", @"terminal-stream", @@ -25,6 +27,8 @@ pub const Action = enum { pub fn Struct(comptime action: Action) type { 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 5673044f2..61404c265 100644 --- a/src/benchmark/main.zig +++ b/src/benchmark/main.zig @@ -7,6 +7,8 @@ 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"); +pub const ScrollbackCompression = @import("ScrollbackCompression.zig"); test { @import("std").testing.refAllDecls(@This()); diff --git a/src/config/Config.zig b/src/config/Config.zig index fa491e49c..d6a038d9d 100644 --- a/src/config/Config.zig +++ b/src/config/Config.zig @@ -1373,10 +1373,17 @@ 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 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 +/// 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. /// @@ -1384,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/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/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 bedbfb599..7e0012896 100644 --- a/src/inspector/widgets/pagelist.zig +++ b/src/inspector/widgets/pagelist.zig @@ -1,12 +1,23 @@ 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 = .{}; @@ -25,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, @@ -58,8 +76,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.data; - 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,14 +89,29 @@ 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(); - 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()); } } } @@ -106,7 +141,7 @@ 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()); cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -114,34 +149,21 @@ 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), - ); - - 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( - "%d 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); @@ -150,14 +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); +} +fn compressionTable(pages: *const PageList) void { + const memory = pages.memoryStats(); + + 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("Tracked Pins"); + cimgui.c.ImGui_Text("%s", label.ptr); _ = cimgui.c.ImGui_TableSetColumnIndex(1); - widgets.helpMarker("Number of pins tracked for automatic updates."); + widgets.helpMarker(help); _ = cimgui.c.ImGui_TableSetColumnIndex(2); - cimgui.c.ImGui_Text("%d", pages.countTrackedPins()); + 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 { @@ -321,11 +471,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); @@ -540,17 +697,37 @@ 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 style = cell.node.data.styles.get( - cell.node.data.memory, + const style = page.styles.get( + page.memory, cell.cell.style_id, ).*; widgets.style.table(style, null); @@ -558,12 +735,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); } } }; @@ -577,7 +754,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, @@ -585,7 +762,6 @@ fn hyperlinkTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = &cell.node.data; const link_id = page.lookupHyperlink(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -632,7 +808,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, @@ -640,7 +816,6 @@ fn graphemeTable(cell: PageList.Cell) void { )) return; defer cimgui.c.ImGui_EndTable(); - const page = &cell.node.data; const cps = page.lookupGrapheme(cell.cell) orelse { cimgui.c.ImGui_TableNextRow(); _ = cimgui.c.ImGui_TableSetColumnIndex(0); @@ -680,8 +855,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", @@ -747,7 +923,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 (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 +1021,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 = page.lookupHyperlink(cell.cell) orelse 0; cimgui.c.ImGui_Text("id=%d", link_id); } } 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/renderer/Thread.zig b/src/renderer/Thread.zig index 488642199..e397a55a1 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"); @@ -72,6 +73,9 @@ cursor_h: xev.Timer, cursor_c: xev.Completion = .{}, cursor_c_cancel: xev.Completion = .{}, +/// Incremental scrollback compression scheduling. +compression: Compression = undefined, + /// The surface we're rendering to. surface: *apprt.Surface, @@ -111,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", }; } }; @@ -162,7 +168,7 @@ pub fn init( var mailbox = try Mailbox.create(alloc); errdefer mailbox.destroy(alloc); - return .{ + var result: Thread = .{ .alloc = alloc, .config = .init(config), .loop = loop, @@ -178,6 +184,14 @@ pub fn init( .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 @@ -189,6 +203,8 @@ pub fn deinit(self: *Thread) void { self.draw_h.deinit(); self.draw_now.deinit(); self.cursor_h.deinit(); + if (comptime terminalpkg.compression_enabled) + self.compression.deinit(); self.loop.deinit(); // Nothing can possibly access the mailbox anymore, destroy it. @@ -492,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.*; } @@ -537,6 +563,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 +753,113 @@ fn cursorBlinkInterval() u64 { return CURSOR_BLINK_INTERVAL; } + +/// 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; + + timer: xev.Timer, + completion: xev.Completion = .{}, + reset_completion: xev.Completion = .{}, + activity: ?u64 = null, + + 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 { + // 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 + // 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; + } 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, but is + // necessary when no timer is already active. + 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 (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, 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(); + + 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, + .complete, + => null, + }; + } +}; diff --git a/src/terminal/PageList.zig b/src/terminal/PageList.zig index 338aad4c0..167654d61 100644 --- a/src/terminal/PageList.zig +++ b/src/terminal/PageList.zig @@ -12,8 +12,10 @@ const fastmem = @import("../fastmem.zig"); const tripwire = @import("../tripwire.zig"); const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList; const color = @import("color.zig"); +const compression = @import("compress.zig"); const highlight = @import("highlight.zig"); const kitty = @import("kitty.zig"); +const terminal_mem = @import("mem.zig"); const point = @import("point.zig"); const pagepkg = @import("page.zig"); const stylepkg = @import("style.zig"); @@ -44,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 @@ -61,7 +63,234 @@ 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: compression.Page, + }; + const Owned = enum { pool, heap }; + + /// The backing-memory representation currently stored by this node. + pub const Storage = enum { resident, compressed }; + + /// Return the terminal page stored in this node. + /// + /// WARNING: This will DECOMPRESS compressed pages! Only use this if + /// you need access to the underlying memory. If you only need access to + /// metadata (row, col counts etc) then use the other metadata functions. + pub inline fn page(self: *Node) *Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => self.restore(.preserve), + }; + } + + /// Return the terminal page only when its raw memory is resident. + /// + /// Unlike `page`, this never restores a compressed node. This is useful + /// for diagnostics which can display node metadata without touching the + /// discarded mapping, but may inspect page contents when they are already + /// available. + pub inline fn pageIfResident(self: *Node) ?*Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => null, + }; + } + + /// Read-only page with full memory access that doesn't change + /// this node's storage state: if a node is compressed it remains + /// compressed. + /// + /// Resident pages are borrowed directly. So they're basically free. + /// + /// Compressed pages are decoded into an independently owned Page + /// by the caller so callers can inspect their contents. Because resident + /// pages are still borrowed, you have to continue to have exclusive + /// access to the PageList during this operation. + pub const PreservedPage = union(enum) { + borrowed: *const Page, + owned: struct { + page: Page, + alloc: Allocator, + }, + + /// Return the read-only page represented by this value. + pub fn page(self: *const PreservedPage) *const Page { + return switch (self.*) { + .borrowed => |page_| page_, + .owned => |*owned| &owned.page, + }; + } + + /// Release storage owned by this preserved page. + pub fn deinit(self: *PreservedPage) void { + switch (self.*) { + .borrowed => {}, + // The clone buffer came from the caller's allocator rather + // than Page's OS allocator, so it must not use Page.deinit. + .owned => |*owned| owned.alloc.free(owned.page.memory), + } + self.* = undefined; + } + }; + + /// Return read-only page contents without changing this node's storage. + /// + /// `alloc` is unused for a resident node. A compressed node uses it for an + /// exact-sized, page-aligned decode buffer owned by the returned value. + /// + /// The caller must call `PreservedPage.deinit` when finished. See + /// `PreservedPage` for the synchronization required by its borrowed + /// resident representation. + pub fn pagePreservingState( + self: *const Node, + alloc: Allocator, + ) Allocator.Error!PreservedPage { + return switch (self.data) { + .resident => |*page_| .{ .borrowed = page_ }, + .compressed => |*compressed| owned: { + const memory = try alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + compressed.page.memory.len, + ); + + const page_ = compressed.cloneBuf(memory) catch |err| { + // The encoded data was produced by our codec and remains + // immutable while compressed. Failure is internal + // corruption, matching the normal restoration boundary. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + alloc.free(memory); + log.err("failed to clone compressed page err={}", .{err}); + @panic("failed to clone compressed terminal page"); + }; + + break :owned .{ .owned = .{ + .page = page_, + .alloc = alloc, + } }; + }, + }; + } + + /// Return the node's backing-memory representation without restoring it. + pub inline fn storage(self: *const Node) Storage { + return switch (self.data) { + .resident => .resident, + .compressed => .compressed, + }; + } + + /// Return a page which the caller knows is already resident. + /// + /// This avoids the representation check in hot paths which already hold + /// live pointers into the page mapping. Such pointers are only valid while + /// the page is resident. Prefer `page` unless the caller can establish + /// that invariant independently. + pub inline fn pageAssumeResident(self: *Node) *Page { + return &self.data.resident; + } + + /// Return the number of populated rows without accessing page memory. + pub inline fn rows(self: *const Node) size.CellCountInt { + return self.metadata().size.rows; + } + + /// Return the current column count without accessing page memory. + pub inline fn cols(self: *const Node) size.CellCountInt { + return self.metadata().size.cols; + } + + /// Return the page capacity without accessing page memory. + pub inline fn capacity(self: *const Node) Capacity { + return self.metadata().capacity; + } + + /// Return the embedded Page metadata without restoring its memory. + inline fn metadata(self: *const Node) *const Page { + return switch (self.data) { + .resident => |*page_| page_, + .compressed => |*page_| &page_.page, + }; + } + + const RestoreMode = enum { + /// Decode the compressed representation back into the raw mapping. + preserve, + + /// Discard the compressed representation without decoding it. The + /// caller must not read the page contents before overwriting them. + discard, + }; + + /// Restore this node to the resident representation. + /// + /// Preserve mode reconstructs the page contents and is used by `page`. + /// Discard mode skips decoding for callers which will overwrite or destroy + /// the page. Both modes recommit the retained mapping, free the encoded + /// allocation, and leave the node in a valid resident state. + noinline fn restore(self: *Node, comptime mode: RestoreMode) *Page { + const compressed = switch (self.data) { + .resident => |*page_| return page_, + .compressed => |*page_| page_, + }; + + // Decommit only discarded the physical pages. Recommit prepares the + // still-reserved mapping for decoding or reuse by the caller. + terminal_mem.recommit(compressed.page.memory); + + const restored = switch (mode) { + .preserve => compressed.restore() catch |err| { + // Compressed nodes retain the immutable encoding and exact + // output mapping used to create them. Any decode error is an + // internal codec bug or memory corruption. Keep this switch + // exhaustive so new decoder errors require classification. + switch (err) { + error.TruncatedInput, + error.InvalidOffset, + error.OutputTooSmall, + error.OutputSizeMismatch, + => {}, + } + + log.err("failed to restore compressed page err={}", .{err}); + @panic("failed to restore compressed terminal page"); + }, + + .discard => compressed.page, + }; + + // Remove our compressed data + compressed.deinit(); + + // We're now a resident, non-compressed node. + self.data = .{ .resident = restored }; + return &self.data.resident; + } + + inline fn isCompressed(self: *const Node) bool { + return switch (self.data) { + .resident => false, + .compressed => true, + }; + } }; /// The memory pool we get page nodes from. @@ -154,11 +383,16 @@ 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, +/// 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. @@ -467,7 +701,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), } } } @@ -481,7 +715,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); @@ -507,12 +741,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); @@ -573,7 +807,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 @@ -608,7 +842,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; @@ -657,9 +891,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), } } @@ -678,6 +913,9 @@ pub fn deinit(self: *PageList) void { pub fn reset(self: *PageList) void { defer self.assertIntegrity(); + // 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 // rebuilds the page list from the pools, so old untracked refs must be // rejected before any validation attempts to inspect their node pointers. @@ -703,9 +941,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), } } } @@ -847,7 +1086,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), } } } @@ -861,25 +1100,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. @@ -932,8 +1173,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. @@ -973,6 +1215,12 @@ pub const Resize = struct { pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void { defer self.assertIntegrity(); + // Resizing forces all nodes to be decompressed today so we need to + // reschedule compression. + // TODO(mitchellh): Deferred reflow on non-viewport/non-active pages. + self.page_compression.reset(); + self.page_compression.markActivity(); + if (comptime std.debug.runtime_safety) { // Resize does not work with 0 values, this should be protected // upstream @@ -1106,7 +1354,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: { @@ -1124,7 +1372,7 @@ fn resizeCols( }; const node = try self.createPage(.{ .cap = cap }); - node.data.size.rows = 1; + node.page().size.rows = 1; break :node node; }; @@ -1165,7 +1413,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); } } @@ -1181,7 +1429,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(); @@ -1254,7 +1502,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, @@ -1267,7 +1515,7 @@ const ReflowCursor = struct { .new_rows = 0, // Initially whatever size our input node is. - .total_rows = node.data.size.rows, + .total_rows = node.rows(), }; } @@ -1278,7 +1526,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]; @@ -1301,7 +1549,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; @@ -1324,7 +1572,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); } } @@ -1375,7 +1623,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; @@ -1400,7 +1648,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; @@ -1884,7 +2132,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; @@ -1987,7 +2235,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); @@ -2087,7 +2335,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| { @@ -2184,12 +2432,17 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void { // just update our rows and we're done. This effectively // "pulls down" scrollback. // + // This traversal intentionally reads only node metadata. A + // compressed history page pulled into the active area remains + // compressed until a renderer or other content consumer goes + // through `Node.page`, which restores it transparently. + // // If we don't have enough scrollback, we add the difference, // to the active area. var count: usize = 0; var page = self.pages.first; while (page) |p| : (page = p.next) { - count += p.data.size.rows; + count += p.rows(); if (count >= rows) break; } else { assert(count < rows); @@ -2220,7 +2473,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; @@ -2294,7 +2547,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; @@ -2339,7 +2592,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( @@ -2365,7 +2618,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. @@ -2374,10 +2628,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, @@ -2395,7 +2649,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; } } @@ -2436,12 +2690,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; @@ -2488,11 +2743,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; @@ -2545,6 +2800,14 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { return; } + // Moving the viewport changes which historical pages are visible. Restart + // traversal so pages which leave the viewport are reconsidered after the + // renderer's idle delay. False positives from clamped scrolling are cheap. + defer { + self.page_compression.reset(); + self.page_compression.markActivity(); + } + switch (behavior) { .active => self.viewport = .active, .top => self.viewport = .top, @@ -2605,7 +2868,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 { @@ -2616,17 +2879,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; }, @@ -2634,7 +2897,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void { break :row; } - rem -= node.data.size.rows; + rem -= node.rows(); } } @@ -2806,11 +3069,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; } @@ -2844,7 +3108,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); @@ -2867,7 +3131,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( @@ -2928,7 +3192,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 @@ -2950,12 +3214,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}, @@ -2973,7 +3237,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; @@ -3091,9 +3355,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; } @@ -3168,11 +3432,15 @@ pub fn maxSize(self: *const PageList) usize { pub fn grow(self: *PageList) Allocator.Error!?*List.Node { defer self.assertIntegrity(); + // Growing can move a complete page behind the active boundary. + self.page_compression.markActivity(); + const last = self.pages.last.?; - if (last.data.capacity.rows > last.data.size.rows) { + if (last.capacity().rows > last.rows()) { // Fast path: we have capacity in the last page. - last.data.size.rows += 1; - last.data.assertIntegrity(); + const page = last.page(); + page.size.rows += 1; + page.assertIntegrity(); // Increase our total rows by one self.total_rows += 1; @@ -3202,7 +3470,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 @@ -3210,7 +3478,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; } @@ -3219,14 +3487,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(); } } @@ -3256,13 +3524,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; @@ -3277,7 +3546,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; } @@ -3286,11 +3555,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; @@ -3337,7 +3607,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; @@ -3395,7 +3665,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; @@ -3487,7 +3757,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, @@ -3513,11 +3783,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| { @@ -3530,6 +3800,410 @@ inline fn createPageExt( return page; } +/// Temporary output memory used while creating a compressed page. +/// +/// Standard-sized output borrows a page-pool item so repeated compression can +/// reuse the same virtual mapping. Oversized pages use a temporary allocation +/// from the page allocator and release it immediately after compression. +const CompressionScratch = union(enum) { + pooled: *align(std.heap.page_size_min) [std_size]u8, + allocated: []align(std.heap.page_size_min) u8, + + fn init( + pool: *MemoryPool, + required: usize, + raw_len: usize, + ) Allocator.Error!CompressionScratch { + assert(required <= raw_len); + + if (required <= std_size) { + const memory = try pool.pages.create(); + terminal_mem.recommit(memory); + return .{ .pooled = memory }; + } + + const page_alloc = pool.pages.arena.child_allocator; + return .{ .allocated = try page_alloc.alignedAlloc( + u8, + .fromByteUnits(std.heap.page_size_min), + raw_len, + ) }; + } + + fn bytes(self: *CompressionScratch) []u8 { + return switch (self.*) { + .pooled => |memory| memory, + .allocated => |memory| memory, + }; + } + + fn deinit(self: *CompressionScratch, pool: *MemoryPool) void { + switch (self.*) { + .pooled => |memory| { + _ = terminal_mem.decommit(.zero, memory, memory.len); + pool.pages.destroy(memory); + }, + .allocated => |memory| { + const page_alloc = pool.pages.arena.child_allocator; + page_alloc.free(memory); + }, + } + } +}; + +/// PageList-owned state for incremental compression. +/// +/// The position is stored as a page serial rather than a node pointer so it +/// remains safe when PageList operations destroy, replace, or reuse nodes +/// between steps. It also records the first serial which was unallocated at +/// the prior step so new nodes before a valid marker restart safely. If the +/// exact serial no longer exists in the cold prefix, the next step also +/// restarts at the first page. +/// +/// A completed pass is followed by another pass from the oldest cold page. +/// Compression becomes idle only when that verification pass compresses no +/// pages. This converges after pages are restored between incremental steps +/// without requiring consumers to maintain a separate attempt cursor. +const IncrementalCompressionState = struct { + flags: packed struct { + /// Set after any page is compressed during the current traversal. + /// We always run incremental compression until we get a fully + /// no-compression pass (the verification pass). This lets us + /// recompress decompressed pages due to search, scrolling, etc. + did_compress: bool = false, + + /// Set after a traversal first reaches the active boundary. When this + /// verification traversal reaches the boundary, `did_compress` + /// determines whether to restart once more or finish the pass. + verifying: bool = false, + } = .{}, + + /// Changes whenever PageList activity may affect compression work. + /// Callers use this value (via Terminal.compressionActivity) to + /// determine whether to recompress. + /// + /// The directionality of this doesn't matter. We overflow and + /// wrap when maxxed. The comparison of this value to your saved + /// value is all that matters. There is a possible edge case where you + /// don't compress, a full wraparound happens, and you get the same value, + /// but its so unlikely. + /// + /// This is 48-bits so that 16-bits can be reserved for Terminal to + /// add extra state. + activity_serial: u48 = 0, + + /// Serial of the last page inspected by the traversal. This is + /// intentionally an implementation detail and is reset by PageList + /// operations which restart traversal progress. + last_serial: ?u64 = null, + + /// First node serial which had not been allocated at the prior step. A + /// node at or above this value before the saved marker requires a restart. + next_serial: u64 = 0, + + /// Record activity without disturbing valid traversal progress. + fn markActivity(self: *IncrementalCompressionState) void { + self.activity_serial +%= 1; + } + + /// Discard traversal progress without changing the activity token. + fn reset(self: *IncrementalCompressionState) void { + const activity_serial: u48 = self.activity_serial; + self.* = .{ .activity_serial = activity_serial }; + } +}; + +/// Result of one incremental compression step. +pub const IncrementalCompressionResult = enum { + /// Strict retained-mapping reclamation is unavailable on this target. + unsupported, + + /// More cold pages or a verification pass remain after this invocation's + /// candidate-bounded work. + pending, + + /// A complete verification pass compressed zero pages. + complete, +}; + +/// Iterate complete historical pages which do not intersect the viewport. +/// +/// All boundaries come from PageList pins and Page metadata. Advancing this +/// iterator never restores a compressed page or reads its backing memory. +const CompressionIterator = struct { + current: *List.Node, + active: *List.Node, + viewport_first: *List.Node, + viewport_last: *List.Node, + + fn init(self: *const PageList) CompressionIterator { + return .{ + .current = self.pages.first.?, + .active = self.getTopLeft(.active).node, + .viewport_first = self.getTopLeft(.viewport).node, + .viewport_last = self.getBottomRight(.viewport).?.node, + }; + } + + fn next(self: *CompressionIterator) ?*List.Node { + while (self.current != self.active) { + // The viewport is a contiguous node range. Once traversal reaches + // its first node, advance through the complete visible range and + // resume at the next offscreen page. + if (self.current == self.viewport_first) { + while (self.current != self.active and + self.current != self.viewport_last) + { + self.current = self.current.next.?; + } + + if (self.current == self.active) return null; + self.current = self.current.next.?; + continue; + } + + const node = self.current; + self.current = node.next.?; + return node; + } + + return null; + } + + fn done(self: *const CompressionIterator) bool { + return self.current == self.active; + } +}; + +/// Bound candidate inspection independently from compression work. Skipping +/// an already-compressed page is cheap, but still counts toward this limit. +const incremental_compression_max_inspected = 8; + +/// Failure injection for the final reclamation step. The operating-system +/// failure path cannot otherwise be exercised by tests because terminal_mem +/// deliberately simulates successful decommit in test builds. +const compressPage_tw = tripwire.module( + enum { decommit }, + error{DecommitFailed}, +); + +/// Compress eligible nodes, saving a significant amount of memory. +/// +/// Eligible nodes are complete pages before the active boundary which do not +/// intersect the viewport. The boundary page is excluded because it may +/// contain both scrollback and active rows; visible pages remain resident for +/// immediate redraw and scrolling. +/// +/// Compression requires a system that supports reclaiming physical memory for +/// virtual allocations while retaining their address ranges. +/// +/// Compression is SLOW (relatively), so incremental compression during idle +/// periods is recommended. Incremental mode performs one bounded step and its +/// result specifies whether to continue immediately. Drain mode performs +/// incremental steps until the pass and its verification pass finish. Full +/// mode visits every currently eligible node once without using incremental +/// state. +/// +/// PageList tracks mutations which require a later incremental pass in its +/// compression state. On supported targets, full compression returns +/// `complete`, indicating that it has no continuation to schedule rather than +/// that every page was compressed. +pub fn compress( + self: *PageList, + mode: enum { incremental, drain, full }, +) IncrementalCompressionResult { + return switch (mode) { + .incremental => self.compressIncremental(), + .drain => while (true) switch (self.compressIncremental()) { + .pending => continue, + .complete => break .complete, + .unsupported => break .unsupported, + }, + .full => full: { + // Match incremental mode's unsupported result. Full compression + // has no useful work to perform without strict reclamation. + if (!terminal_mem.canReclaim(.strict)) { + self.page_compression.reset(); + break :full .unsupported; + } + + self.compressFull(); + + // Full compression has no continuation. Discard any partial + // incremental cursor so later activity starts at the oldest page. + self.page_compression.reset(); + + break :full .complete; + }, + }; +} + +/// Perform one candidate-bounded incremental cold-history compression step. +fn compressIncremental(self: *PageList) IncrementalCompressionResult { + const state = &self.page_compression; + + // If we can't reclaim virtual memory, compression is unsupported. + if (!terminal_mem.canReclaim(.strict)) { + state.reset(); + return .unsupported; + } + + // Find the node following the exact continuation marker within the cold + // prefix. A missing marker means the list changed between steps, so begin + // again at the current first page. This lookup does not touch page memory. + var it: CompressionIterator = .init(self); + if (state.last_serial) |last_serial| continuation: { + while (it.next()) |node| { + // A newly allocated or replacement node appeared before the + // marker. Restart immediately so that node cannot be skipped. + if (node.serial >= state.next_serial) break; + + // Not a match? Keep looking + if (node.serial != last_serial) continue; + + // Match! The iterator already points to the next offscreen node. + break :continuation; + } + + // Not found or otherwise invalid. Reset + state.last_serial = null; + it = .init(self); + } + + // Keep track of our next_serial + state.next_serial = self.page_serial; + + // We cap the number of pages we look at to do our best to + // time-bound the incremental compression. + var inspected_pages: usize = 0; + while (inspected_pages < incremental_compression_max_inspected) { + const node = it.next() orelse break; + + state.last_serial = node.serial; + inspected_pages += 1; + + // If this page is already compressed, ignore it. + if (node.isCompressed()) continue; + + // Compression is substantially more expensive even if it fails. + // So we just try it. + if (self.compressPage(node)) state.flags.did_compress = true; + break; + } + + // If we didn't reach our active node, then we still have work to do. + if (!it.done()) return .pending; + + // We reached our active node. So we're done, except that we always + // do one pass after the first success so we can recompress nodes that + // were possibly decompressed (e.g. by search, inspector, whatever). + if (!state.flags.verifying or state.flags.did_compress) { + const activity_serial: u48 = state.activity_serial; + state.* = .{ + .flags = .{ .verifying = true }, + .activity_serial = activity_serial, + }; + return .pending; + } + + // Leave the state fresh while idle so later activity naturally begins at + // the oldest cold page, including pages restored after this pass. + state.reset(); + return .complete; +} + +/// Compress every fully historical resident page which is currently cold. +fn compressFull(self: *PageList) void { + var it: CompressionIterator = .init(self); + while (it.next()) |node| { + + // Don't restore an already-compressed page just to recompress it. + if (node.isCompressed()) continue; + + // Failure leaves this node resident and unchanged. + _ = self.compressPage(node); + } +} + +/// Attempt to compress one resident page while retaining its raw mapping. +/// +/// Compression is opportunistic: every failure leaves the page resident and +/// usable. Candidate selection and retry policy belong to `compress`; this +/// primitive only performs one state transition. +fn compressPage(self: *PageList, node: *List.Node) bool { + // Recompression requires first restoring the raw page and is a policy + // decision, so this primitive only accepts resident nodes. + if (node.isCompressed()) return false; + + const page = node.page(); + + // The scratch size is capped just below the representation's break-even + // point. Codec limits and pages too small to cover the compressed-state + // overhead simply make this page ineligible for compression. + const required = compression.Page.requiredScratch(page.memory.len) catch |err| + switch (err) { + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + if (required == 0) return false; + + // Build the compressed candidate without changing the node. This scope is + // intentional: its defer releases the borrowed or temporary scratch before + // we attempt to discard the source mapping below. Only the candidate's + // exact-sized encoded allocation survives the scope. + const candidate = candidate: { + var scratch = CompressionScratch.init( + &self.pool, + required, + page.memory.len, + ) catch |err| switch (err) { + error.OutOfMemory => return false, + }; + defer scratch.deinit(&self.pool); + + var table: compression.lz4.HashTable = undefined; + break :candidate compression.Page.init( + self.pool.alloc, + page, + scratch.bytes()[0..required], + &table, + ) catch |err| switch (err) { + error.OutOfMemory, + error.InputTooLarge, + error.OutputTooSmall, + => return false, + }; + }; + + // Null means compression crossed the break-even point. The node and its + // resident mapping are still untouched in this case. + var compressed = candidate orelse return false; + + // Strict decommit is the final fallible step. It either discards the whole + // raw mapping or leaves it untouched, so failure can safely free the + // candidate and preserve the resident node exactly as it was. + const decommit_allowed: bool = allowed: { + compressPage_tw.check(.decommit) catch |err| switch (err) { + error.DecommitFailed => break :allowed false, + }; + break :allowed true; + }; + if (!decommit_allowed or !terminal_mem.decommit( + .strict, + compressed.page.memory, + compressed.page.memory.len, + )) { + compressed.deinit(); + return false; + } + + // Publish the new state only after both the encoded allocation and the + // retained-mapping decommit have succeeded. + node.data = .{ .compressed = compressed }; + return true; +} + /// Destroy the memory of the given node in the PageList linked list /// and return it to the pool. The node is assumed to already be removed /// from the linked list. @@ -3546,7 +4220,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 @@ -3565,7 +4239,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 +4252,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. @@ -3687,12 +4267,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. @@ -3710,12 +4291,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 @@ -3732,19 +4314,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 @@ -3754,7 +4337,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; @@ -3762,7 +4345,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 @@ -3786,20 +4369,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 @@ -3834,18 +4418,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. { @@ -3874,15 +4458,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 @@ -3891,15 +4477,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. @@ -3918,7 +4504,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; @@ -3927,13 +4513,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. @@ -3951,7 +4537,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; @@ -3960,7 +4546,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. @@ -4015,27 +4601,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]; @@ -4049,12 +4636,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(), ); } @@ -4072,7 +4659,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; } @@ -4202,8 +4789,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; @@ -4257,7 +4844,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) { @@ -4265,7 +4852,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; @@ -4287,7 +4874,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, @@ -4330,13 +4917,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 @@ -4361,8 +4948,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; @@ -4411,7 +4998,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}); } @@ -4539,7 +5126,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; } @@ -4830,7 +5417,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; @@ -4851,7 +5438,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; @@ -4984,13 +5571,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; @@ -5017,7 +5604,7 @@ pub const PageIterator = struct { break :row .{ .node = row.node, .start = row.y, - .end = row.node.data.size.rows, + .end = row.node.rows(), }; } @@ -5046,7 +5633,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, }; }; @@ -5082,7 +5669,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, }; }; @@ -5116,13 +5703,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 @@ -5194,12 +5782,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 @@ -5216,22 +5804,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; }, }; @@ -5246,7 +5834,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; } @@ -5266,6 +5854,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 { @@ -5277,8 +5947,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; } } @@ -5325,7 +5996,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 }; } @@ -5335,8 +6006,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], @@ -5347,21 +6019,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. @@ -5542,7 +6216,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); @@ -5559,7 +6233,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; } @@ -5571,7 +6245,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); @@ -5598,7 +6272,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); @@ -5645,7 +6319,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 @@ -5660,18 +6334,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(); } } @@ -5700,13 +6374,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(); } } }; @@ -5723,7 +6397,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. @@ -5731,8 +6405,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, ).*; } @@ -5747,7 +6422,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; } @@ -5758,6 +6433,1018 @@ 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 incremental compression skips visible history" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + const initial_activity = s.page_compression.activity_serial; + try testing.expect(initial_activity > 0); + + s.scroll(.top); + const top_activity = s.page_compression.activity_serial; + try testing.expect(top_activity != initial_activity); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.drain), + ); + + const first = s.pages.first.?; + const second = first.next.?; + try testing.expectEqual(first, s.getTopLeft(.viewport).node); + try testing.expectEqual(first, s.getBottomRight(.viewport).?.node); + try testing.expect(!first.isCompressed()); + + var eligible: CompressionIterator = .init(&s); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); + + // Move the viewport to the start of the second page. Rendering it restores + // that page, while the first page which just left view becomes eligible. + s.scroll(.{ .row = first.rows() }); + try testing.expectEqual(second, s.getTopLeft(.viewport).node); + _ = second.page(); + try testing.expect(!second.isCompressed()); + _ = s.compress(.drain); + try testing.expect(first.isCompressed()); + try testing.expect(!second.isCompressed()); + + // Returning to the active area makes every complete historical page + // eligible again, including the page which was just visible. + s.scroll(.active); + _ = s.compress(.drain); + try testing.expect(second.isCompressed()); + try testing.expect(!s.page_compression.flags.did_compress); + try testing.expect(!s.page_compression.flags.verifying); + try testing.expectEqual(@as(?u64, null), s.page_compression.last_serial); + try testing.expectEqual(@as(u64, 0), s.page_compression.next_serial); +} + +test "PageList owns incremental compression state" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const state: IncrementalCompressionState = .{ + .flags = .{ + .did_compress = true, + .verifying = true, + }, + .activity_serial = 42, + .last_serial = 42, + .next_serial = 43, + }; + + s.page_compression = state; + s.scroll(.top); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + // Every scroll restarts traversal, even if clamping leaves the viewport in + // the same place. Missing an eligible page is worse than a no-op pass. + s.page_compression = state; + s.scroll(.top); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + s.scroll(.active); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + try s.resize(.{ .cols = 80, .rows = 24 }); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 43 }, + s.page_compression, + ); + + s.page_compression = state; + s.reset(); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); + + s.page_compression = state; + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.full), + ); + try testing.expectEqual( + IncrementalCompressionState{ .activity_serial = 42 }, + s.page_compression, + ); + + s.page_compression = state; + var cloned = try s.clone(testing.allocator, .{ + .top = .{ .active = .{} }, + }); + defer cloned.deinit(); + try testing.expectEqual( + IncrementalCompressionState{}, + cloned.page_compression, + ); +} + +test "PageList incremental compression bounds inspected pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + + // Precompress every candidate so the incremental pass exercises its + // metadata-only skip budget without stopping at a resident attempt. + _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markActivity(); + try testing.expectEqual( + incremental_compression_max_inspected + 1, + s.memoryStats().compressed_pages, + ); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + const first = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); + + const second = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, second); + try testing.expect(s.page_compression.flags.verifying); + try testing.expect(s.page_compression.last_serial == null); + + // The verification pass is bounded independently, too. + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); +} + +test "PageList incremental compression advances after failure" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x494E_4352_5041_5353); + prng.random().bytes(first.page().memory); + + const failed = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // The unsuccessful first page does not stall the pass. The next step + // continues at the following serial and compresses that page. + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after allocation failure" { + const testing = std.testing; + + var failing = testing.FailingAllocator.init(testing.allocator, .{}); + const alloc = failing.allocator(); + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + const first = s.pages.first.?; + const second = first.next.?; + + // Pool preheating supplies compression scratch. Failing the allocator's + // next request therefore rejects the exact encoded allocation while the + // source page and pass remain valid. + failing.fail_index = failing.alloc_index; + const failed = s.compress(.incremental); + try testing.expect(failing.has_induced_failure); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!first.isCompressed()); + + // Allow allocations again. The pass must continue with the following page + // rather than retrying the failed candidate. + failing.fail_index = std.math.maxInt(usize); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(second.isCompressed()); +} + +test "PageList incremental compression advances after decommit failure" { + const testing = std.testing; + const tw = compressPage_tw; + defer tw.end(.reset) catch unreachable; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + tw.errorAlways(.decommit, error.DecommitFailed); + const failed = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, failed); + try testing.expect(!s.pages.first.?.isCompressed()); + try tw.end(.reset); + + // The failed candidate remains resident and the pass continues at the + // next serial once reclamation is available again. + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.pages.first.?.next.?.isCompressed()); +} + +test "PageList incremental compression restarts after replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const old = s.pages.first.?; + const old_serial = old.serial; + var replacement = old; + while (replacement.page().memory.len <= std_size) { + replacement = try s.increaseCapacity( + replacement, + .grapheme_bytes, + ); + } + try testing.expect(replacement.serial != old_serial); + try testing.expect(replacement.page().memory.len > std_size); + try testing.expect(!replacement.isCompressed()); + + // The exact continuation serial disappeared with the old node. The pass + // restarts at the first page and considers the oversized replacement. + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression restarts after reset" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + // Reset replaces every page and clears the PageList-owned traversal. + s.reset(); + try s.growColdPagesForTest(1); + const restarted = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, restarted); + try testing.expect(s.pages.first.?.isCompressed()); +} + +test "PageList incremental compression restarts after active boundary resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const first = s.pages.first.?; + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + try testing.expectEqual(first, s.getTopLeft(.active).node); + + // Restore the page while it is active. Resize reset the traversal, and + // active contents remain ineligible. + _ = first.page(); + const active = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, active); + try testing.expectEqual( + IncrementalCompressionResult.complete, + s.compress(.incremental), + ); + + // Shrinking the active area makes the page fully historical again. The + // resize reset the PageList-owned cursor, so the next step can reclaim it. + try s.resize(.{ .rows = 24 }); + try s.growColdPagesForTest(1); + try testing.expectEqual( + IncrementalCompressionResult.pending, + s.compress(.incremental), + ); + try testing.expect(first.isCompressed()); +} + +test "PageList incremental compression restarts after prune reuse" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const initial = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, initial); + try testing.expect(s.pages.first.?.isCompressed()); + + const reused = s.pages.first.?; + const old_serial = reused.serial; + while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) { + _ = try s.grow(); + } + try testing.expectEqual(reused, (try s.grow()).?); + try testing.expect(reused.serial != old_serial); + + // Make the remaining old page fully historical. The continuation serial + // disappeared when its node was recycled, so the pass safely restarts. + try s.growColdPagesForTest(1); + _ = s.compress(.incremental); + try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages); +} + +test "PageList incremental compression restarts after earlier replacement" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(3); + + _ = s.compress(.incremental); + _ = s.compress(.incremental); + try testing.expect(s.pages.first.?.isCompressed()); + try testing.expect(s.pages.first.?.next.?.isCompressed()); + + // Replace a page before the still-valid continuation marker. The list's + // allocation serial changes even though the marker itself remains, so the + // next step must restart and inspect the replacement. + const old_first = s.pages.first.?; + const old_serial = old_first.serial; + const replacement = try s.increaseCapacity( + old_first, + .grapheme_bytes, + ); + try testing.expect(replacement.serial != old_serial); + try testing.expect(!replacement.isCompressed()); + + _ = s.compress(.incremental); + try testing.expect(replacement.isCompressed()); +} + +test "PageList incremental compression keeps progress after tail growth" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(incremental_compression_max_inspected + 1); + _ = s.compress(.full); + s.page_compression.reset(); + s.page_compression.markActivity(); + + var expected_last = s.pages.first.?; + for (1..incremental_compression_max_inspected) |_| + expected_last = expected_last.next.?; + + const first = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, first); + try testing.expectEqual( + expected_last.serial, + s.page_compression.last_serial.?, + ); + + // Allocate a new page at the active tail between steps. It is after the + // continuation marker and must not restart progress through cold history. + const next_serial = s.page_serial; + while (s.page_serial == next_serial) _ = try s.grow(); + const continued = s.compress(.incremental); + try testing.expectEqual(IncrementalCompressionResult.pending, continued); + try testing.expect(s.page_compression.flags.verifying); +} + +test "PageList memory stats do not restore compressed pages" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const before = s.memoryStats(); + try testing.expectEqual(s.totalPages(), before.resident_pages); + try testing.expectEqual(@as(usize, 0), before.compressed_pages); + try testing.expectEqual(s.page_size, before.raw_bytes); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(@as(usize, 0), before.decommitted_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(@as(usize, 0), before.encoded_bytes); + try testing.expectEqual( + before.resident_backing_bytes, + before.estimatedResidentBytes(), + ); + try testing.expectEqual(@as(usize, 0), before.estimatedSavings()); + + _ = s.compress(.full); + const first = s.pages.first.?; + try testing.expectEqual(Node.Storage.compressed, first.storage()); + try testing.expect(first.pageIfResident() == null); + + const after = s.memoryStats(); + try testing.expect(first.isCompressed()); + try testing.expectEqual(s.totalPages(), after.resident_pages + after.compressed_pages); + try testing.expectEqual(@as(usize, 2), after.compressed_pages); + try testing.expectEqual(s.page_size, after.raw_bytes); + try testing.expectEqual( + after.raw_bytes, + after.resident_raw_bytes + after.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + after.encoded_bytes, + after.estimatedResidentBytes(), + ); + try testing.expectEqual( + after.decommitted_raw_bytes - after.encoded_bytes, + after.estimatedSavings(), + ); + + const first_raw_len = first.metadata().memory.len; + const first_encoded_len = first.data.compressed.encoded.len; + _ = first.page(); + try testing.expectEqual(Node.Storage.resident, first.storage()); + try testing.expect(first.pageIfResident() != null); + + const restored = s.memoryStats(); + try testing.expectEqual(after.resident_pages + 1, restored.resident_pages); + try testing.expectEqual(after.compressed_pages - 1, restored.compressed_pages); + try testing.expectEqual(after.raw_bytes, restored.raw_bytes); + try testing.expectEqual( + after.resident_raw_bytes + first_raw_len, + restored.resident_raw_bytes, + ); + try testing.expectEqual( + after.decommitted_raw_bytes - first_raw_len, + restored.decommitted_raw_bytes, + ); + try testing.expectEqual( + after.resident_backing_bytes + first_raw_len, + restored.resident_backing_bytes, + ); + try testing.expectEqual( + after.encoded_bytes - first_encoded_len, + restored.encoded_bytes, + ); +} + +test "PageList preserved page keeps compressed storage" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const resident = node.page(); + resident.dirty = true; + resident.getRowAndCell(3, 2).cell.* = .init('X'); + + // Resident nodes can be borrowed without allocating an unnecessary copy. + { + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + var preserved = try node.pagePreservingState(failing.allocator()); + defer preserved.deinit(); + switch (preserved) { + .borrowed => |page_| try testing.expectEqual(resident, page_), + .owned => try testing.expect(false), + } + try testing.expect(!failing.has_induced_failure); + } + + const expected = try alloc.dupe(u8, resident.memory); + defer alloc.free(expected); + const retained_ptr = resident.memory.ptr; + + try testing.expect(s.compressPage(node)); + const stats = s.memoryStats(); + const expected_encoded = try alloc.dupe(u8, node.data.compressed.encoded); + defer alloc.free(expected_encoded); + + // Test decommit simulates physical reclamation by clearing the retained + // mapping. A preserved page must decode elsewhere rather than restoring + // it. + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + + // Preserved-page allocation is opportunistic for callers. Failure leaves + // the node and its compressed representation untouched. + var failing = testing.FailingAllocator.init(alloc, .{ + .fail_index = 0, + }); + try testing.expectError( + error.OutOfMemory, + node.pagePreservingState(failing.allocator()), + ); + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + + var preserved = try node.pagePreservingState(alloc); + defer preserved.deinit(); + switch (preserved) { + .borrowed => try testing.expect(false), + .owned => {}, + } + const page_ = preserved.page(); + + try testing.expect(page_.memory.ptr != retained_ptr); + try testing.expectEqualSlices(u8, expected, page_.memory); + try testing.expect(page_.dirty); + try testing.expectEqual( + @as(u21, 'X'), + page_.getRowAndCell(3, 2).cell.content.codepoint, + ); + + // The node still owns the same compressed representation, and neither its + // storage accounting nor its discarded raw mapping changed while cloning. + try testing.expectEqual(Node.Storage.compressed, node.storage()); + try testing.expectEqual(stats, s.memoryStats()); + try testing.expectEqual(retained_ptr, node.metadata().memory.ptr); + try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0)); + try testing.expectEqualSlices( + u8, + expected_encoded, + node.data.compressed.encoded, + ); +} + +test "PageList memory stats include unused pool backing" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // Pool allocation ownership is based on the requested layout fitting in a + // standard item. The Page itself exposes only the initialized prefix. + const node = try s.createPage(.{ .cap = .{ .cols = 1, .rows = 1 } }); + try testing.expectEqual(Node.Owned.pool, node.owned); + try testing.expect(node.page().memory.len < PagePool.item_size); + node.page().size.rows = 1; + s.pages.append(node); + s.total_rows += 1; + + const raw_len = node.metadata().memory.len; + const before = s.memoryStats(); + try testing.expect(before.raw_bytes < s.page_size); + try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes); + try testing.expectEqual(s.page_size, before.resident_backing_bytes); + try testing.expectEqual(s.page_size, before.estimatedResidentBytes()); + + try testing.expect(s.compressPage(node)); + const encoded_len = node.data.compressed.encoded.len; + const compressed = s.memoryStats(); + try testing.expectEqual(before.raw_bytes, compressed.raw_bytes); + try testing.expectEqual( + before.resident_raw_bytes - raw_len, + compressed.resident_raw_bytes, + ); + try testing.expectEqual(raw_len, compressed.decommitted_raw_bytes); + try testing.expectEqual( + before.resident_backing_bytes - raw_len, + compressed.resident_backing_bytes, + ); + try testing.expectEqual(encoded_len, compressed.encoded_bytes); + try testing.expectEqual( + before.estimatedResidentBytes() - raw_len + encoded_len, + compressed.estimatedResidentBytes(), + ); +} + +test "PageList does not compress the mixed history and active page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + // One additional row creates history, but the history and all active rows + // still share the first page. The active boundary therefore has a + // historical prefix and must remain resident as one indivisible mapping. + _ = try s.grow(); + const active = s.getTopLeft(.active); + try testing.expectEqual(s.pages.first.?, active.node); + try testing.expect(active.y > 0); + + _ = s.compress(.full); + try testing.expect(!s.pages.first.?.isCompressed()); +} + +test "PageList compresses only complete cold history pages" { + const testing = std.testing; + const alloc = testing.allocator; + + // More active rows than one page at these dimensions can hold ensures the + // active area spans multiple nodes when the pass chooses its boundary. + const active_rows = initialCapacity(80).rows + 1; + var s = try init(alloc, 80, active_rows, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + // Move the active top into the boundary page so it has both a historical + // prefix and active rows while the active area still spans later pages. + _ = try s.grow(); + + const active = s.getTopLeft(.active); + const active_node = active.node; + try testing.expect(active.y > 0); + try testing.expect(active_node != s.pages.last.?); + + var expected_compressed: usize = 0; + var expected_raw_bytes: usize = 0; + var current = s.pages.first; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + expected_compressed += 1; + expected_raw_bytes += node.metadata().memory.len; + } + try testing.expectEqual(@as(usize, 2), expected_compressed); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const expected = try alloc.dupe(u8, first.page().memory); + defer alloc.free(expected); + const first_memory = first.page().memory.ptr; + const page_size = s.page_size; + + _ = s.compress(.full); + const memory = s.memoryStats(); + try testing.expectEqual(expected_compressed, memory.compressed_pages); + try testing.expectEqual(expected_raw_bytes, memory.decommitted_raw_bytes); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + current = s.pages.first; + var actual_encoded_bytes: usize = 0; + while (current) |node| : (current = node.next) { + if (node == active_node) break; + try testing.expect(node.isCompressed()); + actual_encoded_bytes += node.data.compressed.encoded.len; + } + try testing.expectEqual(actual_encoded_bytes, memory.encoded_bytes); + current = active_node; + while (current) |node| : (current = node.next) { + try testing.expect(!node.isCompressed()); + } + + // Restoring the oldest page preserves both the mapping identity and all + // of its bytes even though the pass discarded its physical pages. + try testing.expectEqual(first_memory, first.metadata().memory.ptr); + try testing.expectEqualSlices(u8, expected, first.page().memory); + try testing.expectEqual(first_memory, first.page().memory.ptr); +} + +test "PageList lazily restores compressed history made active by resize" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(1); + + const first = s.pages.first.?; + first.page().getRowAndCell(0, 0).cell.* = .init('X'); + const memory_ptr = first.page().memory.ptr; + const memory_len = first.page().memory.len; + const page_size = s.page_size; + + _ = s.compress(.full); + try testing.expect(first.isCompressed()); + + // Pull all scrollback into the active area by making the viewport as tall + // as the complete screen. A row-only resize needs only page metadata, so + // the newly active page can remain compressed until its contents are used. + const all_rows: size.CellCountInt = @intCast(s.total_rows); + try s.resize(.{ .rows = all_rows }); + const active = s.getTopLeft(.active); + try testing.expectEqual(first, active.node); + try testing.expectEqual(@as(size.CellCountInt, 0), active.y); + try testing.expect(first.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + + // The compression pass must not reconsider the node now that it is active. + // Content access follows the normal page boundary, which recommits and + // restores the retained mapping before returning the cell. + _ = s.compress(.full); + try testing.expect(first.isCompressed()); + const cell = s.getCell(.{ .active = .{} }).?; + try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(memory_ptr, first.page().memory.ptr); + try testing.expectEqual(memory_len, first.page().memory.len); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList full and incremental compression skip a spanning viewport" { + const testing = std.testing; + + var full = try init(testing.allocator, 80, 24, null); + defer full.deinit(); + try full.growColdPagesForTest(3); + + var incremental = try init(testing.allocator, 80, 24, null); + defer incremental.deinit(); + try incremental.growColdPagesForTest(3); + + // Start near the end of the first page so the viewport intersects both + // the first and second historical page mappings. + const first = full.pages.first.?; + const overlap_rows: usize = full.rows / 2; + const viewport_row: usize = first.rows() - overlap_rows; + full.scroll(.{ .row = viewport_row }); + incremental.scroll(.{ .row = viewport_row }); + try testing.expect( + full.getTopLeft(.viewport).node != + full.getBottomRight(.viewport).?.node, + ); + const second = first.next.?; + try testing.expectEqual(first, full.getTopLeft(.viewport).node); + try testing.expectEqual(second, full.getBottomRight(.viewport).?.node); + + _ = full.compress(.full); + _ = incremental.compress(.drain); + try testing.expectEqual(full.memoryStats(), incremental.memoryStats()); + try testing.expect(!first.isCompressed()); + try testing.expect(!second.isCompressed()); + + const full_active = full.getTopLeft(.active).node; + const incremental_active = incremental.getTopLeft(.active).node; + var full_node = full.pages.first.?; + var incremental_node = incremental.pages.first.?; + while (full_node != full_active) { + try testing.expectEqual( + full_node.isCompressed(), + incremental_node.isCompressed(), + ); + + full_node = full_node.next.?; + incremental_node = incremental_node.next.?; + } + try testing.expectEqual(incremental_active, incremental_node); + + var eligible: CompressionIterator = .init(&full); + var compressed_pages: usize = 0; + while (eligible.next()) |node| { + compressed_pages += 1; + try testing.expect(node.isCompressed()); + } + try testing.expect(compressed_pages > 0); +} + +test "PageList cold compression continues after an incompressible page" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + try s.growColdPagesForTest(2); + + const first = s.pages.first.?; + const second = first.next.?; + var prng = std.Random.DefaultPrng.init(0x434F_4C44_5041_4745); + prng.random().bytes(first.page().memory); + + const page_size = s.page_size; + _ = s.compress(.full); + const memory = s.memoryStats(); + try testing.expectEqual(@as(usize, 1), memory.compressed_pages); + try testing.expect(!first.isCompressed()); + try testing.expect(second.isCompressed()); + try testing.expectEqual( + second.metadata().memory.len, + memory.decommitted_raw_bytes, + ); + try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes); + try testing.expectEqual(page_size, s.page_size); + + // Failed resident candidates are deliberately retried on later passes, + // while the successful page remains compressed and is skipped. + _ = s.compress(.full); + try testing.expectEqual(memory, s.memoryStats()); +} + +test "PageList compression restores through page access" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const page = node.page(); + page.dirty = true; + page.getRowAndCell(3, 2).cell.* = .init('X'); + + const expected = try alloc.dupe(u8, page.memory); + defer alloc.free(expected); + const memory_ptr = page.memory.ptr; + const memory_len = page.memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 24), node.rows()); + try testing.expectEqual(@as(size.CellCountInt, 80), node.cols()); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + try testing.expectEqual(page_size, s.page_size); + + // Pin access restores the page without changing its retained mapping. + const page_pin: Pin = .{ .node = node, .x = 3, .y = 2 }; + try testing.expectEqual( + @as(u21, 'X'), + page_pin.rowAndCell().cell.content.codepoint, + ); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(node.page().dirty); + + // Recompressing exercises reuse of the page-pool scratch item. Page + // iterator chunks also restore before exposing row memory. + try testing.expect(s.compressPage(node)); + var page_it = (Pin{ .node = node }).pageIterator(.right_down, null); + const chunk = page_it.next().?; + try testing.expectEqual(node.rows(), chunk.rows().len); + try testing.expect(!node.isCompressed()); + try testing.expectEqualSlices(u8, expected, node.page().memory); + + // Read-only PageList operations restore through the same boundary. + try testing.expect(s.compressPage(node)); + var cloned = try s.clone(alloc, .{ + .top = .{ .screen = .{} }, + }); + defer cloned.deinit(); + try testing.expect(!node.isCompressed()); + try testing.expectEqual( + @as(u21, 'X'), + cloned.pages.first.?.page().getRowAndCell(3, 2).cell.content.codepoint, + ); +} + +test "PageList compression uses temporary scratch for oversized pages" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + var node = s.pages.first.?; + while (node.page().memory.len <= std_size) { + node = try s.increaseCapacity(node, .grapheme_bytes); + } + + const expected = try alloc.dupe(u8, node.page().memory); + defer alloc.free(expected); + const memory_ptr = node.page().memory.ptr; + const memory_len = node.page().memory.len; + const page_size = s.page_size; + + try testing.expect(s.compressPage(node)); + try testing.expect(node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); + try testing.expectEqual(memory_ptr, node.metadata().memory.ptr); + try testing.expectEqual(memory_len, node.metadata().memory.len); + + try testing.expectEqualSlices(u8, expected, node.page().memory); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(memory_ptr, node.page().memory.ptr); +} + +test "PageList compression leaves incompressible pages resident" { + const testing = std.testing; + const alloc = testing.allocator; + + var s = try init(alloc, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + const original = try alloc.dupe(u8, node.page().memory); + defer alloc.free(original); + defer @memcpy(node.page().memory, original); + + var prng = std.Random.DefaultPrng.init(0x5041_4745_4C49_5354); + prng.random().bytes(node.page().memory); + const page_size = s.page_size; + + try testing.expect(!s.compressPage(node)); + try testing.expect(!node.isCompressed()); + try testing.expectEqual(page_size, s.page_size); +} + +test "PageList reset discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + defer s.deinit(); + + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.reset(); + try testing.expect(!s.pages.first.?.isCompressed()); + try testing.expectEqual(@as(usize, 1), s.totalPages()); +} + +test "PageList deinit discards malformed compressed data" { + const testing = std.testing; + + var s = try init(testing.allocator, 80, 24, null); + const node = s.pages.first.?; + try testing.expect(s.compressPage(node)); + @memset(node.data.compressed.encoded, 0xFF); + + s.deinit(); +} + +test "PageList prune reuses malformed compressed page memory" { + const testing = std.testing; + + var s = try init( + testing.allocator, + 80, + 24, + 2 * PagePool.item_size, + ); + defer s.deinit(); + + // Allocate the second page so the first one can be pruned and reused. + while (s.pages.first == s.pages.last) _ = try s.grow(); + const first = s.pages.first.?; + try testing.expect(s.compressPage(first)); + @memset(first.data.compressed.encoded, 0xFF); + + var reused = false; + const growth_limit = @as(usize, s.pages.last.?.capacity().rows) + 1; + for (0..growth_limit) |_| { + if (try s.grow()) |new_node| { + if (new_node == first) { + reused = true; + break; + } + } + } + + try testing.expect(reused); + try testing.expectEqual(first, s.pages.last.?); + try testing.expect(!first.isCompressed()); + try testing.expectEqual(@as(size.CellCountInt, 1), first.rows()); + first.page().assertIntegrity(); +} + test "PageList" { const testing = std.testing; const alloc = testing.allocator; @@ -5898,7 +7585,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); @@ -5982,17 +7669,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{ @@ -6025,17 +7712,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(); @@ -6125,8 +7812,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 @@ -6155,7 +7842,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); } @@ -6168,7 +7855,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); @@ -7058,7 +8745,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; @@ -7086,7 +8773,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; @@ -7204,7 +8891,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 @@ -7227,7 +8914,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); } @@ -7256,14 +8943,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); } @@ -7325,14 +9012,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); } @@ -7383,7 +9070,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) |_| { @@ -7423,7 +9110,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) |_| { @@ -7462,7 +9149,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) |_| { @@ -7502,7 +9189,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) |_| { @@ -7543,7 +9230,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) |_| { @@ -7586,7 +9273,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) |_| { @@ -7630,11 +9317,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| { @@ -7653,7 +9340,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( @@ -7681,11 +9368,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| { @@ -7702,7 +9389,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); @@ -7725,11 +9412,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| { @@ -7746,7 +9433,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); @@ -7769,11 +9456,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| { @@ -7790,7 +9477,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); @@ -7845,7 +9532,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; }; } @@ -7863,7 +9550,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); } @@ -7872,7 +9559,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); @@ -7888,19 +9575,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); @@ -7908,13 +9595,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, ); } @@ -7926,7 +9613,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); @@ -7939,10 +9626,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); @@ -7981,12 +9668,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 @@ -7994,9 +9681,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().?; @@ -8017,12 +9704,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 @@ -8070,12 +9757,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 @@ -8092,9 +9779,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); @@ -8110,12 +9797,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 @@ -8138,7 +9825,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); @@ -8188,7 +9875,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); @@ -8238,7 +9925,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); @@ -8295,7 +9982,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); @@ -8352,7 +10039,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) { @@ -8395,7 +10082,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 @@ -8434,7 +10121,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 { @@ -8466,7 +10153,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 { @@ -8499,7 +10186,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 { @@ -8553,7 +10240,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 { @@ -8618,7 +10305,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 { @@ -8674,7 +10361,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) { @@ -8718,7 +10405,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 { @@ -8766,7 +10453,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 { @@ -8821,7 +10508,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 { @@ -8886,7 +10573,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 { @@ -8963,7 +10650,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 { @@ -9011,7 +10698,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 { @@ -9059,7 +10746,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 { @@ -9106,7 +10793,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 { @@ -9177,7 +10864,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 { @@ -9268,7 +10955,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 { @@ -9353,7 +11040,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 { @@ -9425,7 +11112,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 { @@ -9485,7 +11172,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 { @@ -9564,17 +11251,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 @@ -9598,17 +11285,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. @@ -9624,17 +11311,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); @@ -9703,17 +11390,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)) }); @@ -9732,17 +11419,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)) }); @@ -9761,17 +11448,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 = {} }); @@ -9801,7 +11488,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.* = .{ @@ -9924,13 +11611,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 @@ -9945,11 +11632,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); @@ -9972,12 +11659,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 @@ -10051,7 +11738,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); @@ -10083,7 +11770,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()); } } @@ -10281,7 +11968,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| { @@ -10315,7 +12002,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| { @@ -10349,7 +12036,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| { @@ -10401,7 +12088,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| { @@ -10452,7 +12139,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 { @@ -10511,7 +12198,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 { @@ -10554,7 +12241,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 { @@ -10587,7 +12274,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 { @@ -10667,7 +12354,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); } } @@ -10691,7 +12378,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); } @@ -10709,7 +12396,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.* = .{ @@ -10727,7 +12414,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()); } } @@ -10746,7 +12433,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); } } @@ -10759,7 +12446,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); @@ -10802,7 +12489,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); @@ -10842,7 +12529,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 { @@ -10881,7 +12568,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); @@ -10937,14 +12624,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; @@ -10967,7 +12654,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()); } } } @@ -10991,7 +12678,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); } } @@ -11011,7 +12698,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); } } @@ -11061,7 +12748,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)) }, @@ -11115,7 +12802,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); } } @@ -11155,7 +12842,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); } } @@ -11174,7 +12861,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); @@ -11194,7 +12881,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); } @@ -11210,7 +12897,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.* = .{ @@ -11280,7 +12967,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); @@ -11299,7 +12986,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); } @@ -11312,7 +12999,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); @@ -11350,7 +13037,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); @@ -11366,7 +13053,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); @@ -11428,7 +13115,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); @@ -11460,8 +13147,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 @@ -11481,7 +13168,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(); @@ -11495,7 +13182,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); @@ -11510,7 +13197,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); @@ -11612,7 +13299,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(); @@ -11626,7 +13313,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); @@ -11641,7 +13328,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); @@ -11698,7 +13385,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(); @@ -11712,7 +13399,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); @@ -11727,7 +13414,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); @@ -11874,7 +13561,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); @@ -11925,7 +13612,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); @@ -11976,7 +13663,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); @@ -12042,7 +13729,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; } @@ -12054,7 +13741,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); } @@ -12071,7 +13758,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(); @@ -12105,7 +13792,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), @@ -12129,7 +13816,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), @@ -12165,7 +13852,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(); @@ -12199,7 +13886,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.* = .{ @@ -12232,7 +13919,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.* = .{ @@ -12277,7 +13964,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(); @@ -12295,7 +13982,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, @@ -12322,7 +14009,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, @@ -12358,7 +14045,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); @@ -12402,7 +14089,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); @@ -12431,7 +14118,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); @@ -12491,7 +14178,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); @@ -12535,7 +14222,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); @@ -12578,7 +14265,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); @@ -12616,7 +14303,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; @@ -12659,7 +14346,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; } @@ -12671,7 +14358,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); } @@ -12685,7 +14372,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; } @@ -12697,7 +14384,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); } @@ -12710,7 +14397,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); @@ -12734,7 +14421,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); } @@ -12748,7 +14435,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); @@ -12778,7 +14465,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); @@ -12786,7 +14473,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); @@ -12795,7 +14482,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); @@ -12803,7 +14490,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); @@ -12818,7 +14505,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); @@ -12848,13 +14535,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); @@ -12862,7 +14549,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); @@ -12876,7 +14563,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); @@ -12884,7 +14571,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); @@ -12903,7 +14590,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); @@ -12938,7 +14625,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); @@ -12996,7 +14683,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); @@ -13033,7 +14720,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); @@ -13064,7 +14751,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); @@ -13098,7 +14785,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); @@ -13132,7 +14819,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); @@ -13166,7 +14853,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); @@ -13200,7 +14887,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); @@ -13221,7 +14908,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); @@ -13229,7 +14916,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); @@ -13243,7 +14930,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); @@ -13277,7 +14964,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); @@ -13285,7 +14972,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); @@ -13299,7 +14986,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.* = .{ @@ -13324,7 +15011,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); @@ -13332,13 +15019,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); @@ -13352,7 +15039,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); @@ -13397,7 +15084,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 } }; @@ -13431,8 +15118,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); @@ -13451,7 +15138,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); @@ -13478,7 +15165,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); @@ -13496,7 +15183,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); @@ -13530,7 +15217,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); @@ -13564,7 +15251,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: // @@ -13623,7 +15310,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); @@ -13685,7 +15372,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| { @@ -13726,7 +15413,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| { @@ -13769,7 +15456,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| { @@ -13925,7 +15612,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; @@ -13933,7 +15620,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(); @@ -13955,7 +15642,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; @@ -13970,7 +15657,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); @@ -14013,7 +15700,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) { @@ -14057,16 +15744,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(); } @@ -14076,23 +15763,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 }); @@ -14111,7 +15798,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); @@ -14140,19 +15827,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; @@ -14161,8 +15848,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 @@ -14179,7 +15866,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, @@ -14213,12 +15900,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. @@ -14245,7 +15932,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; @@ -14263,20 +15950,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)); @@ -14291,7 +15978,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. @@ -14312,7 +15999,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. @@ -14331,7 +16018,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' }, @@ -14341,7 +16028,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 = .{} }, @@ -14352,7 +16039,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); } } @@ -14366,11 +16053,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); @@ -14378,7 +16065,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); @@ -14395,30 +16082,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.?); @@ -14432,7 +16119,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); @@ -14444,34 +16131,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; @@ -14482,25 +16141,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); } @@ -14515,27 +16174,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" { @@ -14550,12 +16209,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" { @@ -14565,7 +16224,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| { @@ -14584,8 +16243,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); @@ -14612,7 +16271,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| { @@ -14646,7 +16305,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| { @@ -14665,8 +16324,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); @@ -14852,8 +16511,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 }; @@ -14880,9 +16539,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" { @@ -14900,7 +16559,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); @@ -14912,8 +16571,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" { @@ -14945,9 +16604,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" { @@ -14957,7 +16616,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) @@ -14979,7 +16638,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 @@ -15011,7 +16670,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 } }; @@ -15034,8 +16693,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()); @@ -15062,7 +16721,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) { @@ -15081,8 +16740,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()); @@ -15110,7 +16769,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(.{ @@ -15130,8 +16789,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..b9363684d 100644 --- a/src/terminal/Terminal.zig +++ b/src/terminal/Terminal.zig @@ -30,6 +30,7 @@ const Stream = @import("stream_terminal.zig").Stream; const size = @import("size.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; @@ -642,7 +643,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 +949,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 +1006,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 +1285,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 +1311,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 +1334,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 +1343,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 +1366,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 +1395,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); } @@ -2291,6 +2294,76 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void { }); } +/// Return the current compression activity value. +/// +/// 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 +/// the primary screen. +/// +/// Note that compression requires specific system features, namely +/// the ability to retain virtual memory allocations while discarding their +/// 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); +} + +/// 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 +/// 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: CompressionMode, +) CompressionResult { + const pages = &self.screens.get(.primary).?.pages; + 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) /// /// Takes care of boundary conditions such as potentially split wide chars @@ -2439,8 +2512,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 +2531,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 +2595,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 +2611,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 +2707,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 +2726,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 +2783,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 +2799,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 +2854,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 +2937,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 +3014,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 +3022,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 +3094,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 +3102,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 +3276,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 +3911,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 +4159,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 +4170,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 +4189,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 +4200,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 +4231,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 +4240,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 +4248,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 +4257,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 +4265,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 +4273,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 +4339,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 +4420,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 +4453,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 +4661,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 +4696,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 +4755,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 +4807,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 +4858,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 +4909,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 +4947,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 +4986,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 +5017,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 +5026,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 +5119,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 +5148,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 +5196,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 +5217,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 +5255,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 +5288,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 +5350,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 +5390,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 +5441,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 +5889,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 +5916,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 +5943,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 +5978,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 +5988,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 +6012,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 +6827,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 +6911,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 +7243,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 +7257,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 +7294,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 +7396,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 +7408,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 +7420,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 +7436,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 +7448,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 +7460,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 +7743,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 +7757,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 +7867,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 +7879,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 +7891,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 +7907,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 +7919,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 +7931,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 +8165,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 +8242,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 +8272,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 +8299,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 +8602,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 +8614,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 +8776,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 +8788,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 +8825,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 +9702,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 +10317,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 +10341,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 +10363,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 +10754,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 +10790,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 +10858,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 +10868,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 +10898,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 +11471,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 +11523,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 +11562,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 +11723,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 +12546,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 +14023,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 +14038,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 +14110,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 +14125,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/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( 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/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/Page.zig b/src/terminal/compress/Page.zig new file mode 100644 index 000000000..b7ad80256 --- /dev/null +++ b/src/terminal/compress/Page.zig @@ -0,0 +1,398 @@ +//! 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. `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: +//! +//! 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, + +/// 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. +/// +/// 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 +/// 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. +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. 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 +/// 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]), + .alloc = alloc, + }; +} + +/// 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 +/// 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; +} + +/// Clone this page into caller-owned memory without restoring its mapping. +/// +/// `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" { + 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(); + + 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); + 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(); + + 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; + + 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; + 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/compress/lz4.zig b/src/terminal/compress/lz4.zig new file mode 100644 index 000000000..a6b07763b --- /dev/null +++ b/src/terminal/compress/lz4.zig @@ -0,0 +1,891 @@ +//! 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 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 +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 +/// 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 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; + +/// 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; + + // 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 + // 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; + + // 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. 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; + var ip: usize = 0; + var search_attempts: usize = 0; + + 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); + + 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, + ); + + 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 + // 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. + // + // 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; + + 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. + // 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; + } + + // 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; + + // 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 = 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 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, + min_match, + ) catch return error.OutputTooSmall; + if (match_len > output.len - op) return error.OutputTooSmall; + + // Match copies may overlap, so this cannot always be one memcpy. + // `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; + } +} + +/// 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 { + assert(match_len >= min_match); + 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. + writeIntAt(u16, output, op.*, offset); + 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: usize, +) 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 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 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 decoded match from `offset` bytes behind `op`. +/// +/// 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; + + // 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; + } + + 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, + }; + + const limit = @min(end, output.len -| 7); + while (op < limit) : (op += 8) + writeIntAt(u64, output, op, pattern); + }, + + // 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); + }, + } + + while (op < end) : (op += 1) output[op] = output[op - offset]; +} + +/// 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 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" { + 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" { + 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" { + // 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" { + // 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 "short offset compatibility vectors" { + + // 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" { + + // 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 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 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 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" { + 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 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" { + 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 { + _ = @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); +} 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/main.zig b/src/terminal/main.zig index 53491a009..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; @@ -83,7 +86,9 @@ test { // Internals _ = @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..7fa314802 --- /dev/null +++ b/src/terminal/mem.zig @@ -0,0 +1,222 @@ +//! 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, +}; + +/// 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 +/// 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)); +} + +test "test builds can reclaim retained mappings" { + const testing = std.testing; + try testing.expect(canReclaim(.zero)); + try testing.expect(canReclaim(.strict)); +} 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..aee21b3e3 100644 --- a/src/terminal/search/active.zig +++ b/src/terminal/search/active.zig @@ -68,26 +68,22 @@ 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); - 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 f76ad4e4b..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; @@ -56,8 +57,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); @@ -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; @@ -190,7 +191,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 +235,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 +273,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 +314,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"); @@ -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 }); @@ -367,7 +438,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 +475,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..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.data.size.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.data.size.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].data.size.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.data, .{ + 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.data.getRow(node.data.size.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! @@ -813,7 +875,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 +930,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 +985,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 +1030,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 +1063,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 +1093,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 +1125,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 +1226,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 +1352,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 +1407,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 +1453,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 +1485,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 +1587,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 +1727,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..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.data.getRow(node.data.size.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.data.getRow(end.data.size.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.data.getRow(node.data.size.rows - 1); - if (!row.wrap) break; + if (!appended.last_row_wrapped) break; } } @@ -348,7 +346,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);