aboutsummaryrefslogtreecommitdiff
path: root/vendor/libghostty-vt/src/terminal
diff options
context:
space:
mode:
authorOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:28:05 +0300
committerOgulcan Celik <ogulcancelik@gmail.com>2026-07-16 23:29:28 +0300
commit4a3302d19a2baa3a33e910e0088c24e780905b90 (patch)
treedcaf76fae1c82102d933c9225b17ca7ac5d3a847 /vendor/libghostty-vt/src/terminal
parent550e9f8cc085f428429f5cf2da0bb2dba897e8b8 (diff)
chore: update vendored libghostty-vt
refs #276
Diffstat (limited to 'vendor/libghostty-vt/src/terminal')
-rw-r--r--vendor/libghostty-vt/src/terminal/PageList.zig4215
-rw-r--r--vendor/libghostty-vt/src/terminal/Screen.zig1111
-rw-r--r--vendor/libghostty-vt/src/terminal/Selection.zig92
-rw-r--r--vendor/libghostty-vt/src/terminal/SelectionGesture.zig119
-rw-r--r--vendor/libghostty-vt/src/terminal/Tabstops.zig17
-rw-r--r--vendor/libghostty-vt/src/terminal/Terminal.zig1647
-rw-r--r--vendor/libghostty-vt/src/terminal/apc.zig308
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph.zig164
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md9
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig444
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig367
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/request.zig855
-rw-r--r--vendor/libghostty-vt/src/terminal/apc/glyph/response.zig346
-rw-r--r--vendor/libghostty-vt/src/terminal/bitmap_allocator.zig81
-rw-r--r--vendor/libghostty-vt/src/terminal/c/color.zig575
-rw-r--r--vendor/libghostty-vt/src/terminal/c/color_scheme.zig63
-rw-r--r--vendor/libghostty-vt/src/terminal/c/grid_ref.zig17
-rw-r--r--vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig317
-rw-r--r--vendor/libghostty-vt/src/terminal/c/main.zig23
-rw-r--r--vendor/libghostty-vt/src/terminal/c/render.zig173
-rw-r--r--vendor/libghostty-vt/src/terminal/c/terminal.zig711
-rw-r--r--vendor/libghostty-vt/src/terminal/c/types.zig19
-rw-r--r--vendor/libghostty-vt/src/terminal/c/unicode.zig85
-rw-r--r--vendor/libghostty-vt/src/terminal/clipboard.zig36
-rw-r--r--vendor/libghostty-vt/src/terminal/color.zig199
-rw-r--r--vendor/libghostty-vt/src/terminal/compress.zig15
-rw-r--r--vendor/libghostty-vt/src/terminal/compress/AGENTS.md90
-rw-r--r--vendor/libghostty-vt/src/terminal/compress/Page.zig398
-rw-r--r--vendor/libghostty-vt/src/terminal/compress/lz4.zig891
-rw-r--r--vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig493
-rw-r--r--vendor/libghostty-vt/src/terminal/device_status.zig42
-rw-r--r--vendor/libghostty-vt/src/terminal/formatter.zig146
-rw-r--r--vendor/libghostty-vt/src/terminal/hash_map.zig583
-rw-r--r--vendor/libghostty-vt/src/terminal/hyperlink.zig2
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/color.zig21
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig71
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig85
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig22
-rw-r--r--vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig299
-rw-r--r--vendor/libghostty-vt/src/terminal/main.zig6
-rw-r--r--vendor/libghostty-vt/src/terminal/mem.zig222
-rw-r--r--vendor/libghostty-vt/src/terminal/osc.zig20
-rw-r--r--vendor/libghostty-vt/src/terminal/osc/parsers.zig1
-rw-r--r--vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig363
-rw-r--r--vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig24
-rw-r--r--vendor/libghostty-vt/src/terminal/page.zig336
-rw-r--r--vendor/libghostty-vt/src/terminal/ref_counted_set.zig9
-rw-r--r--vendor/libghostty-vt/src/terminal/render.zig1028
-rw-r--r--vendor/libghostty-vt/src/terminal/search/Thread.zig100
-rw-r--r--vendor/libghostty-vt/src/terminal/search/active.zig16
-rw-r--r--vendor/libghostty-vt/src/terminal/search/pagelist.zig129
-rw-r--r--vendor/libghostty-vt/src/terminal/search/screen.zig482
-rw-r--r--vendor/libghostty-vt/src/terminal/search/sliding_window.zig143
-rw-r--r--vendor/libghostty-vt/src/terminal/search/viewport.zig229
-rw-r--r--vendor/libghostty-vt/src/terminal/stream.zig537
-rw-r--r--vendor/libghostty-vt/src/terminal/stream_terminal.zig509
-rw-r--r--vendor/libghostty-vt/src/terminal/style.zig30
-rw-r--r--vendor/libghostty-vt/src/terminal/x11_color.zig56
59 files changed, 17493 insertions, 1899 deletions
diff --git a/vendor/libghostty-vt/src/terminal/PageList.zig b/vendor/libghostty-vt/src/terminal/PageList.zig
index 63155962..79aa96f1 100644
--- a/vendor/libghostty-vt/src/terminal/PageList.zig
+++ b/vendor/libghostty-vt/src/terminal/PageList.zig
@@ -12,8 +12,10 @@ const fastmem = @import("../fastmem.zig");
const tripwire = @import("../tripwire.zig");
const DoublyLinkedList = @import("../datastruct/main.zig").IntrusiveDoublyLinkedList;
const color = @import("color.zig");
+const compression = @import("compress.zig");
const highlight = @import("highlight.zig");
const kitty = @import("kitty.zig");
+const terminal_mem = @import("mem.zig");
const point = @import("point.zig");
const pagepkg = @import("page.zig");
const stylepkg = @import("style.zig");
@@ -44,8 +46,251 @@ pub const List = DoublyLinkedList(Node);
const Node = struct {
prev: ?*Node = null,
next: ?*Node = null,
- data: Page,
+ data: Data,
serial: u64,
+
+ /// How the backing memory of the embedded Page was allocated. Pool-owned
+ /// memory is always a full standard-size item from the memory
+ /// pool, regardless of the page layout size. Heap-owned memory
+ /// is allocated directly with the page allocator and is exactly
+ /// `Page.memory.len` bytes.
+ ///
+ /// This must never be inferred from the memory length: heap-owned
+ /// pages can be smaller than the standard size (e.g. compacted
+ /// pages), and returning one to the pool would corrupt it.
+ ///
+ /// This has no default on purpose so that every construction site
+ /// is forced to make an explicit decision.
+ owned: Owned,
+
+ /// The physical representation of the page contents.
+ ///
+ /// Both states retain the same `Page.memory` virtual mapping and the same
+ /// `Page` metadata. A resident page can access the mapping directly since
+ /// it is, well, resident.
+ ///
+ /// A compressed page owns an encoded copy while the physical memory
+ /// behind the raw mapping is discarded. HOWEVER, we retain the
+ /// virtual allocation so that restoration is infallible.
+ const Data = union(enum) {
+ resident: Page,
+ compressed: compression.Page,
+ };
+
+ const Owned = enum { pool, heap };
+
+ /// The backing-memory representation currently stored by this node.
+ pub const Storage = enum { resident, compressed };
+
+ /// Return the terminal page stored in this node.
+ ///
+ /// WARNING: This will DECOMPRESS compressed pages! Only use this if
+ /// you need access to the underlying memory. If you only need access to
+ /// metadata (row, col counts etc) then use the other metadata functions.
+ pub inline fn page(self: *Node) *Page {
+ return switch (self.data) {
+ .resident => |*page_| page_,
+ .compressed => self.restore(.preserve),
+ };
+ }
+
+ /// Return the terminal page only when its raw memory is resident.
+ ///
+ /// Unlike `page`, this never restores a compressed node. This is useful
+ /// for diagnostics which can display node metadata without touching the
+ /// discarded mapping, but may inspect page contents when they are already
+ /// available.
+ pub inline fn pageIfResident(self: *Node) ?*Page {
+ return switch (self.data) {
+ .resident => |*page_| page_,
+ .compressed => null,
+ };
+ }
+
+ /// Read-only page with full memory access that doesn't change
+ /// this node's storage state: if a node is compressed it remains
+ /// compressed.
+ ///
+ /// Resident pages are borrowed directly. So they're basically free.
+ ///
+ /// Compressed pages are decoded into an independently owned Page
+ /// by the caller so callers can inspect their contents. Because resident
+ /// pages are still borrowed, you have to continue to have exclusive
+ /// access to the PageList during this operation.
+ pub const PreservedPage = union(enum) {
+ borrowed: *const Page,
+ owned: struct {
+ page: Page,
+ alloc: Allocator,
+ },
+
+ /// Return the read-only page represented by this value.
+ pub fn page(self: *const PreservedPage) *const Page {
+ return switch (self.*) {
+ .borrowed => |page_| page_,
+ .owned => |*owned| &owned.page,
+ };
+ }
+
+ /// Release storage owned by this preserved page.
+ pub fn deinit(self: *PreservedPage) void {
+ switch (self.*) {
+ .borrowed => {},
+ // The clone buffer came from the caller's allocator rather
+ // than Page's OS allocator, so it must not use Page.deinit.
+ .owned => |*owned| owned.alloc.free(owned.page.memory),
+ }
+ self.* = undefined;
+ }
+ };
+
+ /// Return read-only page contents without changing this node's storage.
+ ///
+ /// `alloc` is unused for a resident node. A compressed node uses it for an
+ /// exact-sized, page-aligned decode buffer owned by the returned value.
+ ///
+ /// The caller must call `PreservedPage.deinit` when finished. See
+ /// `PreservedPage` for the synchronization required by its borrowed
+ /// resident representation.
+ pub fn pagePreservingState(
+ self: *const Node,
+ alloc: Allocator,
+ ) Allocator.Error!PreservedPage {
+ return switch (self.data) {
+ .resident => |*page_| .{ .borrowed = page_ },
+ .compressed => |*compressed| owned: {
+ const memory = try alloc.alignedAlloc(
+ u8,
+ .fromByteUnits(std.heap.page_size_min),
+ compressed.page.memory.len,
+ );
+
+ const page_ = compressed.cloneBuf(memory) catch |err| {
+ // The encoded data was produced by our codec and remains
+ // immutable while compressed. Failure is internal
+ // corruption, matching the normal restoration boundary.
+ switch (err) {
+ error.TruncatedInput,
+ error.InvalidOffset,
+ error.OutputTooSmall,
+ error.OutputSizeMismatch,
+ => {},
+ }
+
+ alloc.free(memory);
+ log.err("failed to clone compressed page err={}", .{err});
+ @panic("failed to clone compressed terminal page");
+ };
+
+ break :owned .{ .owned = .{
+ .page = page_,
+ .alloc = alloc,
+ } };
+ },
+ };
+ }
+
+ /// Return the node's backing-memory representation without restoring it.
+ pub inline fn storage(self: *const Node) Storage {
+ return switch (self.data) {
+ .resident => .resident,
+ .compressed => .compressed,
+ };
+ }
+
+ /// Return a page which the caller knows is already resident.
+ ///
+ /// This avoids the representation check in hot paths which already hold
+ /// live pointers into the page mapping. Such pointers are only valid while
+ /// the page is resident. Prefer `page` unless the caller can establish
+ /// that invariant independently.
+ pub inline fn pageAssumeResident(self: *Node) *Page {
+ return &self.data.resident;
+ }
+
+ /// Return the number of populated rows without accessing page memory.
+ pub inline fn rows(self: *const Node) size.CellCountInt {
+ return self.metadata().size.rows;
+ }
+
+ /// Return the current column count without accessing page memory.
+ pub inline fn cols(self: *const Node) size.CellCountInt {
+ return self.metadata().size.cols;
+ }
+
+ /// Return the page capacity without accessing page memory.
+ pub inline fn capacity(self: *const Node) Capacity {
+ return self.metadata().capacity;
+ }
+
+ /// Return the embedded Page metadata without restoring its memory.
+ inline fn metadata(self: *const Node) *const Page {
+ return switch (self.data) {
+ .resident => |*page_| page_,
+ .compressed => |*page_| &page_.page,
+ };
+ }
+
+ const RestoreMode = enum {
+ /// Decode the compressed representation back into the raw mapping.
+ preserve,
+
+ /// Discard the compressed representation without decoding it. The
+ /// caller must not read the page contents before overwriting them.
+ discard,
+ };
+
+ /// Restore this node to the resident representation.
+ ///
+ /// Preserve mode reconstructs the page contents and is used by `page`.
+ /// Discard mode skips decoding for callers which will overwrite or destroy
+ /// the page. Both modes recommit the retained mapping, free the encoded
+ /// allocation, and leave the node in a valid resident state.
+ noinline fn restore(self: *Node, comptime mode: RestoreMode) *Page {
+ const compressed = switch (self.data) {
+ .resident => |*page_| return page_,
+ .compressed => |*page_| page_,
+ };
+
+ // Decommit only discarded the physical pages. Recommit prepares the
+ // still-reserved mapping for decoding or reuse by the caller.
+ terminal_mem.recommit(compressed.page.memory);
+
+ const restored = switch (mode) {
+ .preserve => compressed.restore() catch |err| {
+ // Compressed nodes retain the immutable encoding and exact
+ // output mapping used to create them. Any decode error is an
+ // internal codec bug or memory corruption. Keep this switch
+ // exhaustive so new decoder errors require classification.
+ switch (err) {
+ error.TruncatedInput,
+ error.InvalidOffset,
+ error.OutputTooSmall,
+ error.OutputSizeMismatch,
+ => {},
+ }
+
+ log.err("failed to restore compressed page err={}", .{err});
+ @panic("failed to restore compressed terminal page");
+ },
+
+ .discard => compressed.page,
+ };
+
+ // Remove our compressed data
+ compressed.deinit();
+
+ // We're now a resident, non-compressed node.
+ self.data = .{ .resident = restored };
+ return &self.data.resident;
+ }
+
+ inline fn isCompressed(self: *const Node) bool {
+ return switch (self.data) {
+ .resident => false,
+ .compressed => true,
+ };
+ }
};
/// The memory pool we get page nodes from.
@@ -120,29 +365,42 @@ pool: MemoryPool,
/// The list of pages in the screen.
pages: List,
-/// A monotonically increasing serial number that is incremented each
-/// time a page is allocated or reused as new. The serial is assigned to
-/// the Node.
+/// The next globally unique reference generation for this PageList. A
+/// generation is assigned whenever a page is allocated, reused as new, or
+/// changed in place such that existing page coordinates are no longer stable.
///
/// The serial number can be used to detect whether the page is identical
/// to the page that was originally referenced by a pointer. Since we reuse
/// and pool memory, pointer stability is not guaranteed, but the serial
-/// will always be different for different allocations.
+/// will always be different for different page generations.
///
/// Developer note: we never do overflow checking on this. If we created
/// a new page every second it'd take 584 billion years to overflow. We're
/// going to risk it.
page_serial: u64,
-/// The lowest still valid serial number that could exist. This allows
-/// for quick comparisons to find invalid pages in references.
-page_serial_min: u64,
-
-/// Byte size of the total amount of allocated pages. Note this does
-/// not include the total allocated amount in the pool which may be more
-/// than this due to preheating.
+/// The first serial in the current whole-list validity epoch. Only `reset`
+/// advances this value, immediately before rebuilding every page. It is not
+/// the generation of the first page or the exact minimum live generation.
+/// Page generations are not monotonic in list order: replacement and split
+/// operations can put a fresh generation before older live pages.
+///
+/// A generation below this epoch is definitely invalid, allowing O(1)
+/// rejection in `nodeIsValid` and bulk removal of pre-reset search results.
+/// A generation at or above it is only potentially valid and must still be
+/// checked against the live list before its coordinates are used.
+page_serial_epoch: u64,
+
+/// Byte size of the raw backing mappings owned by active page nodes. This is
+/// logical scrollback accounting and does not change while a mapping is
+/// decommitted. It excludes encoded storage and unused preheated pool items.
page_size: usize,
+/// Continuation state for incremental page compression. This allows
+/// compress(.incremental) to work. More details on all that there and
+/// in the state struct.
+page_compression: IncrementalCompressionState = .{},
+
/// Maximum size of the page allocation in bytes. This only includes pages
/// that are used ONLY for scrollback. If the active area is still partially
/// in a page that also includes scrollback, then that page is not included.
@@ -403,7 +661,7 @@ pub fn init(
.pool = pool,
.pages = page_list,
.page_serial = page_serial,
- .page_serial_min = 0,
+ .page_serial_epoch = 0,
.page_size = page_size,
.explicit_max_size = max_size orelse std.math.maxInt(usize),
.min_max_size = min_max_size,
@@ -444,13 +702,14 @@ fn initPages(
// redundant here for safety.
assert(layout.total_size <= size.max_page_size);
- // If we have an error, we need to clean up our non-standard pages
+ // If we have an error, we need to clean up our heap-owned pages
// since they're not in the pool.
errdefer {
var it = page_list.first;
while (it) |node| : (it = node.next) {
- if (node.data.memory.len > std_size) {
- page_alloc.free(node.data.memory);
+ switch (node.owned) {
+ .pool => {},
+ .heap => page_alloc.free(node.page().memory),
}
}
}
@@ -463,7 +722,9 @@ fn initPages(
const page_buf = if (pooled) buf: {
try tw.check(.page_buf_std);
- break :buf try pool.pages.create();
+ const buf = try pool.pages.create();
+ terminal_mem.recommit(buf);
+ break :buf buf;
} else buf: {
try tw.check(.page_buf_non_std);
break :buf try page_alloc.alignedAlloc(
@@ -488,11 +749,12 @@ fn initPages(
// Initialize the first set of pages to contain our viewport so that
// the top of the first page is always the active area.
node.* = .{
- .data = .initBuf(.init(page_buf), layout),
+ .data = .{ .resident = .initBuf(.init(page_buf), layout) },
.serial = serial.*,
+ .owned = if (pooled) .pool else .heap,
};
- node.data.size.rows = @min(rem, node.data.capacity.rows);
- rem -= node.data.size.rows;
+ node.page().size.rows = @min(rem, node.capacity().rows);
+ rem -= node.rows();
// Add the page to the list
page_list.append(node);
@@ -553,15 +815,14 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void {
{
var node_ = self.pages.first;
while (node_) |node| {
- actual_total += node.data.size.rows;
+ actual_total += node.rows();
node_ = node.next;
- // While doing this traversal, verify no node has a serial
- // number lower than our min.
- if (node.serial < self.page_serial_min) {
+ // Every live node must belong to the current validity epoch.
+ if (node.serial < self.page_serial_epoch) {
log.warn(
- "PageList integrity violation: page serial too low serial={} min={}",
- .{ node.serial, self.page_serial_min },
+ "PageList integrity violation: page serial predates epoch serial={} epoch={}",
+ .{ node.serial, self.page_serial_epoch },
);
return IntegrityError.PageSerialInvalid;
}
@@ -588,7 +849,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void {
var offset: usize = 0;
var node = self.pages.last;
while (node) |n| : (node = n.prev) {
- offset += n.data.size.rows;
+ offset += n.rows();
if (n == self.viewport_pin.node) {
offset -= self.viewport_pin.y;
break :offset self.total_rows - offset;
@@ -624,8 +885,7 @@ fn verifyIntegrity(self: *const PageList) IntegrityError!void {
}
}
-/// Deinit the pagelist. If you own the memory pool (used clonePool) then
-/// this will reset the pool and retain capacity.
+/// Deinit the pagelist, freeing all page memory and the memory pool.
pub fn deinit(self: *PageList) void {
// Verify integrity before cleanup
self.assertIntegrity();
@@ -634,12 +894,14 @@ pub fn deinit(self: *PageList) void {
self.tracked_pins.deinit(self.pool.alloc);
// Go through our linked list and deallocate all pages that are
- // not standard size.
+ // heap-owned (not in the pool).
const page_alloc = self.pool.pages.arena.child_allocator;
var it = self.pages.first;
while (it) |node| : (it = node.next) {
- if (node.data.memory.len > std_size) {
- page_alloc.free(node.data.memory);
+ const page = node.restore(.discard);
+ switch (node.owned) {
+ .pool => {},
+ .heap => page_alloc.free(page.memory),
}
}
@@ -658,10 +920,13 @@ pub fn deinit(self: *PageList) void {
pub fn reset(self: *PageList) void {
defer self.assertIntegrity();
- // Invalidate all external page refs to the previous list. The reset below
- // rebuilds the page list from the pools, so old untracked refs must be
- // rejected before any validation attempts to inspect their node pointers.
- self.page_serial_min = self.page_serial;
+ // Reset discards all scrollback, so there is nothing left to compress.
+ self.page_compression.reset();
+
+ // Begin a new whole-list validity epoch before rebuilding from the pools.
+ // Every old reference now has a serial below the epoch and can be rejected
+ // in O(1), even if the node pool later reuses its pointer address.
+ self.page_serial_epoch = self.page_serial;
// We need enough pages/nodes to keep our active area. This should
// never fail since we by definition have allocated a page already
@@ -678,14 +943,15 @@ pub fn reset(self: *PageList) void {
) catch unreachable;
// Before resetting our pools we need to free any pages that
- // are non-standard size since those were allocated outside
- // the pool.
+ // are heap-owned since those were allocated outside the pool.
{
const page_alloc = self.pool.pages.arena.child_allocator;
var it = self.pages.first;
while (it) |node| : (it = node.next) {
- if (node.data.memory.len > std_size) {
- page_alloc.free(node.data.memory);
+ const page = node.restore(.discard);
+ switch (node.owned) {
+ .pool => {},
+ .heap => page_alloc.free(page.memory),
}
}
}
@@ -825,8 +1091,9 @@ pub fn clone(
const page_alloc = pool.pages.arena.child_allocator;
var page_it = page_list.first;
while (page_it) |node| : (page_it = node.next) {
- if (node.data.memory.len > std_size) {
- page_alloc.free(node.data.memory);
+ switch (node.owned) {
+ .pool => {},
+ .heap => page_alloc.free(node.page().memory),
}
}
}
@@ -840,25 +1107,27 @@ pub fn clone(
// we don't know if the source page has a standard size.
const node = try createPageExt(
&pool,
- chunk.node.data.capacity,
+ .{ .cap = chunk.node.capacity() },
&page_serial,
&page_size,
);
- assert(node.data.capacity.rows >= chunk.end - chunk.start);
- defer node.data.assertIntegrity();
- node.data.size.rows = chunk.end - chunk.start;
- node.data.size.cols = chunk.node.data.size.cols;
- try node.data.cloneFrom(
- &chunk.node.data,
+ const dst_page = node.page();
+ const src_page = chunk.node.page();
+ assert(node.capacity().rows >= chunk.end - chunk.start);
+ defer dst_page.assertIntegrity();
+ dst_page.size.rows = chunk.end - chunk.start;
+ dst_page.size.cols = chunk.node.cols();
+ try dst_page.cloneFrom(
+ src_page,
chunk.start,
chunk.end,
);
- node.data.dirty = chunk.node.data.dirty;
+ dst_page.dirty = src_page.dirty;
page_list.append(node);
- total_rows += node.data.size.rows;
+ total_rows += node.rows();
// Remap our tracked pins by changing the page and
// offsetting the Y position based on the chunk start.
@@ -887,7 +1156,7 @@ pub fn clone(
.pool = pool,
.pages = page_list,
.page_serial = page_serial,
- .page_serial_min = 0,
+ .page_serial_epoch = 0,
.page_size = page_size,
.explicit_max_size = self.explicit_max_size,
.min_max_size = self.min_max_size,
@@ -911,8 +1180,9 @@ pub fn clone(
// now we rarely clone less than the active area and if we do
// the area is by definition very small.
const last = result.pages.last.?;
- const row = &last.data.rows.ptr(last.data.memory)[last.data.size.rows - 1];
- last.data.clearCells(row, 0, result.cols);
+ const page = last.page();
+ const row = &page.rows.ptr(page.memory)[last.rows() - 1];
+ page.clearCells(row, 0, result.cols);
}
// Update our total rows to be our row size.
@@ -952,6 +1222,12 @@ pub const Resize = struct {
pub fn resize(self: *PageList, opts: Resize) Allocator.Error!void {
defer self.assertIntegrity();
+ // Resizing forces all nodes to be decompressed today so we need to
+ // reschedule compression.
+ // TODO(mitchellh): Deferred reflow on non-viewport/non-active pages.
+ self.page_compression.reset();
+ self.page_compression.markActivity();
+
if (comptime std.debug.runtime_safety) {
// Resize does not work with 0 values, this should be protected
// upstream
@@ -1047,6 +1323,16 @@ fn resizeCols(
const wrapped = wrapped: {
var wrapped: usize = 0;
+ // If shrinking rows (in the .lt branch of resize, rows shrink
+ // before we get here) pushed the cursor pin above the new active
+ // area, there are no rows to count and iterating .left_up toward
+ // the active-area top would be an invalid (reversed) range. The
+ // preserved-cursor growth below already no-ops for a cursor that
+ // isn't in the active area, so we just count zero here.
+ if (active_pin) |ap| {
+ if (p.before(ap)) break :wrapped 0;
+ }
+
var row_it = p.rowIterator(.left_up, active_pin);
while (row_it.next()) |next| {
const row = next.rowAndCell().row;
@@ -1075,7 +1361,7 @@ fn resizeCols(
// Create the first node that contains our reflow.
const first_rewritten_node = node: {
- const page = &self.pages.first.?.data;
+ const page = self.pages.first.?.page();
const cap = page.capacity.adjust(
.{ .cols = cols },
) catch |err| err: {
@@ -1092,8 +1378,8 @@ fn resizeCols(
break :err cap;
};
- const node = try self.createPage(cap);
- node.data.size.rows = 1;
+ const node = try self.createPage(.{ .cap = cap });
+ node.page().size.rows = 1;
break :node node;
};
@@ -1134,7 +1420,7 @@ fn resizeCols(
// Once we're done reflowing a page, destroy it immediately.
// This frees memory and makes it more likely in memory
// constrained environments that the next reflow will work.
- if (row.y == row.node.data.size.rows - 1) {
+ if (row.y == row.node.rows() - 1) {
self.destroyNode(row.node);
}
}
@@ -1150,7 +1436,7 @@ fn resizeCols(
var node_it = self.pages.first;
var total: usize = 0;
while (node_it) |node| : (node_it = node.next) {
- total += node.data.size.rows;
+ total += node.rows();
if (total >= self.rows) break;
} else {
for (total..self.rows) |_| _ = try self.grow();
@@ -1223,7 +1509,7 @@ const ReflowCursor = struct {
total_rows: usize,
fn init(node: *List.Node) ReflowCursor {
- const page = &node.data;
+ const page = node.page();
const rows = page.rows.ptr(page.memory);
return .{
.x = 0,
@@ -1236,7 +1522,7 @@ const ReflowCursor = struct {
.new_rows = 0,
// Initially whatever size our input node is.
- .total_rows = node.data.size.rows,
+ .total_rows = node.rows(),
};
}
@@ -1247,7 +1533,7 @@ const ReflowCursor = struct {
row: Pin,
cursor_pin: ?*Pin,
) Allocator.Error!void {
- const src_page: *Page = &row.node.data;
+ const src_page: *Page = row.node.page();
const src_row = row.rowAndCell().row;
const src_y = row.y;
const cells = src_row.cells.ptr(src_page.memory)[0..src_page.size.cols];
@@ -1270,7 +1556,7 @@ const ReflowCursor = struct {
{
const pin_keys = list.tracked_pins.keys();
for (pin_keys) |p| {
- if (&p.node.data != src_page or
+ if (p.node.page() != src_page or
p.y != src_y) continue;
if (cursor_pin != null and p == cursor_pin.?) continue;
@@ -1293,7 +1579,7 @@ const ReflowCursor = struct {
// If the cursor is after blanks on the right, those cells are still
// before the next write and must reflow with it.
if (cursor_pin) |p| {
- if (&p.node.data == src_page and p.y == src_y) {
+ if (p.node.page() == src_page and p.y == src_y) {
cols_len = @max(cols_len, p.x + 1);
}
}
@@ -1344,7 +1630,7 @@ const ReflowCursor = struct {
{
const pin_keys = list.tracked_pins.keys();
for (pin_keys) |p| {
- if (&p.node.data != src_page or
+ if (p.node.page() != src_page or
p.y != src_y or
p.x != x) continue;
@@ -1369,7 +1655,7 @@ const ReflowCursor = struct {
// since we won't process that cell in the loop.
const pin_keys = list.tracked_pins.keys();
for (pin_keys) |p| {
- if (&p.node.data != src_page or
+ if (p.node.page() != src_page or
p.y != src_y or
p.x != x + 1) continue;
@@ -1853,7 +2139,7 @@ const ReflowCursor = struct {
{
const pin_keys = list.tracked_pins.keys();
for (pin_keys) |p| {
- if (&p.node.data != old_page or
+ if (p.node.page() != old_page or
p.y != old_page.size.rows - 1) continue;
p.node = self.node;
@@ -1954,9 +2240,9 @@ const ReflowCursor = struct {
// after reinitializing our cursor on the new page.
const new_rows = self.new_rows;
- const node = try list.createPage(cap);
+ const node = try list.createPage(.{ .cap = cap });
errdefer comptime unreachable;
- node.data.size.rows = 1;
+ node.page().size.rows = 1;
list.pages.insertAfter(self.node, node);
self.* = .init(node);
@@ -2056,7 +2342,7 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void {
.lt => {
var it = self.pageIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |chunk| {
- const page = &chunk.node.data;
+ const page = chunk.node.page();
defer page.assertIntegrity();
const rows = page.rows.ptr(page.memory);
for (0..page.size.rows) |i| {
@@ -2153,12 +2439,17 @@ fn resizeWithoutReflow(self: *PageList, opts: Resize) Allocator.Error!void {
// just update our rows and we're done. This effectively
// "pulls down" scrollback.
//
+ // This traversal intentionally reads only node metadata. A
+ // compressed history page pulled into the active area remains
+ // compressed until a renderer or other content consumer goes
+ // through `Node.page`, which restores it transparently.
+ //
// If we don't have enough scrollback, we add the difference,
// to the active area.
var count: usize = 0;
var page = self.pages.first;
while (page) |p| : (page = p.next) {
- count += p.data.size.rows;
+ count += p.rows();
if (count >= rows) break;
} else {
assert(count < rows);
@@ -2189,7 +2480,7 @@ fn resizeWithoutReflowGrowCols(
chunk: PageIterator.Chunk,
) Allocator.Error!void {
assert(cols > self.cols);
- const page = &chunk.node.data;
+ const page = chunk.node.page();
// Update our col count
const old_cols = self.cols;
@@ -2263,7 +2554,7 @@ fn resizeWithoutReflowGrowCols(
// This can fail for many reasons (can't fit styles/graphemes, etc.) so
// if it fails then we give up and drop back into creating new pages.
if (prev) |prev_node| prev: {
- const prev_page = &prev_node.data;
+ const prev_page = prev_node.page();
// We only want scenarios where we have excess capacity.
if (prev_page.size.rows >= prev_page.capacity.rows) break :prev;
@@ -2308,7 +2599,7 @@ fn resizeWithoutReflowGrowCols(
// If we have an error, we clear the rows we just added to our prev page.
const prev_copied = copied;
errdefer if (prev_copied > 0) {
- const prev_page = &prev.?.data;
+ const prev_page = prev.?.page();
const prev_size = prev_page.size.rows - prev_copied;
const prev_rows = prev_page.rows.ptr(prev_page.memory)[prev_size..prev_page.size.rows];
for (prev_rows) |*row| prev_page.clearCells(
@@ -2333,8 +2624,9 @@ fn resizeWithoutReflowGrowCols(
// We need to loop because our col growth may force us
// to split pages.
while (copied < page.size.rows) {
- const new_node = try self.createPage(cap);
- defer new_node.data.assertIntegrity();
+ const new_node = try self.createPage(.{ .cap = cap });
+ const new_page = new_node.page();
+ defer new_page.assertIntegrity();
// The length we can copy into the new page is at most the number
// of rows in our cap. But if we can finish our source page we use that.
@@ -2343,10 +2635,10 @@ fn resizeWithoutReflowGrowCols(
// Perform the copy
const y_start = copied;
const src_rows = page.rows.ptr(page.memory)[y_start .. copied + len];
- const dst_rows = new_node.data.rows.ptr(new_node.data.memory)[0..len];
+ const dst_rows = new_page.rows.ptr(new_page.memory)[0..len];
for (dst_rows, src_rows) |*dst_row, *src_row| {
- new_node.data.size.rows += 1;
- if (new_node.data.cloneRowFrom(
+ new_page.size.rows += 1;
+ if (new_page.cloneRowFrom(
page,
dst_row,
src_row,
@@ -2364,7 +2656,7 @@ fn resizeWithoutReflowGrowCols(
// We can actually safely handle this though by exiting
// this loop early and cutting our copy short.
- new_node.data.size.rows -= 1;
+ new_page.size.rows -= 1;
break;
}
}
@@ -2405,12 +2697,13 @@ fn trailingBlankLines(
// Go through our pages backwards since we're counting trailing blanks.
var it = self.pages.last;
- while (it) |page| : (it = page.prev) {
- const len = page.data.size.rows;
- const rows = page.data.rows.ptr(page.data.memory)[0..len];
+ while (it) |node| : (it = node.prev) {
+ const page = node.page();
+ const len = node.rows();
+ const rows = page.rows.ptr(page.memory)[0..len];
for (0..len) |i| {
const rev_i = len - i - 1;
- const cells = rows[rev_i].cells.ptr(page.data.memory)[0..page.data.size.cols];
+ const cells = rows[rev_i].cells.ptr(page.memory)[0..node.cols()];
// If the row has any text then we're done.
if (pagepkg.Cell.hasTextAny(cells)) return count;
@@ -2436,6 +2729,7 @@ fn trimTrailingBlankRows(
max: size.CellCountInt,
) size.CellCountInt {
var trimmed: size.CellCountInt = 0;
+ var invalidated_node: ?*List.Node = null;
const bl_pin = self.getBottomRight(.screen).?;
var it = bl_pin.rowIterator(.left_up, null);
while (it.next()) |row_pin| {
@@ -2457,11 +2751,16 @@ fn trimTrailingBlankRows(
// No text, we can trim this row. Because it has
// no text we can also be sure it has no styling
// so we don't need to worry about memory.
- row_pin.node.data.size.rows -= 1;
- if (row_pin.node.data.size.rows == 0) {
+ if (row_pin.node.rows() > 1 and invalidated_node != row_pin.node) {
+ // Shrinking a retained page changes its valid row-coordinate range.
+ self.invalidateNodeLayout(row_pin.node);
+ invalidated_node = row_pin.node;
+ }
+ row_pin.node.page().size.rows -= 1;
+ if (row_pin.node.page().size.rows == 0) {
self.erasePage(row_pin.node);
} else {
- row_pin.node.data.assertIntegrity();
+ row_pin.node.page().assertIntegrity();
}
trimmed += 1;
@@ -2514,6 +2813,14 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
return;
}
+ // Moving the viewport changes which historical pages are visible. Restart
+ // traversal so pages which leave the viewport are reconsidered after the
+ // renderer's idle delay. False positives from clamped scrolling are cheap.
+ defer {
+ self.page_compression.reset();
+ self.page_compression.markActivity();
+ }
+
switch (behavior) {
.active => self.viewport = .active,
.top => self.viewport = .top,
@@ -2572,44 +2879,38 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
if (n < midpoint) {
// Iterate forward from the first node.
var node_it = self.pages.first;
- var rem: size.CellCountInt = std.math.cast(
- size.CellCountInt,
- n,
- ) orelse {
- self.viewport = .active;
- break :row;
- };
+ var rem: usize = n;
while (node_it) |node| : (node_it = node.next) {
- if (rem < node.data.size.rows) {
+ if (rem < node.rows()) {
self.viewport_pin.* = .{
.node = node,
- .y = rem,
+ .y = std.math.cast(size.CellCountInt, rem) orelse {
+ self.viewport = .active;
+ break :row;
+ },
};
break :row;
}
- rem -= node.data.size.rows;
+ rem -= node.rows();
}
} else {
// Iterate backwards from the last node.
var node_it = self.pages.last;
- var rem: size.CellCountInt = std.math.cast(
- size.CellCountInt,
- self.total_rows - n,
- ) orelse {
- self.viewport = .active;
- break :row;
- };
+ var rem: usize = self.total_rows - n;
while (node_it) |node| : (node_it = node.prev) {
- if (rem <= node.data.size.rows) {
+ if (rem <= node.rows()) {
self.viewport_pin.* = .{
.node = node,
- .y = node.data.size.rows - rem,
+ .y = std.math.cast(size.CellCountInt, node.rows() - rem) orelse {
+ self.viewport = .active;
+ break :row;
+ },
};
break :row;
}
- rem -= node.data.size.rows;
+ rem -= node.rows();
}
}
@@ -2619,6 +2920,8 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
},
.delta_prompt => |n| self.scrollPrompt(n),
.delta_row => |n| delta_row: {
+ const amount: usize = @abs(n);
+
switch (self.viewport) {
// If we're at the top and we're scrolling backwards,
// we don't have to do anything, because there's nowhere to go.
@@ -2635,11 +2938,11 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
.pin => switch (std.math.order(n, 0)) {
.eq => break :delta_row,
- .lt => switch (self.viewport_pin.upOverflow(@intCast(-n))) {
+ .lt => switch (self.viewport_pin.upOverflow(amount)) {
.offset => |new_pin| {
self.viewport_pin.* = new_pin;
if (self.viewport_pin_row_offset) |*v| {
- v.* -= @as(usize, @intCast(-n));
+ v.* -= amount;
}
break :delta_row;
},
@@ -2651,7 +2954,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
},
},
- .gt => switch (self.viewport_pin.downOverflow(@intCast(n))) {
+ .gt => switch (self.viewport_pin.downOverflow(amount)) {
// If we offset its a valid pin but we still have to
// check if we're in the active area.
.offset => |new_pin| {
@@ -2660,7 +2963,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
} else {
self.viewport_pin.* = new_pin;
if (self.viewport_pin_row_offset) |*v| {
- v.* += @intCast(n);
+ v.* += amount;
}
}
break :delta_row;
@@ -2678,10 +2981,10 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
// Slow path: we have to calculate the new pin by moving
// from our viewport.
const top = self.getTopLeft(.viewport);
- const p: Pin = if (n < 0) switch (top.upOverflow(@intCast(-n))) {
+ const p: Pin = if (n < 0) switch (top.upOverflow(amount)) {
.offset => |v| v,
.overflow => |v| v.end,
- } else switch (top.downOverflow(@intCast(n))) {
+ } else switch (top.downOverflow(amount)) {
.offset => |v| v,
.overflow => |v| v.end,
};
@@ -2719,7 +3022,7 @@ pub fn scroll(self: *PageList, behavior: Scroll) void {
fn scrollPrompt(self: *PageList, delta: isize) void {
// If we aren't jumping any prompts then we don't need to do anything.
if (delta == 0) return;
- const delta_start: usize = @intCast(if (delta > 0) delta else -delta);
+ const delta_start: usize = @abs(delta);
var delta_rem: usize = delta_start;
// We start at the row before or after our viewport depending on the
@@ -2781,11 +3084,12 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void {
var page = self.pages.last.?;
var n: usize = 0;
while (true) {
- const rows: [*]Row = page.data.rows.ptr(page.data.memory);
- for (0..page.data.size.rows) |i| {
- const rev_i = page.data.size.rows - i - 1;
+ const current_page = page.page();
+ const rows: [*]Row = current_page.rows.ptr(current_page.memory);
+ for (0..page.rows()) |i| {
+ const rev_i = page.rows() - i - 1;
const row = rows[rev_i];
- const cells = row.cells.ptr(page.data.memory)[0..self.cols];
+ const cells = row.cells.ptr(current_page.memory)[0..self.cols];
for (cells) |cell| {
if (!cell.isEmpty()) break :non_empty self.rows - n;
}
@@ -2802,40 +3106,90 @@ pub fn scrollClear(self: *PageList) Allocator.Error!void {
for (0..non_empty) |_| _ = try self.grow();
}
+/// Give a live node a new generation before changing its coordinate layout in
+/// place.
+///
+/// This must be called when a node remains at the same address and stays in the
+/// list, but a mutation changes which logical row a `(node, y)` coordinate
+/// identifies or whether that coordinate is still in range. Examples include
+/// rotating rows after an erase, truncating a page's row range, reinitializing
+/// the sole remaining page, or shortening the source page of a split. Without
+/// a new generation, a cached pointer, serial, and coordinate could still pass
+/// `nodeIsValid` while referring to a different row than it originally did.
+/// Screen and Terminal fast paths which manipulate Page rows directly must use
+/// this because they bypass PageList's own row-mutation helpers.
+///
+/// The caller must pass a node which is currently live in this PageList. Call
+/// this at the point the operation commits to changing the layout and before
+/// the first such change. When an operation is intended to be atomic, finish
+/// its failable preparation first so a failed operation does not needlessly
+/// invalidate references. One call per affected node is sufficient when an
+/// operation performs several layout changes without exposing intermediate
+/// references.
+///
+/// This helper is only needed for in-place changes. Removing or replacing a
+/// node already invalidates old references through the live-list check in
+/// `nodeIsValid`, and newly allocated or reused nodes receive a fresh serial as
+/// part of their initialization. Ordinary cell or style changes which preserve
+/// the meaning of page coordinates do not require a new generation solely for
+/// that reason.
+///
+/// The only state changed here is `node.serial` and the next-generation counter
+/// `page_serial`. Consequently, every previously captured pointer-plus-serial
+/// pair for this node becomes invalid while new references can capture the new
+/// generation. This does not advance `page_serial_epoch`: only `reset` starts a
+/// new whole-list validity epoch.
+///
+/// This also does not mutate the page, update tracked pins or viewport state,
+/// adjust row or memory accounting, mark cells dirty, or notify incremental
+/// compression. The surrounding operation remains responsible for all such
+/// bookkeeping required by its layout change.
+pub fn invalidateNodeLayout(self: *PageList, node: *List.Node) void {
+ node.serial = self.page_serial;
+ self.page_serial += 1;
+}
+
/// Compact a page to use the minimum required memory for the contents
/// it stores. Returns the new node pointer if compaction occurred, or null
-/// if the page was already compact or compaction would not provide meaningful
+/// if the page was already compact or compaction would not provide any
/// savings.
///
-/// The current design of PageList at the time of writing this doesn't
-/// allow for smaller than `std_size` nodes so if the current node's backing
-/// page is standard size or smaller, no compaction will occur. In the
-/// future we should fix this up.
+/// The compacted page is always an exact-size heap allocation, never
+/// a pool item, since a pool item always retains a full std_size
+/// buffer regardless of the page layout. Note that this means that
+/// when compacting a pool-owned node, the freed pool item is returned
+/// to the pool free list, so the memory savings are only fully
+/// realized once the pool itself is reset or freed.
///
/// If this returns OOM, the PageList is left unchanged and no dangling
/// memory references exist. It is safe to ignore the error and continue using
/// the uncompacted page.
pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node {
defer self.assertIntegrity();
- const page: *Page = &node.data;
+ const page: *Page = node.page();
// We should never have empty rows in our pagelist anyways...
assert(page.size.rows > 0);
- // We never compact standard size or smaller pages because changing
- // the capacity to something smaller won't save memory.
- if (page.memory.len <= std_size) return null;
-
// Compute the minimum capacity required for this page's content
const req_cap = page.exactRowCapacity(0, page.size.rows);
const new_size = Page.layout(req_cap).total_size;
- const old_size = page.memory.len;
+
+ // The memory this node currently retains. A pool-owned node always
+ // retains a full pool item no matter its layout size.
+ const old_size: usize = switch (node.owned) {
+ .pool => PagePool.item_size,
+ .heap => page.memory.len,
+ };
if (new_size >= old_size) return null;
// Create the new smaller page
- const new_node = try self.createPage(req_cap);
+ const new_node = try self.createPage(.{
+ .cap = req_cap,
+ .exact_size = true,
+ });
errdefer self.destroyNode(new_node);
- const new_page: *Page = &new_node.data;
+ const new_page: *Page = new_node.page();
new_page.size = page.size;
new_page.dirty = page.dirty;
new_page.cloneFrom(
@@ -2866,6 +3220,7 @@ pub fn compact(self: *PageList, node: *List.Node) Allocator.Error!?*List.Node {
self.pages.insertBefore(node, new_node);
self.pages.remove(node);
self.destroyNode(node);
+ self.page_compression.markActivity();
new_page.assertIntegrity();
return new_node;
@@ -2896,7 +3251,7 @@ pub fn split(
// when we modify it later it updates our p here. Copying the node
// fixes this.
const original_node = p.node;
- const page: *Page = &original_node.data;
+ const page: *Page = original_node.page();
// A page that is already 1 row can't be split. In the future we can
// theoretically maybe split by soft-wrapping multiple pages but that
@@ -2912,18 +3267,18 @@ pub fn split(
defer self.assertIntegrity();
// Create a new node with the same capacity of managed memory.
- const target = try self.createPage(page.capacity);
+ const target = try self.createPage(.{ .cap = page.capacity });
errdefer self.destroyNode(target);
// Determine how many rows we're copying
const y_start = p.y;
const y_end = page.size.rows;
- target.data.size.rows = y_end - y_start;
- assert(target.data.size.rows <= target.data.capacity.rows);
+ target.page().size.rows = y_end - y_start;
+ assert(target.rows() <= target.capacity().rows);
// Copy our old data. This should NOT fail because we have the
// capacity of the old page which already fits the data we requested.
- target.data.cloneFrom(page, y_start, y_end) catch |err| {
+ target.page().cloneFrom(page, y_start, y_end) catch |err| {
log.err(
"error cloning rows for split err={}",
.{err},
@@ -2939,9 +3294,13 @@ pub fn split(
// error handling. It is possible but we haven't written it.
errdefer comptime unreachable;
+ // Failable split work is complete; shortening the source changes its row range.
+ self.invalidateNodeLayout(original_node);
+ self.page_compression.markActivity();
+
// Move any tracked pins from the copied rows
for (self.tracked_pins.keys()) |tracked| {
- if (&tracked.node.data != page or
+ if (tracked.node.page() != page or
tracked.y < p.y) continue;
tracked.node = target;
@@ -3008,9 +3367,11 @@ pub const Scrollbar = struct {
/// Return the scrollbar state for this PageList.
///
-/// This may be expensive to calculate depending on where the viewport
-/// is (arbitrary pins are expensive). The caller should take care to only
-/// call this as needed and not too frequently.
+/// This is amortized O(1): the total is maintained incrementally and
+/// the viewport offset is cached. The first call after the viewport
+/// moves to an arbitrary pin (e.g. scrolling to a selection) may cost
+/// O(pages) to compute the offset, after which it is cached again.
+/// See viewportRowOffset for more details.
pub fn scrollbar(self: *PageList) Scrollbar {
// If we have no scrollback, special case no scrollbar.
// We need to do this because the way PageList works is that
@@ -3057,9 +3418,9 @@ fn viewportRowOffset(self: *PageList) usize {
var offset: usize = 0;
var node = self.pages.last;
while (node) |n| : (node = n.prev) {
- offset += n.data.size.rows;
+ offset += n.rows();
if (n == self.viewport_pin.node) {
- assert(n.data.size.rows > self.viewport_pin.y);
+ assert(n.rows() > self.viewport_pin.y);
offset -= self.viewport_pin.y;
break :offset self.total_rows - offset;
}
@@ -3134,11 +3495,15 @@ pub fn maxSize(self: *const PageList) usize {
pub fn grow(self: *PageList) Allocator.Error!?*List.Node {
defer self.assertIntegrity();
+ // Growing can move a complete page behind the active boundary.
+ self.page_compression.markActivity();
+
const last = self.pages.last.?;
- if (last.data.capacity.rows > last.data.size.rows) {
+ if (last.capacity().rows > last.rows()) {
// Fast path: we have capacity in the last page.
- last.data.size.rows += 1;
- last.data.assertIntegrity();
+ const page = last.page();
+ page.size.rows += 1;
+ page.assertIntegrity();
// Increase our total rows by one
self.total_rows += 1;
@@ -3168,7 +3533,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node {
assert(first != last);
// Decrease our total row count from the pruned page
- self.total_rows -= first.data.size.rows;
+ self.total_rows -= first.rows();
// If our total row count is now less than our required
// rows then we can't prune. The "+ 1" is because we'll add one
@@ -3176,7 +3541,7 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node {
if (self.total_rows + 1 < self.rows) {
self.pages.prepend(first);
assert(self.pages.first == first);
- self.total_rows += first.data.size.rows;
+ self.total_rows += first.rows();
break :prune;
}
@@ -3185,14 +3550,14 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node {
if (self.viewport_pin_row_offset) |*v| {
// If our offset is less than the number of rows in the
// pruned page, then we are now at the top.
- if (v.* < first.data.size.rows) {
+ if (v.* < first.rows()) {
self.viewport = .top;
break :viewport;
}
// Otherwise, our viewport pin is below what we pruned
// so we just decrement our offset.
- v.* -= first.data.size.rows;
+ v.* -= first.rows();
}
}
@@ -3208,48 +3573,56 @@ pub fn grow(self: *PageList) Allocator.Error!?*List.Node {
}
self.viewport_pin.garbage = false;
- // Non-standard pages can't be reused, just destroy them.
- if (first.data.memory.len > std_size) {
- self.destroyNode(first);
- break :prune;
+ switch (first.owned) {
+ // Pool-owned pages are reused below.
+ .pool => {},
+
+ // Heap-owned pages can't be reused because they may be
+ // any size (larger or smaller than a standard page), so
+ // just destroy them.
+ .heap => {
+ self.destroyNode(first);
+ break :prune;
+ },
}
// Reset our memory
- const buf = first.data.memory;
+ const buf = first.restore(.discard).memory;
@memset(buf, 0);
assert(buf.len <= std_size);
// Initialize our new page and reinsert it as the last
- first.data = .initBuf(.init(buf), Page.layout(cap));
- first.data.size.rows = 1;
+ first.data = .{ .resident = .initBuf(.init(buf), Page.layout(cap)) };
+ const page = first.page();
+ page.size.rows = 1;
self.pages.insertAfter(last, first);
self.total_rows += 1;
- // We also need to reset the serial number. Since this is the only
- // place we ever reuse a serial number, we also can safely set
- // page_serial_min to be one more than the old serial because we
- // only ever prune the oldest pages.
- self.page_serial_min = first.serial + 1;
+ // Reusing the node gives it a fresh generation. Do not begin a new
+ // page_serial_epoch here: generations are not monotonic in list order,
+ // so older live successors may have lower generations. The epoch only
+ // advances when reset invalidates the entire list.
first.serial = self.page_serial;
self.page_serial += 1;
// In this case we do NOT need to update page_size because
// we're reusing an existing page so nothing has changed.
- first.data.assertIntegrity();
+ page.assertIntegrity();
return first;
}
// We need to allocate a new memory buffer.
- const next_node = try self.createPage(cap);
+ const next_node = try self.createPage(.{ .cap = cap });
// we don't errdefer this because we've added it to the linked
// list and its fine to have dangling unused pages.
self.pages.append(next_node);
- next_node.data.size.rows = 1;
+ const page = next_node.page();
+ page.size.rows = 1;
// We should never be more than our max size here because we've
// verified the case above.
- next_node.data.assertIntegrity();
+ page.assertIntegrity();
// Record the increased row count
self.total_rows += 1;
@@ -3296,7 +3669,7 @@ pub fn increaseCapacity(
adjustment: ?IncreaseCapacity,
) IncreaseCapacityError!*List.Node {
defer self.assertIntegrity();
- const page: *Page = &node.data;
+ const page: *Page = node.page();
// Apply our adjustment
var cap = page.capacity;
@@ -3306,21 +3679,36 @@ pub fn increaseCapacity(
const Int = @FieldType(Capacity, field_name);
const old = @field(cap, field_name);
- // We use checked math to prevent overflow. If there is an
- // overflow it means we're out of space in this dimension,
- // since pages can take up to their maxInt capacity in any
- // category.
- const new = std.math.mul(
- Int,
- old,
- 2,
- ) catch |err| overflow: {
- comptime assert(@TypeOf(err) == error{Overflow});
- // Our final doubling would overflow since maxInt is
- // 2^N - 1 for an unsignged int of N bits. So, if we overflow
- // and we haven't used all the bits, use all the bits.
- if (old < std.math.maxInt(Int)) break :overflow std.math.maxInt(Int);
- return error.OutOfSpace;
+ const new: Int = new: {
+ // A dimension can be zero for pages with exact
+ // capacities (see compact). Doubling zero stays zero,
+ // which would break our guarantee that we always
+ // increase by at least one unit and turn caller retry
+ // loops into infinite loops. Jump straight to the
+ // standard default instead: it is what all standard
+ // pages start with, so retrying callers are guaranteed
+ // enough room for their pending allocation.
+ if (old == 0) {
+ const default: Capacity = .{ .cols = 0, .rows = 0 };
+ break :new @field(default, field_name);
+ }
+
+ // We use checked math to prevent overflow. If there is
+ // an overflow it means we're out of space in this
+ // dimension, since pages can take up to their maxInt
+ // capacity in any category.
+ break :new std.math.mul(
+ Int,
+ old,
+ 2,
+ ) catch |err| overflow: {
+ comptime assert(@TypeOf(err) == error{Overflow});
+ // Our final doubling would overflow since maxInt is
+ // 2^N - 1 for an unsignged int of N bits. So, if we overflow
+ // and we haven't used all the bits, use all the bits.
+ if (old < std.math.maxInt(Int)) break :overflow std.math.maxInt(Int);
+ return error.OutOfSpace;
+ };
};
@field(cap, field_name) = new;
@@ -3337,9 +3725,9 @@ pub fn increaseCapacity(
log.info("adjusting page capacity={}", .{cap});
// Create our new page and clone the old page into it.
- const new_node = try self.createPage(cap);
+ const new_node = try self.createPage(.{ .cap = cap });
errdefer self.destroyNode(new_node);
- const new_page: *Page = &new_node.data;
+ const new_page: *Page = new_node.page();
assert(new_page.capacity.rows >= page.capacity.rows);
assert(new_page.capacity.cols >= page.capacity.cols);
new_page.size.rows = page.size.rows;
@@ -3375,21 +3763,35 @@ pub fn increaseCapacity(
self.pages.insertBefore(node, new_node);
self.pages.remove(node);
self.destroyNode(node);
+ self.page_compression.markActivity();
new_page.assertIntegrity();
return new_node;
}
+/// Options for createPage and createPageExt.
+const CreatePage = struct {
+ /// The capacity to allocate the page with.
+ cap: Capacity,
+
+ /// Force the page backing memory to be an exact-size heap
+ /// allocation even if it would fit within a standard-size pool
+ /// item. This is used when compacting pages to their minimum
+ /// size, since a pool item always retains a full std_size buffer
+ /// regardless of the page layout.
+ exact_size: bool = false,
+};
+
/// Create a new page node. This does not add it to the list and this
/// does not do any memory size accounting with max_size/page_size.
inline fn createPage(
self: *PageList,
- cap: Capacity,
+ opts: CreatePage,
) Allocator.Error!*List.Node {
- // log.debug("create page cap={}", .{cap});
+ // log.debug("create page cap={}", .{opts.cap});
return try createPageExt(
&self.pool,
- cap,
+ opts,
&self.page_serial,
&self.page_size,
);
@@ -3397,15 +3799,15 @@ inline fn createPage(
inline fn createPageExt(
pool: *MemoryPool,
- cap: Capacity,
+ opts: CreatePage,
serial: *u64,
total_size: ?*usize,
) Allocator.Error!*List.Node {
var page = try pool.nodes.create();
errdefer pool.nodes.destroy(page);
- const layout = Page.layout(cap);
- const pooled = layout.total_size <= std_size;
+ const layout = Page.layout(opts.cap);
+ const pooled = !opts.exact_size and layout.total_size <= std_size;
const page_alloc = pool.pages.arena.child_allocator;
// It would be better to encode this into the Zig error handling
@@ -3416,14 +3818,15 @@ inline fn createPageExt(
// Our page buffer comes from our standard memory pool if it
// is within our standard size since this is what the pool
// dispenses. Otherwise, we use the heap allocator to allocate.
- const page_buf = if (pooled)
- try pool.pages.create()
- else
- try page_alloc.alignedAlloc(
- u8,
- .fromByteUnits(std.heap.page_size_min),
- layout.total_size,
- );
+ const page_buf = if (pooled) buf: {
+ const buf = try pool.pages.create();
+ terminal_mem.recommit(buf);
+ break :buf buf;
+ } else try page_alloc.alignedAlloc(
+ u8,
+ .fromByteUnits(std.heap.page_size_min),
+ layout.total_size,
+ );
errdefer if (pooled)
pool.pages.destroy(page_buf)
else
@@ -3431,14 +3834,23 @@ inline fn createPageExt(
// In runtime safety modes, allocators fill with 0xAA. On freestanding
// (WASM), the WasmAllocator reuses freed slots without zeroing.
+ //
+ // Otherwise, we rely on pool item buffers being zeroed: fresh items
+ // come from the OS page allocator (zeroed pages) and destroyNodeExt
+ // zeroes buffers before returning them to the pool. The one
+ // exception is the first pointer-size bytes, which hold the pool's
+ // free list node while a buffer is in the free list; initBuf below
+ // always overwrites those since the page rows start at offset 0
+ // (comptime-asserted in Page).
if (comptime std.debug.runtime_safety or builtin.os.tag == .freestanding)
@memset(page_buf, 0);
page.* = .{
- .data = .initBuf(.init(page_buf), layout),
+ .data = .{ .resident = .initBuf(.init(page_buf), layout) },
.serial = serial.*,
+ .owned = if (pooled) .pool else .heap,
};
- page.data.size.rows = 0;
+ page.page().size.rows = 0;
serial.* += 1;
if (total_size) |v| {
@@ -3451,6 +3863,410 @@ inline fn createPageExt(
return page;
}
+/// Temporary output memory used while creating a compressed page.
+///
+/// Standard-sized output borrows a page-pool item so repeated compression can
+/// reuse the same virtual mapping. Oversized pages use a temporary allocation
+/// from the page allocator and release it immediately after compression.
+const CompressionScratch = union(enum) {
+ pooled: *align(std.heap.page_size_min) [std_size]u8,
+ allocated: []align(std.heap.page_size_min) u8,
+
+ fn init(
+ pool: *MemoryPool,
+ required: usize,
+ raw_len: usize,
+ ) Allocator.Error!CompressionScratch {
+ assert(required <= raw_len);
+
+ if (required <= std_size) {
+ const memory = try pool.pages.create();
+ terminal_mem.recommit(memory);
+ return .{ .pooled = memory };
+ }
+
+ const page_alloc = pool.pages.arena.child_allocator;
+ return .{ .allocated = try page_alloc.alignedAlloc(
+ u8,
+ .fromByteUnits(std.heap.page_size_min),
+ raw_len,
+ ) };
+ }
+
+ fn bytes(self: *CompressionScratch) []u8 {
+ return switch (self.*) {
+ .pooled => |memory| memory,
+ .allocated => |memory| memory,
+ };
+ }
+
+ fn deinit(self: *CompressionScratch, pool: *MemoryPool) void {
+ switch (self.*) {
+ .pooled => |memory| {
+ _ = terminal_mem.decommit(.zero, memory, memory.len);
+ pool.pages.destroy(memory);
+ },
+ .allocated => |memory| {
+ const page_alloc = pool.pages.arena.child_allocator;
+ page_alloc.free(memory);
+ },
+ }
+ }
+};
+
+/// PageList-owned state for incremental compression.
+///
+/// The position is stored as a page serial rather than a node pointer so it
+/// remains safe when PageList operations destroy, replace, or reuse nodes
+/// between steps. It also records the first serial which was unallocated at
+/// the prior step so new nodes before a valid marker restart safely. If the
+/// exact serial no longer exists in the cold prefix, the next step also
+/// restarts at the first page.
+///
+/// A completed pass is followed by another pass from the oldest cold page.
+/// Compression becomes idle only when that verification pass compresses no
+/// pages. This converges after pages are restored between incremental steps
+/// without requiring consumers to maintain a separate attempt cursor.
+const IncrementalCompressionState = struct {
+ flags: packed struct {
+ /// Set after any page is compressed during the current traversal.
+ /// We always run incremental compression until we get a fully
+ /// no-compression pass (the verification pass). This lets us
+ /// recompress decompressed pages due to search, scrolling, etc.
+ did_compress: bool = false,
+
+ /// Set after a traversal first reaches the active boundary. When this
+ /// verification traversal reaches the boundary, `did_compress`
+ /// determines whether to restart once more or finish the pass.
+ verifying: bool = false,
+ } = .{},
+
+ /// Changes whenever PageList activity may affect compression work.
+ /// Callers use this value (via Terminal.compressionActivity) to
+ /// determine whether to recompress.
+ ///
+ /// The directionality of this doesn't matter. We overflow and
+ /// wrap when maxxed. The comparison of this value to your saved
+ /// value is all that matters. There is a possible edge case where you
+ /// don't compress, a full wraparound happens, and you get the same value,
+ /// but its so unlikely.
+ ///
+ /// This is 48-bits so that 16-bits can be reserved for Terminal to
+ /// add extra state.
+ activity_serial: u48 = 0,
+
+ /// Serial of the last page inspected by the traversal. This is
+ /// intentionally an implementation detail and is reset by PageList
+ /// operations which restart traversal progress.
+ last_serial: ?u64 = null,
+
+ /// First node serial which had not been allocated at the prior step. A
+ /// node at or above this value before the saved marker requires a restart.
+ next_serial: u64 = 0,
+
+ /// Record activity without disturbing valid traversal progress.
+ fn markActivity(self: *IncrementalCompressionState) void {
+ self.activity_serial +%= 1;
+ }
+
+ /// Discard traversal progress without changing the activity token.
+ fn reset(self: *IncrementalCompressionState) void {
+ const activity_serial: u48 = self.activity_serial;
+ self.* = .{ .activity_serial = activity_serial };
+ }
+};
+
+/// Result of one incremental compression step.
+pub const IncrementalCompressionResult = enum {
+ /// Strict retained-mapping reclamation is unavailable on this target.
+ unsupported,
+
+ /// More cold pages or a verification pass remain after this invocation's
+ /// candidate-bounded work.
+ pending,
+
+ /// A complete verification pass compressed zero pages.
+ complete,
+};
+
+/// Iterate complete historical pages which do not intersect the viewport.
+///
+/// All boundaries come from PageList pins and Page metadata. Advancing this
+/// iterator never restores a compressed page or reads its backing memory.
+const CompressionIterator = struct {
+ current: *List.Node,
+ active: *List.Node,
+ viewport_first: *List.Node,
+ viewport_last: *List.Node,
+
+ fn init(self: *const PageList) CompressionIterator {
+ return .{
+ .current = self.pages.first.?,
+ .active = self.getTopLeft(.active).node,
+ .viewport_first = self.getTopLeft(.viewport).node,
+ .viewport_last = self.getBottomRight(.viewport).?.node,
+ };
+ }
+
+ fn next(self: *CompressionIterator) ?*List.Node {
+ while (self.current != self.active) {
+ // The viewport is a contiguous node range. Once traversal reaches
+ // its first node, advance through the complete visible range and
+ // resume at the next offscreen page.
+ if (self.current == self.viewport_first) {
+ while (self.current != self.active and
+ self.current != self.viewport_last)
+ {
+ self.current = self.current.next.?;
+ }
+
+ if (self.current == self.active) return null;
+ self.current = self.current.next.?;
+ continue;
+ }
+
+ const node = self.current;
+ self.current = node.next.?;
+ return node;
+ }
+
+ return null;
+ }
+
+ fn done(self: *const CompressionIterator) bool {
+ return self.current == self.active;
+ }
+};
+
+/// Bound candidate inspection independently from compression work. Skipping
+/// an already-compressed page is cheap, but still counts toward this limit.
+const incremental_compression_max_inspected = 8;
+
+/// Failure injection for the final reclamation step. The operating-system
+/// failure path cannot otherwise be exercised by tests because terminal_mem
+/// deliberately simulates successful decommit in test builds.
+const compressPage_tw = tripwire.module(
+ enum { decommit },
+ error{DecommitFailed},
+);
+
+/// Compress eligible nodes, saving a significant amount of memory.
+///
+/// Eligible nodes are complete pages before the active boundary which do not
+/// intersect the viewport. The boundary page is excluded because it may
+/// contain both scrollback and active rows; visible pages remain resident for
+/// immediate redraw and scrolling.
+///
+/// Compression requires a system that supports reclaiming physical memory for
+/// virtual allocations while retaining their address ranges.
+///
+/// Compression is SLOW (relatively), so incremental compression during idle
+/// periods is recommended. Incremental mode performs one bounded step and its
+/// result specifies whether to continue immediately. Drain mode performs
+/// incremental steps until the pass and its verification pass finish. Full
+/// mode visits every currently eligible node once without using incremental
+/// state.
+///
+/// PageList tracks mutations which require a later incremental pass in its
+/// compression state. On supported targets, full compression returns
+/// `complete`, indicating that it has no continuation to schedule rather than
+/// that every page was compressed.
+pub fn compress(
+ self: *PageList,
+ mode: enum { incremental, drain, full },
+) IncrementalCompressionResult {
+ return switch (mode) {
+ .incremental => self.compressIncremental(),
+ .drain => while (true) switch (self.compressIncremental()) {
+ .pending => continue,
+ .complete => break .complete,
+ .unsupported => break .unsupported,
+ },
+ .full => full: {
+ // Match incremental mode's unsupported result. Full compression
+ // has no useful work to perform without strict reclamation.
+ if (!terminal_mem.canReclaim(.strict)) {
+ self.page_compression.reset();
+ break :full .unsupported;
+ }
+
+ self.compressFull();
+
+ // Full compression has no continuation. Discard any partial
+ // incremental cursor so later activity starts at the oldest page.
+ self.page_compression.reset();
+
+ break :full .complete;
+ },
+ };
+}
+
+/// Perform one candidate-bounded incremental cold-history compression step.
+fn compressIncremental(self: *PageList) IncrementalCompressionResult {
+ const state = &self.page_compression;
+
+ // If we can't reclaim virtual memory, compression is unsupported.
+ if (!terminal_mem.canReclaim(.strict)) {
+ state.reset();
+ return .unsupported;
+ }
+
+ // Find the node following the exact continuation marker within the cold
+ // prefix. A missing marker means the list changed between steps, so begin
+ // again at the current first page. This lookup does not touch page memory.
+ var it: CompressionIterator = .init(self);
+ if (state.last_serial) |last_serial| continuation: {
+ while (it.next()) |node| {
+ // A newly allocated or replacement node appeared before the
+ // marker. Restart immediately so that node cannot be skipped.
+ if (node.serial >= state.next_serial) break;
+
+ // Not a match? Keep looking
+ if (node.serial != last_serial) continue;
+
+ // Match! The iterator already points to the next offscreen node.
+ break :continuation;
+ }
+
+ // Not found or otherwise invalid. Reset
+ state.last_serial = null;
+ it = .init(self);
+ }
+
+ // Keep track of our next_serial
+ state.next_serial = self.page_serial;
+
+ // We cap the number of pages we look at to do our best to
+ // time-bound the incremental compression.
+ var inspected_pages: usize = 0;
+ while (inspected_pages < incremental_compression_max_inspected) {
+ const node = it.next() orelse break;
+
+ state.last_serial = node.serial;
+ inspected_pages += 1;
+
+ // If this page is already compressed, ignore it.
+ if (node.isCompressed()) continue;
+
+ // Compression is substantially more expensive even if it fails.
+ // So we just try it.
+ if (self.compressPage(node)) state.flags.did_compress = true;
+ break;
+ }
+
+ // If we didn't reach our active node, then we still have work to do.
+ if (!it.done()) return .pending;
+
+ // We reached our active node. So we're done, except that we always
+ // do one pass after the first success so we can recompress nodes that
+ // were possibly decompressed (e.g. by search, inspector, whatever).
+ if (!state.flags.verifying or state.flags.did_compress) {
+ const activity_serial: u48 = state.activity_serial;
+ state.* = .{
+ .flags = .{ .verifying = true },
+ .activity_serial = activity_serial,
+ };
+ return .pending;
+ }
+
+ // Leave the state fresh while idle so later activity naturally begins at
+ // the oldest cold page, including pages restored after this pass.
+ state.reset();
+ return .complete;
+}
+
+/// Compress every fully historical resident page which is currently cold.
+fn compressFull(self: *PageList) void {
+ var it: CompressionIterator = .init(self);
+ while (it.next()) |node| {
+
+ // Don't restore an already-compressed page just to recompress it.
+ if (node.isCompressed()) continue;
+
+ // Failure leaves this node resident and unchanged.
+ _ = self.compressPage(node);
+ }
+}
+
+/// Attempt to compress one resident page while retaining its raw mapping.
+///
+/// Compression is opportunistic: every failure leaves the page resident and
+/// usable. Candidate selection and retry policy belong to `compress`; this
+/// primitive only performs one state transition.
+fn compressPage(self: *PageList, node: *List.Node) bool {
+ // Recompression requires first restoring the raw page and is a policy
+ // decision, so this primitive only accepts resident nodes.
+ if (node.isCompressed()) return false;
+
+ const page = node.page();
+
+ // The scratch size is capped just below the representation's break-even
+ // point. Codec limits and pages too small to cover the compressed-state
+ // overhead simply make this page ineligible for compression.
+ const required = compression.Page.requiredScratch(page.memory.len) catch |err|
+ switch (err) {
+ error.InputTooLarge,
+ error.OutputTooSmall,
+ => return false,
+ };
+ if (required == 0) return false;
+
+ // Build the compressed candidate without changing the node. This scope is
+ // intentional: its defer releases the borrowed or temporary scratch before
+ // we attempt to discard the source mapping below. Only the candidate's
+ // exact-sized encoded allocation survives the scope.
+ const candidate = candidate: {
+ var scratch = CompressionScratch.init(
+ &self.pool,
+ required,
+ page.memory.len,
+ ) catch |err| switch (err) {
+ error.OutOfMemory => return false,
+ };
+ defer scratch.deinit(&self.pool);
+
+ var table: compression.lz4.HashTable = undefined;
+ break :candidate compression.Page.init(
+ self.pool.alloc,
+ page,
+ scratch.bytes()[0..required],
+ &table,
+ ) catch |err| switch (err) {
+ error.OutOfMemory,
+ error.InputTooLarge,
+ error.OutputTooSmall,
+ => return false,
+ };
+ };
+
+ // Null means compression crossed the break-even point. The node and its
+ // resident mapping are still untouched in this case.
+ var compressed = candidate orelse return false;
+
+ // Strict decommit is the final fallible step. It either discards the whole
+ // raw mapping or leaves it untouched, so failure can safely free the
+ // candidate and preserve the resident node exactly as it was.
+ const decommit_allowed: bool = allowed: {
+ compressPage_tw.check(.decommit) catch |err| switch (err) {
+ error.DecommitFailed => break :allowed false,
+ };
+ break :allowed true;
+ };
+ if (!decommit_allowed or !terminal_mem.decommit(
+ .strict,
+ compressed.page.memory,
+ compressed.page.memory.len,
+ )) {
+ compressed.deinit();
+ return false;
+ }
+
+ // Publish the new state only after both the encoded allocation and the
+ // retained-mapping decommit have succeeded.
+ node.data = .{ .compressed = compressed };
+ return true;
+}
+
/// Destroy the memory of the given node in the PageList linked list
/// and return it to the pool. The node is assumed to already be removed
/// from the linked list.
@@ -3467,18 +4283,33 @@ fn destroyNodeExt(
node: *List.Node,
total_size: ?*usize,
) void {
- const page: *Page = &node.data;
+ const page = node.restore(.discard);
+
+ // Update our accounting for page size. This must mirror what was
+ // added at creation time: a pool-owned page always accounts for a
+ // full pool item even if its layout is smaller, while a heap-owned
+ // page accounts for its exact memory length.
+ if (total_size) |v| v.* -= switch (node.owned) {
+ .pool => PagePool.item_size,
+ .heap => page.memory.len,
+ };
- // Update our accounting for page size
- if (total_size) |v| v.* -= page.memory.len;
+ switch (node.owned) {
+ .pool => {
+ assert(page.memory.len <= std_size);
- if (page.memory.len <= std_size) {
- // Reset the memory to zero so it can be reused
- @memset(page.memory, 0);
- pool.pages.destroy(@ptrCast(page.memory.ptr));
- } else {
- const page_alloc = pool.pages.arena.child_allocator;
- page_alloc.free(page.memory);
+ // Reset the memory to zero (and decommit it, where
+ // supported) so it can be reused.
+ const item: *align(std.heap.page_size_min) [std_size]u8 =
+ @ptrCast(@alignCast(page.memory.ptr));
+ _ = terminal_mem.decommit(.zero, item, page.memory.len);
+ pool.pages.destroy(item);
+ },
+
+ .heap => {
+ const page_alloc = pool.pages.arena.child_allocator;
+ page_alloc.free(page.memory);
+ },
}
pool.nodes.destroy(node);
@@ -3499,12 +4330,20 @@ pub fn eraseRow(
const pn = self.pin(pt).?;
var node = pn.node;
- var rows = node.data.rows.ptr(node.data.memory.ptr);
+ var page = node.page();
+ var rows = page.rows.ptr(page.memory.ptr);
+
+ // Erasing history may restore compressed pages. Mark unconditionally
+ // because incrementing the activity token is cheaper than locating the
+ // active boundary.
+ self.page_compression.markActivity();
// In order to move the following rows up we rotate the rows array by 1.
// The rotate operation turns e.g. [ 0 1 2 3 ] in to [ 1 2 3 0 ], which
// works perfectly to move all of our elements where they belong.
- fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]);
+ // Rotating rows changes which logical row cached coordinates identify.
+ self.invalidateNodeLayout(node);
+ fastmem.rotateOnce(Row, rows[pn.y..node.rows()]);
// We adjust the tracked pins in this page, moving up any that were below
// the removed row.
@@ -3522,12 +4361,13 @@ pub fn eraseRow(
//
// Technically we only need to mark rows from the erased row to the end
// of the page as dirty, but that's slower and this is a hot function.
- node.data.dirty = true;
+ page.dirty = true;
// We iterate through all of the following pages in order to move their
// rows up by 1 as well.
while (node.next) |next| {
- const next_rows = next.data.rows.ptr(next.data.memory.ptr);
+ const next_page = next.page();
+ const next_rows = next_page.rows.ptr(next_page.memory.ptr);
// We take the top row of the page and clone it in to the bottom
// row of the previous page, which gets rid of the top row that was
@@ -3544,19 +4384,22 @@ pub fn eraseRow(
// 5 5 5 | 6
// 6 6 6 | 7
// 7 7 7 <' 4
- try node.data.cloneRowFrom(
- &next.data,
- &rows[node.data.size.rows - 1],
+ try page.cloneRowFrom(
+ next_page,
+ &rows[node.rows() - 1],
&next_rows[0],
);
node = next;
+ page = next_page;
rows = next_rows;
- fastmem.rotateOnce(Row, rows[0..node.data.size.rows]);
+ // Rotating this page moves every cached row coordinate up by one.
+ self.invalidateNodeLayout(node);
+ fastmem.rotateOnce(Row, rows[0..node.rows()]);
// Mark the whole page as dirty.
- node.data.dirty = true;
+ page.dirty = true;
// Our tracked pins for this page need to be updated.
// If the pin is in row 0 that means the corresponding row has
@@ -3566,7 +4409,7 @@ pub fn eraseRow(
if (p.node != node) continue;
if (p.y == 0) {
p.node = node.prev.?;
- p.y = p.node.data.size.rows - 1;
+ p.y = p.node.rows() - 1;
continue;
}
p.y -= 1;
@@ -3574,7 +4417,7 @@ pub fn eraseRow(
}
// Clear the final row which was rotated from the top of the page.
- node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols);
+ page.clearCells(&rows[node.rows() - 1], 0, node.cols());
}
/// A variant of eraseRow that shifts only a bounded number of following
@@ -3598,20 +4441,28 @@ pub fn eraseRowBounded(
const pn = self.pin(pt).?;
var node: *List.Node = pn.node;
- var rows = node.data.rows.ptr(node.data.memory.ptr);
+ var page = node.page();
+ var rows = page.rows.ptr(page.memory.ptr);
+
+ // Erasing history may restore compressed pages. Mark unconditionally
+ // because incrementing the activity token is cheaper than locating the
+ // active boundary.
+ self.page_compression.markActivity();
// If the row limit is less than the remaining rows before the end of the
// page, then we clear the row, rotate it to the end of the boundary limit
// and update our pins.
- if (node.data.size.rows - pn.y > limit) {
- node.data.clearCells(&rows[pn.y], 0, node.data.size.cols);
+ if (node.rows() - pn.y > limit) {
+ // Rotating this bounded region changes its cached row coordinates.
+ self.invalidateNodeLayout(node);
+ page.clearCells(&rows[pn.y], 0, node.cols());
fastmem.rotateOnce(Row, rows[pn.y..][0 .. limit + 1]);
// Mark the whole page as dirty.
//
// Technically we only need to mark from the erased row to the
// limit but this is a hot function, so we want to minimize work.
- node.data.dirty = true;
+ page.dirty = true;
// If our viewport is a pin and our pin is within the erased
// region we need to maybe shift our cache up. We do this here instead
@@ -3646,18 +4497,20 @@ pub fn eraseRowBounded(
return;
}
- fastmem.rotateOnce(Row, rows[pn.y..node.data.size.rows]);
+ // Rotating this suffix changes which logical row its coordinates identify.
+ self.invalidateNodeLayout(node);
+ fastmem.rotateOnce(Row, rows[pn.y..node.rows()]);
// Mark the whole page as dirty.
//
// Technically we only need to mark rows from the erased row to the end
// of the page as dirty, but that's slower and this is a hot function.
- node.data.dirty = true;
+ page.dirty = true;
// We need to keep track of how many rows we've shifted so that we can
// determine at what point we need to do a partial shift on subsequent
// pages.
- var shifted: usize = node.data.size.rows - pn.y;
+ var shifted: usize = node.rows() - pn.y;
// Update tracked pins.
{
@@ -3686,15 +4539,17 @@ pub fn eraseRowBounded(
}
while (node.next) |next| {
- const next_rows = next.data.rows.ptr(next.data.memory.ptr);
+ const next_page = next.page();
+ const next_rows = next_page.rows.ptr(next_page.memory.ptr);
- try node.data.cloneRowFrom(
- &next.data,
- &rows[node.data.size.rows - 1],
+ try page.cloneRowFrom(
+ next_page,
+ &rows[node.rows() - 1],
&next_rows[0],
);
node = next;
+ page = next_page;
rows = next_rows;
// We check to see if this page contains enough rows to satisfy the
@@ -3703,15 +4558,17 @@ pub fn eraseRowBounded(
//
// The logic here is very similar to the one before the loop.
const shifted_limit = limit - shifted;
- if (node.data.size.rows > shifted_limit) {
- node.data.clearCells(&rows[0], 0, node.data.size.cols);
+ if (node.rows() > shifted_limit) {
+ // Rotating this bounded prefix changes its cached row coordinates.
+ self.invalidateNodeLayout(node);
+ page.clearCells(&rows[0], 0, node.cols());
fastmem.rotateOnce(Row, rows[0 .. shifted_limit + 1]);
// Mark the whole page as dirty.
//
// Technically we only need to mark from the erased row to the
// limit but this is a hot function, so we want to minimize work.
- node.data.dirty = true;
+ page.dirty = true;
// See the other places we do something similar in this function
// for a detailed explanation.
@@ -3730,7 +4587,7 @@ pub fn eraseRowBounded(
if (p.node != node or p.y > shifted_limit) continue;
if (p.y == 0) {
p.node = node.prev.?;
- p.y = p.node.data.size.rows - 1;
+ p.y = p.node.rows() - 1;
continue;
}
p.y -= 1;
@@ -3739,13 +4596,15 @@ pub fn eraseRowBounded(
return;
}
- fastmem.rotateOnce(Row, rows[0..node.data.size.rows]);
+ // Rotating the whole page moves every cached row coordinate up by one.
+ self.invalidateNodeLayout(node);
+ fastmem.rotateOnce(Row, rows[0..node.rows()]);
// Mark the whole page as dirty.
- node.data.dirty = true;
+ page.dirty = true;
// Account for the rows shifted in this node.
- shifted += node.data.size.rows;
+ shifted += node.rows();
// See the other places we do something similar in this function
// for a detailed explanation.
@@ -3763,7 +4622,7 @@ pub fn eraseRowBounded(
if (p.node != node) continue;
if (p.y == 0) {
p.node = node.prev.?;
- p.y = p.node.data.size.rows - 1;
+ p.y = p.node.rows() - 1;
continue;
}
p.y -= 1;
@@ -3772,7 +4631,7 @@ pub fn eraseRowBounded(
// We reached the end of the page list before the limit, so we clear
// the final row since it was rotated down from the top of this page.
- node.data.clearCells(&rows[node.data.size.rows - 1], 0, node.data.size.cols);
+ page.clearCells(&rows[node.rows() - 1], 0, node.cols());
}
/// Erase all history rows, optionally up to a bottom-left bound.
@@ -3800,9 +4659,8 @@ pub fn eraseActive(
/// and the page becomes underutilized (size < capacity).
///
/// Callers must ensure that the erased range only removes pages from
-/// the front or back of the linked list, never the middle. Middle-page
-/// erasure would create serial gaps that page_serial_min cannot
-/// represent, leaving dangling references in consumers such as search.
+/// the front or back of the linked list, never the middle. The pin and row
+/// accounting in this operation is only defined for those boundary ranges.
/// Use the public eraseHistory/eraseActive wrappers which enforce this.
fn eraseRows(
self: *PageList,
@@ -3810,6 +4668,7 @@ fn eraseRows(
bl_pt: ?point.Point,
) void {
defer self.assertIntegrity();
+ self.page_compression.markActivity();
// The count of rows that was erased.
var erased: usize = 0;
@@ -3827,27 +4686,33 @@ fn eraseRows(
// page so to handle this we reinit this page, set it to zero
// size which will let us grow our active area back.
if (chunk.node.next == null and chunk.node.prev == null) {
- const page = &chunk.node.data;
+ // Reinitializing the sole page invalidates every coordinate in it.
+ self.invalidateNodeLayout(chunk.node);
+ const page = chunk.node.page();
erased += page.size.rows;
page.reinit();
page.size.rows = 0;
break;
}
- erased += chunk.node.data.size.rows;
+ erased += chunk.node.rows();
self.erasePage(chunk.node);
continue;
}
+ // Moving the retained suffix changes every captured row coordinate.
+ self.invalidateNodeLayout(chunk.node);
+
// We are modifying our chunk so make sure it is in a good state.
- defer chunk.node.data.assertIntegrity();
+ const page = chunk.node.page();
+ defer page.assertIntegrity();
// The chunk is not a full page so we need to move the rows.
// This is a cheap operation because we're just moving cell offsets,
// not the actual cell contents.
assert(chunk.start == 0);
- const rows = chunk.node.data.rows.ptr(chunk.node.data.memory);
- const scroll_amount = chunk.node.data.size.rows - chunk.end;
+ const rows = page.rows.ptr(page.memory);
+ const scroll_amount = chunk.node.rows() - chunk.end;
for (0..scroll_amount) |i| {
const src: *Row = &rows[i + chunk.end];
const dst: *Row = &rows[i];
@@ -3861,12 +4726,12 @@ fn eraseRows(
// Clear our remaining cells that we didn't shift or swapped
// in case we grow back into them.
- for (scroll_amount..chunk.node.data.size.rows) |i| {
+ for (scroll_amount..chunk.node.rows()) |i| {
const row: *Row = &rows[i];
- chunk.node.data.clearCells(
+ page.clearCells(
row,
0,
- chunk.node.data.size.cols,
+ chunk.node.cols(),
);
}
@@ -3884,7 +4749,7 @@ fn eraseRows(
}
// Our new size is the amount we scrolled
- chunk.node.data.size.rows = @intCast(scroll_amount);
+ page.size.rows = @intCast(scroll_amount);
erased += chunk.end;
}
@@ -3920,17 +4785,10 @@ fn erasePage(self: *PageList, node: *List.Node) void {
// Must not be the final page.
assert(node.next != null or node.prev != null);
- // We only support erasing from the front or back, never the middle.
- // Middle erasure would create serial gaps that page_serial_min can't
- // represent. If this ever needs to change, we'll need a more
- // sophisticated invalidation mechanism.
+ // We only support erasing from the front or back, never the middle. The
+ // public erase operations maintain this contract by construction.
assert(node.prev == null or node.next == null);
- // If we're erasing the first page, update page_serial_min so that
- // any external references holding this page's serial will know it
- // has been invalidated.
- if (node.prev == null) self.page_serial_min = node.next.?.serial;
-
// Update any tracked pins to move to the previous or next page.
const pin_keys = self.tracked_pins.keys();
for (pin_keys) |p| {
@@ -3962,6 +4820,9 @@ pub fn pin(self: *const PageList, pt: point.Point) ?Pin {
// Grab the top left and move to the point.
var p = self.getTopLeft(pt).down(pt.coord().y) orelse return null;
+ // Incomplete reflow can leave a page narrower than the desired width.
+ // Never manufacture an out-of-bounds pin for that page.
+ if (x >= p.node.cols()) return null;
p.x = x;
return p;
}
@@ -4014,8 +4875,28 @@ pub fn pinIsValid(self: *const PageList, p: Pin) bool {
var it = self.pages.first;
while (it) |node| : (it = node.next) {
if (node != p.node) continue;
- return p.y < node.data.size.rows and
- p.x < node.data.size.cols;
+ return p.y < node.rows() and
+ p.x < node.cols();
+ }
+
+ return false;
+}
+
+/// Returns whether a node pointer and serial still identify the same page in
+/// this list. The node pointer is only compared and is safe even if the node
+/// has been destroyed or reused since the serial was captured.
+pub fn nodeIsValid(
+ self: *const PageList,
+ target: *List.Node,
+ serial: u64,
+) bool {
+ // Reset invalidates the whole prior epoch, so reject those generations
+ // without scanning the live list.
+ if (serial < self.page_serial_epoch) return false;
+
+ var it = self.pages.first;
+ while (it) |node| : (it = node.next) {
+ if (node == target) return node.serial == serial;
}
return false;
@@ -4069,15 +4950,23 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point
if (tl.y > p.y) return null;
coord.y = p.y - tl.y;
} else {
- coord.y += tl.node.data.size.rows - tl.y;
+ coord.y = std.math.add(
+ u32,
+ coord.y,
+ tl.node.rows() - tl.y,
+ ) catch return null;
var node_ = tl.node.next;
while (node_) |node| : (node_ = node.next) {
if (node == p.node) {
- coord.y += p.y;
+ coord.y = std.math.add(u32, coord.y, p.y) catch return null;
break;
}
- coord.y += node.data.size.rows;
+ coord.y = std.math.add(
+ u32,
+ coord.y,
+ node.rows(),
+ ) catch return null;
} else {
// We never saw our node, meaning we're outside the range.
return null;
@@ -4099,7 +4988,7 @@ pub fn pointFromPin(self: *const PageList, tag: point.Tag, p: Pin) ?point.Point
/// Warning: this is slow and should not be used in performance critical paths
pub fn getCell(self: *const PageList, pt: point.Point) ?Cell {
const pt_pin = self.pin(pt) orelse return null;
- const rac = pt_pin.node.data.getRowAndCell(pt_pin.x, pt_pin.y);
+ const rac = pt_pin.node.page().getRowAndCell(pt_pin.x, pt_pin.y);
return .{
.node = pt_pin.node,
.row = rac.row,
@@ -4142,13 +5031,13 @@ pub fn diagram(
var it = self.pageIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |chunk| : (page_index += 1) {
- cols = chunk.node.data.size.cols;
+ cols = chunk.node.cols();
// Whether we've just skipped some number of rows and drawn
// an ellipsis row (this is reset when a row is not skipped).
var skipped = false;
- for (0..chunk.node.data.size.rows) |y| {
+ for (0..chunk.node.rows()) |y| {
// Active header
if (!active and
chunk.node == active_pin.node and
@@ -4173,8 +5062,8 @@ pub fn diagram(
// Row contents
{
- const row = chunk.node.data.getRow(y);
- const cells = chunk.node.data.getCells(row)[0..cols];
+ const row = chunk.node.page().getRow(y);
+ const cells = chunk.node.page().getCells(row)[0..cols];
var row_has_content = false;
@@ -4223,7 +5112,7 @@ pub fn diagram(
}
try writer.print("{u}", .{cell.codepoint()});
if (cell.hasGrapheme()) {
- const grapheme = chunk.node.data.lookupGrapheme(cell).?;
+ const grapheme = chunk.node.page().lookupGrapheme(cell).?;
for (grapheme) |cp| {
try writer.print("{u}", .{cp});
}
@@ -4351,7 +5240,7 @@ pub fn highlightSemanticContent(
// two prompts here.
if (it.next()) |next| next: {
var prev = next.up(1) orelse break :next;
- prev.x = prev.node.data.size.cols - 1;
+ prev.x = prev.node.cols() - 1;
break :end prev;
}
@@ -4642,7 +5531,7 @@ pub const CellIterator = struct {
switch (self.row_it.page_it.direction) {
.right_down => {
- if (cell.x + 1 < cell.node.data.size.cols) {
+ if (cell.x + 1 < cell.node.cols()) {
// We still have cells in this row, increase x.
var copy = cell;
copy.x += 1;
@@ -4663,7 +5552,7 @@ pub const CellIterator = struct {
// We need to move to the previous row and last col
if (self.row_it.next()) |next_cell| {
var copy = next_cell;
- copy.x = next_cell.node.data.size.cols - 1;
+ copy.x = next_cell.node.cols() - 1;
self.cell = copy;
} else {
self.cell = null;
@@ -4796,24 +5685,21 @@ pub const PageIterator = struct {
break :none .{
.node = row.node,
.start = row.y,
- .end = row.node.data.size.rows,
+ .end = row.node.rows(),
};
},
.count => |*limit| count: {
assert(limit.* > 0); // should be handled already
- const len = @min(row.node.data.size.rows - row.y, limit.*);
- if (len > limit.*) {
- self.row = row.down(len);
- limit.* -= len;
- } else {
- self.row = null;
- }
+ const available: usize = row.node.rows() - row.y;
+ const len = @min(available, limit.*);
+ limit.* -= len;
+ self.row = if (limit.* > 0) row.down(len) else null;
break :count .{
.node = row.node,
.start = row.y,
- .end = row.y + len,
+ .end = @intCast(@as(usize, row.y) + len),
};
},
@@ -4829,7 +5715,7 @@ pub const PageIterator = struct {
break :row .{
.node = row.node,
.start = row.y,
- .end = row.node.data.size.rows,
+ .end = row.node.rows(),
};
}
@@ -4858,7 +5744,7 @@ pub const PageIterator = struct {
const next_page = row.node.prev orelse break :next null;
break :next .{
.node = next_page,
- .y = next_page.data.size.rows - 1,
+ .y = next_page.rows() - 1,
};
};
@@ -4871,18 +5757,15 @@ pub const PageIterator = struct {
.count => |*limit| count: {
assert(limit.* > 0); // should be handled already
- const len = @min(row.y, limit.*);
- if (len > limit.*) {
- self.row = row.up(len);
- limit.* -= len;
- } else {
- self.row = null;
- }
+ const available: usize = @as(usize, row.y) + 1;
+ const len = @min(available, limit.*);
+ limit.* -= len;
+ self.row = if (limit.* > 0) row.up(len) else null;
break :count .{
.node = row.node,
- .start = row.y - len,
- .end = row.y - 1,
+ .start = @intCast(available - len),
+ .end = @intCast(available),
};
},
@@ -4894,7 +5777,7 @@ pub const PageIterator = struct {
const next_page = row.node.prev orelse break :next null;
break :next .{
.node = next_page,
- .y = next_page.data.size.rows - 1,
+ .y = next_page.rows() - 1,
};
};
@@ -4928,13 +5811,14 @@ pub const PageIterator = struct {
end: size.CellCountInt,
pub fn rows(self: Chunk) []Row {
- const rows_ptr = self.node.data.rows.ptr(self.node.data.memory);
+ const page = self.node.page();
+ const rows_ptr = page.rows.ptr(page.memory);
return rows_ptr[self.start..self.end];
}
/// Returns true if this chunk represents every row in the page.
pub fn fullPage(self: Chunk) bool {
- return self.start == 0 and self.end == self.node.data.size.rows;
+ return self.start == 0 and self.end == self.node.rows();
}
/// Returns true if this chunk overlaps with the given other chunk
@@ -5006,12 +5890,12 @@ pub fn getTopLeft(self: *const PageList, tag: point.Tag) Pin {
var rem = self.rows;
var it = self.pages.last;
while (it) |node| : (it = node.prev) {
- if (rem <= node.data.size.rows) break :active .{
+ if (rem <= node.rows()) break :active .{
.node = node,
- .y = node.data.size.rows - rem,
+ .y = node.rows() - rem,
};
- rem -= node.data.size.rows;
+ rem -= node.rows();
}
unreachable; // assertion: we always have enough rows for active
@@ -5028,22 +5912,22 @@ pub fn getBottomRight(self: *const PageList, tag: point.Tag) ?Pin {
const node = self.pages.last.?;
break :last .{
.node = node,
- .y = node.data.size.rows - 1,
- .x = node.data.size.cols - 1,
+ .y = node.rows() - 1,
+ .x = node.cols() - 1,
};
},
.viewport => viewport: {
var br = self.getTopLeft(.viewport);
br = br.down(self.rows - 1).?;
- br.x = br.node.data.size.cols - 1;
+ br.x = br.node.cols() - 1;
break :viewport br;
},
.history => active: {
var br = self.getTopLeft(.active);
br = br.up(1) orelse return null;
- br.x = br.node.data.size.cols - 1;
+ br.x = br.node.cols() - 1;
break :active br;
},
};
@@ -5058,7 +5942,7 @@ fn totalRows(self: *const PageList) usize {
var rows: usize = 0;
var node_ = self.pages.first;
while (node_) |node| {
- rows += node.data.size.rows;
+ rows += node.rows();
node_ = node.next;
}
@@ -5078,6 +5962,88 @@ pub fn totalPages(self: *const PageList) usize {
return pages;
}
+/// Snapshot of the storage used by page nodes in this list.
+///
+/// The raw byte counts describe page backing mappings only. They exclude
+/// nodes, allocator metadata, unused preheated pool items, and the small
+/// representation values stored in each node. A compressed page retains its
+/// raw mapping as virtual address space, but its bytes are counted as
+/// decommitted because strict reclamation succeeded before the state was
+/// published.
+pub const MemoryStats = struct {
+ /// Pages whose raw backing mappings are resident.
+ resident_pages: usize = 0,
+
+ /// Pages represented by encoded storage and a decommitted raw mapping.
+ compressed_pages: usize = 0,
+
+ /// Logical bytes in every raw page mapping.
+ raw_bytes: usize = 0,
+
+ /// Raw mapping bytes which remain resident.
+ resident_raw_bytes: usize = 0,
+
+ /// Raw mapping bytes discarded for compressed pages.
+ decommitted_raw_bytes: usize = 0,
+
+ /// Raw allocation bytes which remain physically resident.
+ ///
+ /// This can exceed `resident_raw_bytes` because a pool-owned page uses
+ /// only part of a standard pool item. Compressing such a page decommits
+ /// its initialized range, but the unused tail of the item stays resident.
+ resident_backing_bytes: usize = 0,
+
+ /// Exact encoded allocations retained for compressed pages.
+ encoded_bytes: usize = 0,
+
+ /// Estimate resident page backing storage after compression.
+ pub fn estimatedResidentBytes(self: MemoryStats) usize {
+ return self.resident_backing_bytes + self.encoded_bytes;
+ }
+
+ /// Estimate physical bytes avoided by compressed page backing storage.
+ pub fn estimatedSavings(self: MemoryStats) usize {
+ return self.decommitted_raw_bytes -| self.encoded_bytes;
+ }
+};
+
+/// Return a metadata-only snapshot of page backing storage.
+///
+/// This never restores compressed pages. It is intended for diagnostics and
+/// other infrequent reporting because it traverses the complete page list.
+pub fn memoryStats(self: *const PageList) MemoryStats {
+ var result: MemoryStats = .{};
+ var current = self.pages.first;
+ while (current) |node| : (current = node.next) {
+ const raw_len = node.metadata().memory.len;
+ const backing_len = switch (node.owned) {
+ .pool => PagePool.item_size,
+ .heap => raw_len,
+ };
+ assert(backing_len >= raw_len);
+ result.raw_bytes += raw_len;
+
+ switch (node.data) {
+ .resident => {
+ result.resident_pages += 1;
+ result.resident_raw_bytes += raw_len;
+ result.resident_backing_bytes += backing_len;
+ },
+
+ .compressed => |compressed| {
+ result.compressed_pages += 1;
+ result.decommitted_raw_bytes += raw_len;
+ // Strict reclamation covers only Page.memory. A standard pool
+ // item can have an unused tail which remains resident.
+ result.resident_backing_bytes += backing_len - raw_len;
+ result.encoded_bytes += compressed.encoded.len;
+ },
+ }
+ }
+
+ return result;
+}
+
/// Grow the number of rows available in the page list by n.
/// This is only used for testing so it isn't optimized in any way.
fn growRows(self: *PageList, n: usize) Allocator.Error!void {
@@ -5089,8 +6055,9 @@ fn growRows(self: *PageList, n: usize) Allocator.Error!void {
pub fn clearDirty(self: *PageList) void {
var page = self.pages.first;
while (page) |p| : (page = p.next) {
- p.data.dirty = false;
- for (p.data.rows.ptr(p.data.memory)[0..p.data.size.rows]) |*row| {
+ const current_page = p.page();
+ current_page.dirty = false;
+ for (current_page.rows.ptr(current_page.memory)[0..p.rows()]) |*row| {
row.dirty = false;
}
}
@@ -5137,7 +6104,7 @@ pub const Pin = struct {
row: *pagepkg.Row,
cell: *pagepkg.Cell,
} {
- const rac = self.node.data.getRowAndCell(self.x, self.y);
+ const rac = self.node.page().getRowAndCell(self.x, self.y);
return .{ .row = rac.row, .cell = rac.cell };
}
@@ -5147,8 +6114,9 @@ pub const Pin = struct {
/// what subset of the cells are returned. The "left/right" subsets are
/// inclusive of the x coordinate of the pin.
pub inline fn cells(self: Pin, subset: CellSubset) []pagepkg.Cell {
- const rac = self.rowAndCell();
- const all = self.node.data.getCells(rac.row);
+ const page = self.node.page();
+ const rac = page.getRowAndCell(self.x, self.y);
+ const all = page.getCells(rac.row);
return switch (subset) {
.all => all,
.left => all[0 .. self.x + 1],
@@ -5159,21 +6127,23 @@ pub const Pin = struct {
/// Returns the grapheme codepoints for the given cell. These are only
/// the EXTRA codepoints and not the first codepoint.
pub inline fn grapheme(self: Pin, cell: *const pagepkg.Cell) ?[]u21 {
- return self.node.data.lookupGrapheme(cell);
+ return self.node.page().lookupGrapheme(cell);
}
/// Returns the style for the given cell in this pin.
pub inline fn style(self: Pin, cell: *const pagepkg.Cell) stylepkg.Style {
if (cell.style_id == stylepkg.default_id) return .{};
- return self.node.data.styles.get(
- self.node.data.memory,
+ const page = self.node.page();
+ return page.styles.get(
+ page.memory,
cell.style_id,
).*;
}
/// Check if this pin is dirty.
pub inline fn isDirty(self: Pin) bool {
- return self.node.data.dirty or self.rowAndCell().row.dirty;
+ const page = self.node.page();
+ return page.dirty or page.getRowAndCell(self.x, self.y).row.dirty;
}
/// Mark this pin location as dirty.
@@ -5354,7 +6324,7 @@ pub const Pin = struct {
/// Move the pin right n columns. n must fit within the size.
pub inline fn right(self: Pin, n: usize) Pin {
- assert(self.x + n < self.node.data.size.cols);
+ assert(self.x + n < self.node.cols());
var result = self;
result.x +|= std.math.cast(size.CellCountInt, n) orelse
std.math.maxInt(size.CellCountInt);
@@ -5371,7 +6341,7 @@ pub const Pin = struct {
/// Move the pin right n columns, stopping at the end of the row.
pub inline fn rightClamp(self: Pin, n: size.CellCountInt) Pin {
var result = self;
- result.x = @min(self.x +| n, self.node.data.size.cols - 1);
+ result.x = @min(self.x +| n, self.node.cols() - 1);
return result;
}
@@ -5381,25 +6351,18 @@ pub const Pin = struct {
///
/// TODO: Unit tests.
pub fn leftWrap(self: Pin, n: usize) ?Pin {
- // NOTE: This assumes that all pages have the same width, which may
- // be violated under certain circumstances by incomplete reflow.
- const cols = self.node.data.size.cols;
- const remaining_in_row = self.x;
-
- if (n <= remaining_in_row) return self.left(n);
-
- const extra_after_remaining = n - remaining_in_row;
-
- const rows_off = 1 + extra_after_remaining / cols;
-
- switch (self.upOverflow(rows_off)) {
- .offset => |v| {
- var result = v;
- result.x = @intCast(cols - extra_after_remaining % cols);
- return result;
- },
- .overflow => return null,
+ var result = self;
+ var remaining = n;
+ while (remaining > result.x) {
+ remaining -= @as(usize, result.x) + 1;
+ result = result.up(1) orelse return null;
+ // Crossing a row boundary lands on that destination row's final
+ // cell, whose width may differ from ours during reflow.
+ result.x = result.node.cols() - 1;
}
+
+ result.x -= @intCast(remaining);
+ return result;
}
/// Move the pin right n cells, wrapping to the next row as needed.
@@ -5408,24 +6371,18 @@ pub const Pin = struct {
///
/// TODO: Unit tests.
pub fn rightWrap(self: Pin, n: usize) ?Pin {
- // NOTE: This assumes that all pages have the same width, which may
- // be violated under certain circumstances by incomplete reflow.
- const cols = self.node.data.size.cols;
- const remaining_in_row = cols - self.x - 1;
-
- if (n <= remaining_in_row) return self.right(n);
-
- const extra_after_remaining = n - remaining_in_row;
-
- const rows_off = 1 + extra_after_remaining / cols;
-
- switch (self.downOverflow(rows_off)) {
- .offset => |v| {
- var result = v;
- result.x = @intCast(extra_after_remaining % cols - 1);
+ var result = self;
+ var remaining = n;
+ while (true) {
+ const row_remaining = result.node.cols() - result.x - 1;
+ if (remaining <= row_remaining) {
+ result.x += @intCast(remaining);
return result;
- },
- .overflow => return null,
+ }
+
+ remaining -= @as(usize, row_remaining) + 1;
+ result = result.down(1) orelse return null;
+ result.x = 0;
}
}
@@ -5457,7 +6414,7 @@ pub const Pin = struct {
},
} {
// Index fits within this page
- const rows = self.node.data.size.rows - (self.y + 1);
+ const rows = self.node.rows() - (self.y + 1);
if (n <= rows) return .{ .offset = .{
.node = self.node,
.y = std.math.cast(size.CellCountInt, self.y + n) orelse
@@ -5472,18 +6429,18 @@ pub const Pin = struct {
node = node.next orelse return .{ .overflow = .{
.end = .{
.node = node,
- .y = node.data.size.rows - 1,
- .x = self.x,
+ .y = node.rows() - 1,
+ .x = @min(self.x, node.cols() - 1),
},
.remaining = n_left,
} };
- if (n_left <= node.data.size.rows) return .{ .offset = .{
+ if (n_left <= node.rows()) return .{ .offset = .{
.node = node,
.y = std.math.cast(size.CellCountInt, n_left - 1) orelse
std.math.maxInt(size.CellCountInt),
- .x = self.x,
+ .x = @min(self.x, node.cols() - 1),
} };
- n_left -= node.data.size.rows;
+ n_left -= node.rows();
}
}
@@ -5509,20 +6466,111 @@ pub const Pin = struct {
var n_left: usize = n - self.y;
while (true) {
node = node.prev orelse return .{ .overflow = .{
- .end = .{ .node = node, .y = 0, .x = self.x },
+ .end = .{
+ .node = node,
+ .y = 0,
+ .x = @min(self.x, node.cols() - 1),
+ },
.remaining = n_left,
} };
- if (n_left <= node.data.size.rows) return .{ .offset = .{
+ if (n_left <= node.rows()) return .{ .offset = .{
.node = node,
- .y = std.math.cast(size.CellCountInt, node.data.size.rows - n_left) orelse
+ .y = std.math.cast(size.CellCountInt, node.rows() - n_left) orelse
std.math.maxInt(size.CellCountInt),
- .x = self.x,
+ .x = @min(self.x, node.cols() - 1),
} };
- n_left -= node.data.size.rows;
+ n_left -= node.rows();
}
}
};
+fn mixedWidthPinListForTest(alloc: Allocator) !PageList {
+ var result = try init(alloc, 2, 1, null);
+ errdefer result.deinit();
+
+ // This deliberately constructs a layout that normal PageList operations
+ // do not expose yet. Keep integrity checks paused through deinit so the
+ // fixture can exercise mixed-width traversal in isolation.
+ result.pauseIntegrityChecks(true);
+
+ inline for (.{ 4, 3 }) |cols| {
+ const node = try result.createPage(.{ .cap = .{
+ .cols = cols,
+ .rows = 1,
+ } });
+ node.page().size.rows = 1;
+ result.pages.append(node);
+ result.total_rows += 1;
+ }
+
+ // Desired geometry is wider than the first and last stored pages.
+ result.cols = 4;
+ return result;
+}
+
+test "PageList Pin row movement clamps across mixed-width pages" {
+ const testing = std.testing;
+
+ var s = try mixedWidthPinListForTest(testing.allocator);
+ defer s.deinit();
+
+ const first = s.pages.first.?;
+ const second = first.next.?;
+ const third = second.next.?;
+
+ try testing.expect((Pin{ .node = third, .x = 2 }).eql(
+ (Pin{ .node = second, .x = 3 }).down(1).?,
+ ));
+ try testing.expect((Pin{ .node = first, .x = 1 }).eql(
+ (Pin{ .node = second, .x = 3 }).up(1).?,
+ ));
+
+ switch ((Pin{ .node = second, .x = 3 }).downOverflow(10)) {
+ .offset => try testing.expect(false),
+ .overflow => |overflow| try testing.expect(
+ (Pin{ .node = third, .x = 2 }).eql(overflow.end),
+ ),
+ }
+ switch ((Pin{ .node = second, .x = 3 }).upOverflow(10)) {
+ .offset => try testing.expect(false),
+ .overflow => |overflow| try testing.expect(
+ (Pin{ .node = first, .x = 1 }).eql(overflow.end),
+ ),
+ }
+}
+
+test "PageList Pin wrapping crosses mixed-width pages" {
+ const testing = std.testing;
+
+ var s = try mixedWidthPinListForTest(testing.allocator);
+ defer s.deinit();
+
+ const first = s.pages.first.?;
+ const second = first.next.?;
+ const third = second.next.?;
+
+ try testing.expect((Pin{ .node = third, .x = 2 }).eql(
+ (Pin{ .node = first, .x = 1 }).rightWrap(7).?,
+ ));
+ try testing.expect((Pin{ .node = second, .x = 0 }).eql(
+ (Pin{ .node = third, .x = 2 }).leftWrap(6).?,
+ ));
+ try testing.expect((Pin{ .node = first }).leftWrap(1) == null);
+ try testing.expect((Pin{ .node = third, .x = 2 }).rightWrap(1) == null);
+}
+
+test "PageList Pin rejects columns beyond mixed-width page bounds" {
+ const testing = std.testing;
+
+ var s = try mixedWidthPinListForTest(testing.allocator);
+ defer s.deinit();
+
+ try testing.expect(s.pin(.{ .screen = .{ .x = 1, .y = 0 } }) != null);
+ try testing.expect(s.pin(.{ .screen = .{ .x = 2, .y = 0 } }) == null);
+ try testing.expect(s.pin(.{ .screen = .{ .x = 3, .y = 1 } }) != null);
+ try testing.expect(s.pin(.{ .screen = .{ .x = 3, .y = 2 } }) == null);
+}
+
pub const Cell = struct {
node: *List.Node,
row: *pagepkg.Row,
@@ -5535,7 +6583,7 @@ pub const Cell = struct {
/// This is not very performant this is primarily used for assertions
/// and testing.
pub fn isDirty(self: Cell) bool {
- return self.node.data.dirty or self.row.dirty;
+ return self.node.page().dirty or self.row.dirty;
}
/// Get the cell style.
@@ -5543,8 +6591,9 @@ pub const Cell = struct {
/// Not meant for non-test usage since this is inefficient.
pub fn style(self: Cell) stylepkg.Style {
if (self.cell.style_id == stylepkg.default_id) return .{};
- return self.node.data.styles.get(
- self.node.data.memory,
+ const page = self.node.page();
+ return page.styles.get(
+ page.memory,
self.cell.style_id,
).*;
}
@@ -5556,10 +6605,10 @@ pub const Cell = struct {
/// this file then consider a different approach and ask yourself very
/// carefully if you really need this.
pub fn screenPoint(self: Cell) point.Point {
- var y: size.CellCountInt = self.row_idx;
+ var y: u32 = self.row_idx;
var node_ = self.node;
while (node_.prev) |node| {
- y += node.data.size.rows;
+ y += node.rows();
node_ = node;
}
@@ -5570,6 +6619,1293 @@ pub const Cell = struct {
}
};
+/// Grow a test PageList until it contains at least `count` complete history
+/// pages. The production cold-page boundary is intentionally reused here so
+/// tests do not duplicate the row-to-page arithmetic.
+fn growColdPagesForTest(self: *PageList, count: usize) !void {
+ while (true) {
+ const active_node = self.getTopLeft(.active).node;
+ var cold_count: usize = 0;
+ var current = self.pages.first;
+ while (current) |node| : (current = node.next) {
+ if (node == active_node) break;
+ cold_count += 1;
+ }
+
+ if (cold_count >= count) return;
+ _ = try self.grow();
+ }
+}
+
+/// Fill the current tail page to capacity without allocating a successor.
+/// Capturing the tail before the loop makes this stop at the allocation
+/// boundary needed by bounded-pruning tests.
+fn fillLastPageForTest(self: *PageList) !void {
+ const last = self.pages.last.?;
+ while (last.rows() < last.capacity().rows) _ = try self.grow();
+}
+
+/// Verify every live page belongs to the current validity epoch, has an
+/// allocated generation below the next serial, and validates through the same
+/// pointer-plus-generation lookup used by external references.
+fn expectLivePageSerialsValidForTest(self: *const PageList) !void {
+ const testing = std.testing;
+ var node = self.pages.first;
+ while (node) |live| : (node = live.next) {
+ try testing.expect(live.serial >= self.page_serial_epoch);
+ try testing.expect(live.serial < self.page_serial);
+ try testing.expect(self.nodeIsValid(live, live.serial));
+ }
+}
+
+test "PageList Pin rightWrap exact row multiple" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 10, 3, null);
+ defer s.deinit();
+
+ const start = s.pin(.{ .active = .{ .x = 5, .y = 0 } }).?;
+ const wrapped = start.rightWrap(14).?;
+ _ = wrapped.rowAndCell();
+
+ try testing.expectEqual(
+ point.Point{ .active = .{ .x = 9, .y = 1 } },
+ s.pointFromPin(.active, wrapped),
+ );
+}
+
+test "PageList Pin leftWrap exact row multiple" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 10, 3, null);
+ defer s.deinit();
+
+ const start = s.pin(.{ .active = .{ .x = 5, .y = 2 } }).?;
+ const wrapped = start.leftWrap(15).?;
+ _ = wrapped.rowAndCell();
+
+ try testing.expectEqual(
+ point.Point{ .active = .{ .x = 0, .y = 1 } },
+ s.pointFromPin(.active, wrapped),
+ );
+}
+
+test "PageList Pin rightWrap maximum distance" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 1, 3, null);
+ defer s.deinit();
+
+ const start = s.pin(.{ .active = .{ .y = 0 } }).?;
+ try testing.expectEqual(null, start.rightWrap(std.math.maxInt(usize)));
+}
+
+test "PageList Pin leftWrap maximum distance" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 1, 3, null);
+ defer s.deinit();
+
+ const start = s.pin(.{ .active = .{ .y = 2 } }).?;
+ try testing.expectEqual(null, start.leftWrap(std.math.maxInt(usize)));
+}
+
+test "PageList incremental compression skips visible history" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(3);
+
+ const initial_activity = s.page_compression.activity_serial;
+ try testing.expect(initial_activity > 0);
+
+ s.scroll(.top);
+ const top_activity = s.page_compression.activity_serial;
+ try testing.expect(top_activity != initial_activity);
+ try testing.expectEqual(
+ IncrementalCompressionResult.complete,
+ s.compress(.drain),
+ );
+
+ const first = s.pages.first.?;
+ const second = first.next.?;
+ try testing.expectEqual(first, s.getTopLeft(.viewport).node);
+ try testing.expectEqual(first, s.getBottomRight(.viewport).?.node);
+ try testing.expect(!first.isCompressed());
+
+ var eligible: CompressionIterator = .init(&s);
+ var compressed_pages: usize = 0;
+ while (eligible.next()) |node| {
+ compressed_pages += 1;
+ try testing.expect(node.isCompressed());
+ }
+ try testing.expect(compressed_pages > 0);
+
+ // Move the viewport to the start of the second page. Rendering it restores
+ // that page, while the first page which just left view becomes eligible.
+ s.scroll(.{ .row = first.rows() });
+ try testing.expectEqual(second, s.getTopLeft(.viewport).node);
+ _ = second.page();
+ try testing.expect(!second.isCompressed());
+ _ = s.compress(.drain);
+ try testing.expect(first.isCompressed());
+ try testing.expect(!second.isCompressed());
+
+ // Returning to the active area makes every complete historical page
+ // eligible again, including the page which was just visible.
+ s.scroll(.active);
+ _ = s.compress(.drain);
+ try testing.expect(second.isCompressed());
+ try testing.expect(!s.page_compression.flags.did_compress);
+ try testing.expect(!s.page_compression.flags.verifying);
+ try testing.expectEqual(@as(?u64, null), s.page_compression.last_serial);
+ try testing.expectEqual(@as(u64, 0), s.page_compression.next_serial);
+}
+
+test "PageList owns incremental compression state" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+
+ const state: IncrementalCompressionState = .{
+ .flags = .{
+ .did_compress = true,
+ .verifying = true,
+ },
+ .activity_serial = 42,
+ .last_serial = 42,
+ .next_serial = 43,
+ };
+
+ s.page_compression = state;
+ s.scroll(.top);
+ try testing.expectEqual(
+ IncrementalCompressionState{ .activity_serial = 43 },
+ s.page_compression,
+ );
+
+ // Every scroll restarts traversal, even if clamping leaves the viewport in
+ // the same place. Missing an eligible page is worse than a no-op pass.
+ s.page_compression = state;
+ s.scroll(.top);
+ try testing.expectEqual(
+ IncrementalCompressionState{ .activity_serial = 43 },
+ s.page_compression,
+ );
+
+ s.page_compression = state;
+ s.scroll(.active);
+ try testing.expectEqual(
+ IncrementalCompressionState{ .activity_serial = 43 },
+ s.page_compression,
+ );
+
+ s.page_compression = state;
+ try s.resize(.{ .cols = 80, .rows = 24 });
+ try testing.expectEqual(
+ IncrementalCompressionState{ .activity_serial = 43 },
+ s.page_compression,
+ );
+
+ s.page_compression = state;
+ s.reset();
+ try testing.expectEqual(
+ IncrementalCompressionState{ .activity_serial = 42 },
+ s.page_compression,
+ );
+
+ s.page_compression = state;
+ try testing.expectEqual(
+ IncrementalCompressionResult.complete,
+ s.compress(.full),
+ );
+ try testing.expectEqual(
+ IncrementalCompressionState{ .activity_serial = 42 },
+ s.page_compression,
+ );
+
+ s.page_compression = state;
+ var cloned = try s.clone(testing.allocator, .{
+ .top = .{ .active = .{} },
+ });
+ defer cloned.deinit();
+ try testing.expectEqual(
+ IncrementalCompressionState{},
+ cloned.page_compression,
+ );
+}
+
+test "PageList replacements preserve compression continuation and mark activity" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+
+ const state: IncrementalCompressionState = .{
+ .flags = .{ .verifying = true },
+ .activity_serial = 42,
+ .last_serial = 7,
+ .next_serial = 8,
+ };
+ const expected: IncrementalCompressionState = .{
+ .flags = .{ .verifying = true },
+ .activity_serial = 43,
+ .last_serial = 7,
+ .next_serial = 8,
+ };
+
+ s.page_compression = state;
+ const replacement = try s.increaseCapacity(s.pages.first.?, null);
+ try testing.expectEqual(expected, s.page_compression);
+
+ s.page_compression = state;
+ _ = (try s.compact(replacement)).?;
+ try testing.expectEqual(expected, s.page_compression);
+}
+
+test "PageList incremental compression bounds inspected pages" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(incremental_compression_max_inspected + 1);
+
+ // Precompress every candidate so the incremental pass exercises its
+ // metadata-only skip budget without stopping at a resident attempt.
+ _ = s.compress(.full);
+ s.page_compression.reset();
+ s.page_compression.markActivity();
+ try testing.expectEqual(
+ incremental_compression_max_inspected + 1,
+ s.memoryStats().compressed_pages,
+ );
+
+ var expected_last = s.pages.first.?;
+ for (1..incremental_compression_max_inspected) |_|
+ expected_last = expected_last.next.?;
+
+ const first = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, first);
+ try testing.expectEqual(
+ expected_last.serial,
+ s.page_compression.last_serial.?,
+ );
+
+ const second = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, second);
+ try testing.expect(s.page_compression.flags.verifying);
+ try testing.expect(s.page_compression.last_serial == null);
+
+ // The verification pass is bounded independently, too.
+ try testing.expectEqual(
+ IncrementalCompressionResult.pending,
+ s.compress(.incremental),
+ );
+ try testing.expectEqual(
+ IncrementalCompressionResult.complete,
+ s.compress(.incremental),
+ );
+}
+
+test "PageList incremental compression advances after failure" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(2);
+
+ const first = s.pages.first.?;
+ const second = first.next.?;
+ var prng = std.Random.DefaultPrng.init(0x494E_4352_5041_5353);
+ prng.random().bytes(first.page().memory);
+
+ const failed = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, failed);
+ try testing.expect(!first.isCompressed());
+
+ // The unsuccessful first page does not stall the pass. The next step
+ // continues at the following serial and compresses that page.
+ const continued = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, continued);
+ try testing.expect(second.isCompressed());
+}
+
+test "PageList incremental compression advances after allocation failure" {
+ const testing = std.testing;
+
+ var failing = testing.FailingAllocator.init(testing.allocator, .{});
+ const alloc = failing.allocator();
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(2);
+ const first = s.pages.first.?;
+ const second = first.next.?;
+
+ // Pool preheating supplies compression scratch. Failing the allocator's
+ // next request therefore rejects the exact encoded allocation while the
+ // source page and pass remain valid.
+ failing.fail_index = failing.alloc_index;
+ const failed = s.compress(.incremental);
+ try testing.expect(failing.has_induced_failure);
+ try testing.expectEqual(IncrementalCompressionResult.pending, failed);
+ try testing.expect(!first.isCompressed());
+
+ // Allow allocations again. The pass must continue with the following page
+ // rather than retrying the failed candidate.
+ failing.fail_index = std.math.maxInt(usize);
+ const continued = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, continued);
+ try testing.expect(second.isCompressed());
+}
+
+test "PageList incremental compression advances after decommit failure" {
+ const testing = std.testing;
+ const tw = compressPage_tw;
+ defer tw.end(.reset) catch unreachable;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(2);
+
+ tw.errorAlways(.decommit, error.DecommitFailed);
+ const failed = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, failed);
+ try testing.expect(!s.pages.first.?.isCompressed());
+ try tw.end(.reset);
+
+ // The failed candidate remains resident and the pass continues at the
+ // next serial once reclamation is available again.
+ const continued = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, continued);
+ try testing.expect(s.pages.first.?.next.?.isCompressed());
+}
+
+test "PageList incremental compression restarts after replacement" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(1);
+
+ const initial = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, initial);
+ try testing.expect(s.pages.first.?.isCompressed());
+
+ const old = s.pages.first.?;
+ const old_serial = old.serial;
+ var replacement = old;
+ while (replacement.page().memory.len <= std_size) {
+ replacement = try s.increaseCapacity(
+ replacement,
+ .grapheme_bytes,
+ );
+ }
+ try testing.expect(replacement.serial != old_serial);
+ try testing.expect(replacement.page().memory.len > std_size);
+ try testing.expect(!replacement.isCompressed());
+
+ // The exact continuation serial disappeared with the old node. The pass
+ // restarts at the first page and considers the oversized replacement.
+ const restarted = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, restarted);
+ try testing.expect(replacement.isCompressed());
+}
+
+test "PageList incremental compression restarts after reset" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(1);
+
+ const initial = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, initial);
+ try testing.expect(s.pages.first.?.isCompressed());
+
+ // Reset replaces every page and clears the PageList-owned traversal.
+ s.reset();
+ try s.growColdPagesForTest(1);
+ const restarted = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, restarted);
+ try testing.expect(s.pages.first.?.isCompressed());
+}
+
+test "PageList incremental compression restarts after active boundary resize" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(1);
+
+ const initial = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, initial);
+ try testing.expect(s.pages.first.?.isCompressed());
+
+ const first = s.pages.first.?;
+ const all_rows: size.CellCountInt = @intCast(s.total_rows);
+ try s.resize(.{ .rows = all_rows });
+ try testing.expectEqual(first, s.getTopLeft(.active).node);
+
+ // Restore the page while it is active. Resize reset the traversal, and
+ // active contents remain ineligible.
+ _ = first.page();
+ const active = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, active);
+ try testing.expectEqual(
+ IncrementalCompressionResult.complete,
+ s.compress(.incremental),
+ );
+
+ // Shrinking the active area makes the page fully historical again. The
+ // resize reset the PageList-owned cursor, so the next step can reclaim it.
+ try s.resize(.{ .rows = 24 });
+ try s.growColdPagesForTest(1);
+ try testing.expectEqual(
+ IncrementalCompressionResult.pending,
+ s.compress(.incremental),
+ );
+ try testing.expect(first.isCompressed());
+}
+
+test "PageList incremental compression restarts after prune reuse" {
+ const testing = std.testing;
+
+ var s = try init(
+ testing.allocator,
+ 80,
+ 24,
+ 2 * PagePool.item_size,
+ );
+ defer s.deinit();
+ try s.growColdPagesForTest(1);
+
+ const initial = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, initial);
+ try testing.expect(s.pages.first.?.isCompressed());
+
+ const reused = s.pages.first.?;
+ const old_serial = reused.serial;
+ while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) {
+ _ = try s.grow();
+ }
+ try testing.expectEqual(reused, (try s.grow()).?);
+ try testing.expect(reused.serial != old_serial);
+
+ // Make the remaining old page fully historical. The continuation serial
+ // disappeared when its node was recycled, so the pass safely restarts.
+ try s.growColdPagesForTest(1);
+ _ = s.compress(.incremental);
+ try testing.expectEqual(@as(usize, 1), s.memoryStats().compressed_pages);
+}
+
+test "PageList bounded pruning after partial erase preserves live serials" {
+ const testing = std.testing;
+
+ var s = try init(
+ testing.allocator,
+ 80,
+ 24,
+ 2 * PagePool.item_size,
+ );
+ defer s.deinit();
+
+ while (s.totalPages() < 2) _ = try s.grow();
+ const first = s.pages.first.?;
+ const old_serial = first.serial;
+ const old_rows = first.rows();
+
+ s.eraseHistory(.{ .history = .{ .y = 0 } });
+ try testing.expectEqual(first, s.pages.first.?);
+ try testing.expectEqual(old_rows - 1, first.rows());
+ try testing.expect(!s.nodeIsValid(first, old_serial));
+
+ try s.fillLastPageForTest();
+ _ = try s.grow();
+ try s.expectLivePageSerialsValidForTest();
+}
+
+test "PageList partial erase restarts compression before continuation" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(incremental_compression_max_inspected + 1);
+ _ = s.compress(.full);
+
+ const first = s.pages.first.?;
+ try testing.expect(first.isCompressed());
+
+ var marker = first;
+ for (1..incremental_compression_max_inspected) |_| marker = marker.next.?;
+ s.page_compression = .{
+ .flags = .{ .verifying = true },
+ .last_serial = marker.serial,
+ .next_serial = s.page_serial,
+ };
+
+ const activity = s.page_compression.activity_serial;
+ s.eraseHistory(.{ .history = .{ .y = 0 } });
+ try testing.expect(!first.isCompressed());
+ try testing.expect(activity != s.page_compression.activity_serial);
+
+ // The changed generation is before the saved marker, so continuation must
+ // restart and recompress it instead of reporting verification complete.
+ try testing.expectEqual(
+ IncrementalCompressionResult.pending,
+ s.compress(.incremental),
+ );
+ try testing.expect(first.isCompressed());
+}
+
+test "PageList bounded pruning after split invalidation preserves live serials" {
+ const testing = std.testing;
+
+ var s = try init(
+ testing.allocator,
+ 80,
+ 24,
+ 2 * PagePool.item_size,
+ );
+ defer s.deinit();
+
+ while (s.totalPages() < 2) _ = try s.grow();
+ const first = s.pages.first.?;
+ const old_serial = first.serial;
+ const activity = s.page_compression.activity_serial;
+
+ try s.split(.{
+ .node = first,
+ .y = first.rows() / 2,
+ .x = 0,
+ });
+ try testing.expect(!s.nodeIsValid(first, old_serial));
+ try testing.expect(activity != s.page_compression.activity_serial);
+
+ try s.fillLastPageForTest();
+ _ = try s.grow();
+ try s.expectLivePageSerialsValidForTest();
+}
+
+test "PageList repeated bounded pruning after split preserves live serials" {
+ const testing = std.testing;
+
+ var s = try init(
+ testing.allocator,
+ 80,
+ 24,
+ 3 * PagePool.item_size,
+ );
+ defer s.deinit();
+
+ const epoch = s.page_serial_epoch;
+ while (s.totalPages() < 3) _ = try s.grow();
+ const first = s.pages.first.?;
+ try s.split(.{
+ .node = first,
+ .y = first.rows() / 2,
+ .x = 0,
+ });
+
+ // The split target has a fresh serial but precedes older successor pages.
+ // Prune both the old source and then that target while verifying ordinary
+ // list mutation does not advance the whole-list validity epoch.
+ for (0..2) |_| {
+ while (s.pages.last.?.rows() < s.pages.last.?.capacity().rows) {
+ _ = try s.grow();
+ }
+ _ = try s.grow();
+
+ // Ordinary pruning invalidates one generation at a time through live
+ // list validation; only reset may begin a new whole-list epoch.
+ try testing.expectEqual(epoch, s.page_serial_epoch);
+ try s.expectLivePageSerialsValidForTest();
+ }
+}
+
+test "PageList bounded pruning after front replacement preserves live serials" {
+ const testing = std.testing;
+
+ var s = try init(
+ testing.allocator,
+ 80,
+ 24,
+ 2 * PagePool.item_size,
+ );
+ defer s.deinit();
+
+ while (s.totalPages() < 2) _ = try s.grow();
+ const old = s.pages.first.?;
+ const old_serial = old.serial;
+ const replacement = try s.increaseCapacity(old, null);
+ try testing.expect(replacement != old);
+ try testing.expect(!s.nodeIsValid(old, old_serial));
+
+ try s.fillLastPageForTest();
+ _ = try s.grow();
+
+ try s.expectLivePageSerialsValidForTest();
+}
+
+test "PageList bounded pruning after middle replacement preserves live serials" {
+ const testing = std.testing;
+
+ var s = try init(
+ testing.allocator,
+ 80,
+ 24,
+ 3 * PagePool.item_size,
+ );
+ defer s.deinit();
+
+ while (s.totalPages() < 3) _ = try s.grow();
+ const old = s.pages.first.?.next.?;
+ const old_serial = old.serial;
+ const replacement = try s.increaseCapacity(old, null);
+ try testing.expect(replacement != old);
+ try testing.expect(!s.nodeIsValid(old, old_serial));
+
+ // Prune the original first page and then the fresh middle replacement.
+ for (0..2) |_| {
+ try s.fillLastPageForTest();
+ _ = try s.grow();
+ try s.expectLivePageSerialsValidForTest();
+ }
+}
+
+test "PageList incremental compression restarts after earlier replacement" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(3);
+
+ _ = s.compress(.incremental);
+ _ = s.compress(.incremental);
+ try testing.expect(s.pages.first.?.isCompressed());
+ try testing.expect(s.pages.first.?.next.?.isCompressed());
+
+ // Replace a page before the still-valid continuation marker. The list's
+ // allocation serial changes even though the marker itself remains, so the
+ // next step must restart and inspect the replacement.
+ const old_first = s.pages.first.?;
+ const old_serial = old_first.serial;
+ const replacement = try s.increaseCapacity(
+ old_first,
+ .grapheme_bytes,
+ );
+ try testing.expect(replacement.serial != old_serial);
+ try testing.expect(!replacement.isCompressed());
+
+ _ = s.compress(.incremental);
+ try testing.expect(replacement.isCompressed());
+}
+
+test "PageList incremental compression keeps progress after tail growth" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(incremental_compression_max_inspected + 1);
+ _ = s.compress(.full);
+ s.page_compression.reset();
+ s.page_compression.markActivity();
+
+ var expected_last = s.pages.first.?;
+ for (1..incremental_compression_max_inspected) |_|
+ expected_last = expected_last.next.?;
+
+ const first = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, first);
+ try testing.expectEqual(
+ expected_last.serial,
+ s.page_compression.last_serial.?,
+ );
+
+ // Allocate a new page at the active tail between steps. It is after the
+ // continuation marker and must not restart progress through cold history.
+ const next_serial = s.page_serial;
+ while (s.page_serial == next_serial) _ = try s.grow();
+ const continued = s.compress(.incremental);
+ try testing.expectEqual(IncrementalCompressionResult.pending, continued);
+ try testing.expect(s.page_compression.flags.verifying);
+}
+
+test "PageList memory stats do not restore compressed pages" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(2);
+
+ const before = s.memoryStats();
+ try testing.expectEqual(s.totalPages(), before.resident_pages);
+ try testing.expectEqual(@as(usize, 0), before.compressed_pages);
+ try testing.expectEqual(s.page_size, before.raw_bytes);
+ try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes);
+ try testing.expectEqual(@as(usize, 0), before.decommitted_raw_bytes);
+ try testing.expectEqual(s.page_size, before.resident_backing_bytes);
+ try testing.expectEqual(@as(usize, 0), before.encoded_bytes);
+ try testing.expectEqual(
+ before.resident_backing_bytes,
+ before.estimatedResidentBytes(),
+ );
+ try testing.expectEqual(@as(usize, 0), before.estimatedSavings());
+
+ _ = s.compress(.full);
+ const first = s.pages.first.?;
+ try testing.expectEqual(Node.Storage.compressed, first.storage());
+ try testing.expect(first.pageIfResident() == null);
+
+ const after = s.memoryStats();
+ try testing.expect(first.isCompressed());
+ try testing.expectEqual(s.totalPages(), after.resident_pages + after.compressed_pages);
+ try testing.expectEqual(@as(usize, 2), after.compressed_pages);
+ try testing.expectEqual(s.page_size, after.raw_bytes);
+ try testing.expectEqual(
+ after.raw_bytes,
+ after.resident_raw_bytes + after.decommitted_raw_bytes,
+ );
+ try testing.expectEqual(
+ after.resident_backing_bytes + after.encoded_bytes,
+ after.estimatedResidentBytes(),
+ );
+ try testing.expectEqual(
+ after.decommitted_raw_bytes - after.encoded_bytes,
+ after.estimatedSavings(),
+ );
+
+ const first_raw_len = first.metadata().memory.len;
+ const first_encoded_len = first.data.compressed.encoded.len;
+ _ = first.page();
+ try testing.expectEqual(Node.Storage.resident, first.storage());
+ try testing.expect(first.pageIfResident() != null);
+
+ const restored = s.memoryStats();
+ try testing.expectEqual(after.resident_pages + 1, restored.resident_pages);
+ try testing.expectEqual(after.compressed_pages - 1, restored.compressed_pages);
+ try testing.expectEqual(after.raw_bytes, restored.raw_bytes);
+ try testing.expectEqual(
+ after.resident_raw_bytes + first_raw_len,
+ restored.resident_raw_bytes,
+ );
+ try testing.expectEqual(
+ after.decommitted_raw_bytes - first_raw_len,
+ restored.decommitted_raw_bytes,
+ );
+ try testing.expectEqual(
+ after.resident_backing_bytes + first_raw_len,
+ restored.resident_backing_bytes,
+ );
+ try testing.expectEqual(
+ after.encoded_bytes - first_encoded_len,
+ restored.encoded_bytes,
+ );
+}
+
+test "PageList preserved page keeps compressed storage" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ const node = s.pages.first.?;
+ const resident = node.page();
+ resident.dirty = true;
+ resident.getRowAndCell(3, 2).cell.* = .init('X');
+
+ // Resident nodes can be borrowed without allocating an unnecessary copy.
+ {
+ var failing = testing.FailingAllocator.init(alloc, .{
+ .fail_index = 0,
+ });
+ var preserved = try node.pagePreservingState(failing.allocator());
+ defer preserved.deinit();
+ switch (preserved) {
+ .borrowed => |page_| try testing.expectEqual(resident, page_),
+ .owned => try testing.expect(false),
+ }
+ try testing.expect(!failing.has_induced_failure);
+ }
+
+ const expected = try alloc.dupe(u8, resident.memory);
+ defer alloc.free(expected);
+ const retained_ptr = resident.memory.ptr;
+
+ try testing.expect(s.compressPage(node));
+ const stats = s.memoryStats();
+ const expected_encoded = try alloc.dupe(u8, node.data.compressed.encoded);
+ defer alloc.free(expected_encoded);
+
+ // Test decommit simulates physical reclamation by clearing the retained
+ // mapping. A preserved page must decode elsewhere rather than restoring
+ // it.
+ try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0));
+
+ // Preserved-page allocation is opportunistic for callers. Failure leaves
+ // the node and its compressed representation untouched.
+ var failing = testing.FailingAllocator.init(alloc, .{
+ .fail_index = 0,
+ });
+ try testing.expectError(
+ error.OutOfMemory,
+ node.pagePreservingState(failing.allocator()),
+ );
+ try testing.expectEqual(Node.Storage.compressed, node.storage());
+ try testing.expectEqual(stats, s.memoryStats());
+
+ var preserved = try node.pagePreservingState(alloc);
+ defer preserved.deinit();
+ switch (preserved) {
+ .borrowed => try testing.expect(false),
+ .owned => {},
+ }
+ const page_ = preserved.page();
+
+ try testing.expect(page_.memory.ptr != retained_ptr);
+ try testing.expectEqualSlices(u8, expected, page_.memory);
+ try testing.expect(page_.dirty);
+ try testing.expectEqual(
+ @as(u21, 'X'),
+ page_.getRowAndCell(3, 2).cell.content.codepoint,
+ );
+
+ // The node still owns the same compressed representation, and neither its
+ // storage accounting nor its discarded raw mapping changed while cloning.
+ try testing.expectEqual(Node.Storage.compressed, node.storage());
+ try testing.expectEqual(stats, s.memoryStats());
+ try testing.expectEqual(retained_ptr, node.metadata().memory.ptr);
+ try testing.expect(std.mem.allEqual(u8, node.metadata().memory, 0));
+ try testing.expectEqualSlices(
+ u8,
+ expected_encoded,
+ node.data.compressed.encoded,
+ );
+}
+
+test "PageList memory stats include unused pool backing" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+
+ // Pool allocation ownership is based on the requested layout fitting in a
+ // standard item. The Page itself exposes only the initialized prefix.
+ const node = try s.createPage(.{ .cap = .{ .cols = 1, .rows = 1 } });
+ try testing.expectEqual(Node.Owned.pool, node.owned);
+ try testing.expect(node.page().memory.len < PagePool.item_size);
+ node.page().size.rows = 1;
+ s.pages.append(node);
+ s.total_rows += 1;
+
+ const raw_len = node.metadata().memory.len;
+ const before = s.memoryStats();
+ try testing.expect(before.raw_bytes < s.page_size);
+ try testing.expectEqual(before.raw_bytes, before.resident_raw_bytes);
+ try testing.expectEqual(s.page_size, before.resident_backing_bytes);
+ try testing.expectEqual(s.page_size, before.estimatedResidentBytes());
+
+ try testing.expect(s.compressPage(node));
+ const encoded_len = node.data.compressed.encoded.len;
+ const compressed = s.memoryStats();
+ try testing.expectEqual(before.raw_bytes, compressed.raw_bytes);
+ try testing.expectEqual(
+ before.resident_raw_bytes - raw_len,
+ compressed.resident_raw_bytes,
+ );
+ try testing.expectEqual(raw_len, compressed.decommitted_raw_bytes);
+ try testing.expectEqual(
+ before.resident_backing_bytes - raw_len,
+ compressed.resident_backing_bytes,
+ );
+ try testing.expectEqual(encoded_len, compressed.encoded_bytes);
+ try testing.expectEqual(
+ before.estimatedResidentBytes() - raw_len + encoded_len,
+ compressed.estimatedResidentBytes(),
+ );
+}
+
+test "PageList does not compress the mixed history and active page" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+
+ // One additional row creates history, but the history and all active rows
+ // still share the first page. The active boundary therefore has a
+ // historical prefix and must remain resident as one indivisible mapping.
+ _ = try s.grow();
+ const active = s.getTopLeft(.active);
+ try testing.expectEqual(s.pages.first.?, active.node);
+ try testing.expect(active.y > 0);
+
+ _ = s.compress(.full);
+ try testing.expect(!s.pages.first.?.isCompressed());
+}
+
+test "PageList compresses only complete cold history pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // More active rows than one page at these dimensions can hold ensures the
+ // active area spans multiple nodes when the pass chooses its boundary.
+ const active_rows = initialCapacity(80).rows + 1;
+ var s = try init(alloc, 80, active_rows, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(2);
+
+ // Move the active top into the boundary page so it has both a historical
+ // prefix and active rows while the active area still spans later pages.
+ _ = try s.grow();
+
+ const active = s.getTopLeft(.active);
+ const active_node = active.node;
+ try testing.expect(active.y > 0);
+ try testing.expect(active_node != s.pages.last.?);
+
+ var expected_compressed: usize = 0;
+ var expected_raw_bytes: usize = 0;
+ var current = s.pages.first;
+ while (current) |node| : (current = node.next) {
+ if (node == active_node) break;
+ expected_compressed += 1;
+ expected_raw_bytes += node.metadata().memory.len;
+ }
+ try testing.expectEqual(@as(usize, 2), expected_compressed);
+
+ const first = s.pages.first.?;
+ first.page().getRowAndCell(0, 0).cell.* = .init('X');
+ const expected = try alloc.dupe(u8, first.page().memory);
+ defer alloc.free(expected);
+ const first_memory = first.page().memory.ptr;
+ const page_size = s.page_size;
+
+ _ = s.compress(.full);
+ const memory = s.memoryStats();
+ try testing.expectEqual(expected_compressed, memory.compressed_pages);
+ try testing.expectEqual(expected_raw_bytes, memory.decommitted_raw_bytes);
+ try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes);
+ try testing.expectEqual(page_size, s.page_size);
+
+ current = s.pages.first;
+ var actual_encoded_bytes: usize = 0;
+ while (current) |node| : (current = node.next) {
+ if (node == active_node) break;
+ try testing.expect(node.isCompressed());
+ actual_encoded_bytes += node.data.compressed.encoded.len;
+ }
+ try testing.expectEqual(actual_encoded_bytes, memory.encoded_bytes);
+ current = active_node;
+ while (current) |node| : (current = node.next) {
+ try testing.expect(!node.isCompressed());
+ }
+
+ // Restoring the oldest page preserves both the mapping identity and all
+ // of its bytes even though the pass discarded its physical pages.
+ try testing.expectEqual(first_memory, first.metadata().memory.ptr);
+ try testing.expectEqualSlices(u8, expected, first.page().memory);
+ try testing.expectEqual(first_memory, first.page().memory.ptr);
+}
+
+test "PageList lazily restores compressed history made active by resize" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(1);
+
+ const first = s.pages.first.?;
+ first.page().getRowAndCell(0, 0).cell.* = .init('X');
+ const memory_ptr = first.page().memory.ptr;
+ const memory_len = first.page().memory.len;
+ const page_size = s.page_size;
+
+ _ = s.compress(.full);
+ try testing.expect(first.isCompressed());
+
+ // Pull all scrollback into the active area by making the viewport as tall
+ // as the complete screen. A row-only resize needs only page metadata, so
+ // the newly active page can remain compressed until its contents are used.
+ const all_rows: size.CellCountInt = @intCast(s.total_rows);
+ try s.resize(.{ .rows = all_rows });
+ const active = s.getTopLeft(.active);
+ try testing.expectEqual(first, active.node);
+ try testing.expectEqual(@as(size.CellCountInt, 0), active.y);
+ try testing.expect(first.isCompressed());
+ try testing.expectEqual(page_size, s.page_size);
+
+ // The compression pass must not reconsider the node now that it is active.
+ // Content access follows the normal page boundary, which recommits and
+ // restores the retained mapping before returning the cell.
+ _ = s.compress(.full);
+ try testing.expect(first.isCompressed());
+ const cell = s.getCell(.{ .active = .{} }).?;
+ try testing.expectEqual(@as(u21, 'X'), cell.cell.content.codepoint);
+ try testing.expect(!first.isCompressed());
+ try testing.expectEqual(memory_ptr, first.page().memory.ptr);
+ try testing.expectEqual(memory_len, first.page().memory.len);
+ try testing.expectEqual(page_size, s.page_size);
+}
+
+test "PageList full and incremental compression skip a spanning viewport" {
+ const testing = std.testing;
+
+ var full = try init(testing.allocator, 80, 24, null);
+ defer full.deinit();
+ try full.growColdPagesForTest(3);
+
+ var incremental = try init(testing.allocator, 80, 24, null);
+ defer incremental.deinit();
+ try incremental.growColdPagesForTest(3);
+
+ // Start near the end of the first page so the viewport intersects both
+ // the first and second historical page mappings.
+ const first = full.pages.first.?;
+ const overlap_rows: usize = full.rows / 2;
+ const viewport_row: usize = first.rows() - overlap_rows;
+ full.scroll(.{ .row = viewport_row });
+ incremental.scroll(.{ .row = viewport_row });
+ try testing.expect(
+ full.getTopLeft(.viewport).node !=
+ full.getBottomRight(.viewport).?.node,
+ );
+ const second = first.next.?;
+ try testing.expectEqual(first, full.getTopLeft(.viewport).node);
+ try testing.expectEqual(second, full.getBottomRight(.viewport).?.node);
+
+ _ = full.compress(.full);
+ _ = incremental.compress(.drain);
+ try testing.expectEqual(full.memoryStats(), incremental.memoryStats());
+ try testing.expect(!first.isCompressed());
+ try testing.expect(!second.isCompressed());
+
+ const full_active = full.getTopLeft(.active).node;
+ const incremental_active = incremental.getTopLeft(.active).node;
+ var full_node = full.pages.first.?;
+ var incremental_node = incremental.pages.first.?;
+ while (full_node != full_active) {
+ try testing.expectEqual(
+ full_node.isCompressed(),
+ incremental_node.isCompressed(),
+ );
+
+ full_node = full_node.next.?;
+ incremental_node = incremental_node.next.?;
+ }
+ try testing.expectEqual(incremental_active, incremental_node);
+
+ var eligible: CompressionIterator = .init(&full);
+ var compressed_pages: usize = 0;
+ while (eligible.next()) |node| {
+ compressed_pages += 1;
+ try testing.expect(node.isCompressed());
+ }
+ try testing.expect(compressed_pages > 0);
+}
+
+test "PageList cold compression continues after an incompressible page" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ try s.growColdPagesForTest(2);
+
+ const first = s.pages.first.?;
+ const second = first.next.?;
+ var prng = std.Random.DefaultPrng.init(0x434F_4C44_5041_4745);
+ prng.random().bytes(first.page().memory);
+
+ const page_size = s.page_size;
+ _ = s.compress(.full);
+ const memory = s.memoryStats();
+ try testing.expectEqual(@as(usize, 1), memory.compressed_pages);
+ try testing.expect(!first.isCompressed());
+ try testing.expect(second.isCompressed());
+ try testing.expectEqual(
+ second.metadata().memory.len,
+ memory.decommitted_raw_bytes,
+ );
+ try testing.expect(memory.encoded_bytes < memory.decommitted_raw_bytes);
+ try testing.expectEqual(page_size, s.page_size);
+
+ // Failed resident candidates are deliberately retried on later passes,
+ // while the successful page remains compressed and is skipped.
+ _ = s.compress(.full);
+ try testing.expectEqual(memory, s.memoryStats());
+}
+
+test "PageList compression restores through page access" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ const node = s.pages.first.?;
+ const page = node.page();
+ page.dirty = true;
+ page.getRowAndCell(3, 2).cell.* = .init('X');
+
+ const expected = try alloc.dupe(u8, page.memory);
+ defer alloc.free(expected);
+ const memory_ptr = page.memory.ptr;
+ const memory_len = page.memory.len;
+ const page_size = s.page_size;
+
+ try testing.expect(s.compressPage(node));
+ try testing.expect(node.isCompressed());
+ try testing.expectEqual(@as(size.CellCountInt, 24), node.rows());
+ try testing.expectEqual(@as(size.CellCountInt, 80), node.cols());
+ try testing.expectEqual(memory_ptr, node.metadata().memory.ptr);
+ try testing.expectEqual(memory_len, node.metadata().memory.len);
+ try testing.expectEqual(page_size, s.page_size);
+
+ // Pin access restores the page without changing its retained mapping.
+ const page_pin: Pin = .{ .node = node, .x = 3, .y = 2 };
+ try testing.expectEqual(
+ @as(u21, 'X'),
+ page_pin.rowAndCell().cell.content.codepoint,
+ );
+ try testing.expect(!node.isCompressed());
+ try testing.expectEqual(memory_ptr, node.page().memory.ptr);
+ try testing.expectEqualSlices(u8, expected, node.page().memory);
+ try testing.expect(node.page().dirty);
+
+ // Recompressing exercises reuse of the page-pool scratch item. Page
+ // iterator chunks also restore before exposing row memory.
+ try testing.expect(s.compressPage(node));
+ var page_it = (Pin{ .node = node }).pageIterator(.right_down, null);
+ const chunk = page_it.next().?;
+ try testing.expectEqual(node.rows(), chunk.rows().len);
+ try testing.expect(!node.isCompressed());
+ try testing.expectEqualSlices(u8, expected, node.page().memory);
+
+ // Read-only PageList operations restore through the same boundary.
+ try testing.expect(s.compressPage(node));
+ var cloned = try s.clone(alloc, .{
+ .top = .{ .screen = .{} },
+ });
+ defer cloned.deinit();
+ try testing.expect(!node.isCompressed());
+ try testing.expectEqual(
+ @as(u21, 'X'),
+ cloned.pages.first.?.page().getRowAndCell(3, 2).cell.content.codepoint,
+ );
+}
+
+test "PageList compression uses temporary scratch for oversized pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ var node = s.pages.first.?;
+ while (node.page().memory.len <= std_size) {
+ node = try s.increaseCapacity(node, .grapheme_bytes);
+ }
+
+ const expected = try alloc.dupe(u8, node.page().memory);
+ defer alloc.free(expected);
+ const memory_ptr = node.page().memory.ptr;
+ const memory_len = node.page().memory.len;
+ const page_size = s.page_size;
+
+ try testing.expect(s.compressPage(node));
+ try testing.expect(node.isCompressed());
+ try testing.expectEqual(page_size, s.page_size);
+ try testing.expectEqual(memory_ptr, node.metadata().memory.ptr);
+ try testing.expectEqual(memory_len, node.metadata().memory.len);
+
+ try testing.expectEqualSlices(u8, expected, node.page().memory);
+ try testing.expect(!node.isCompressed());
+ try testing.expectEqual(memory_ptr, node.page().memory.ptr);
+}
+
+test "PageList compression leaves incompressible pages resident" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ const node = s.pages.first.?;
+ const original = try alloc.dupe(u8, node.page().memory);
+ defer alloc.free(original);
+ defer @memcpy(node.page().memory, original);
+
+ var prng = std.Random.DefaultPrng.init(0x5041_4745_4C49_5354);
+ prng.random().bytes(node.page().memory);
+ const page_size = s.page_size;
+
+ try testing.expect(!s.compressPage(node));
+ try testing.expect(!node.isCompressed());
+ try testing.expectEqual(page_size, s.page_size);
+}
+
+test "PageList reset discards malformed compressed data" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+
+ const node = s.pages.first.?;
+ try testing.expect(s.compressPage(node));
+ @memset(node.data.compressed.encoded, 0xFF);
+
+ s.reset();
+ try testing.expect(!s.pages.first.?.isCompressed());
+ try testing.expectEqual(@as(usize, 1), s.totalPages());
+}
+
+test "PageList deinit discards malformed compressed data" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ const node = s.pages.first.?;
+ try testing.expect(s.compressPage(node));
+ @memset(node.data.compressed.encoded, 0xFF);
+
+ s.deinit();
+}
+
+test "PageList prune reuses malformed compressed page memory" {
+ const testing = std.testing;
+
+ var s = try init(
+ testing.allocator,
+ 80,
+ 24,
+ 2 * PagePool.item_size,
+ );
+ defer s.deinit();
+
+ // Allocate the second page so the first one can be pruned and reused.
+ while (s.pages.first == s.pages.last) _ = try s.grow();
+ const first = s.pages.first.?;
+ try testing.expect(s.compressPage(first));
+ @memset(first.data.compressed.encoded, 0xFF);
+
+ var reused = false;
+ const growth_limit = @as(usize, s.pages.last.?.capacity().rows) + 1;
+ for (0..growth_limit) |_| {
+ if (try s.grow()) |new_node| {
+ if (new_node == first) {
+ reused = true;
+ break;
+ }
+ }
+ }
+
+ try testing.expect(reused);
+ try testing.expectEqual(first, s.pages.last.?);
+ try testing.expect(!first.isCompressed());
+ try testing.expectEqual(@as(size.CellCountInt, 1), first.rows());
+ first.page().assertIntegrity();
+}
+
test "PageList" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -5710,7 +8046,7 @@ test "PageList init more than max cols" {
// We expect a single, non-standard page
try testing.expect(s.pages.first != null);
- try testing.expect(s.pages.first.?.data.memory.len > std_size);
+ try testing.expect(s.pages.first.?.page().memory.len > std_size);
// Initial total rows should be our row count
try testing.expectEqual(s.rows, s.total_rows);
@@ -5794,17 +8130,17 @@ test "PageList pointFromPin active from prior page" {
var s = try init(alloc, 80, 24, null);
defer s.deinit();
// Grow so we take up at least 5 pages.
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
var cur_page = s.pages.last.?;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
for (0..page.capacity.rows * 5) |_| {
if (try s.grow()) |new_page| {
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
cur_page = new_page;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
}
}
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
{
try testing.expectEqual(point.Point{
@@ -5837,17 +8173,17 @@ test "PageList pointFromPin traverse pages" {
defer s.deinit();
// Grow so we take up at least 2 pages.
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
var cur_page = s.pages.last.?;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
for (0..page.capacity.rows * 2) |_| {
if (try s.grow()) |new_page| {
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
cur_page = new_page;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
}
}
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
{
const pages = s.totalPages();
@@ -5875,6 +8211,44 @@ test "PageList pointFromPin traverse pages" {
}) == null);
}
}
+
+test "PageList pointFromPin rejects overflowing screen coordinate" {
+ const testing = std.testing;
+
+ // Use maximum-height metadata-only pages to model a valid scrollback just
+ // beyond the u32 coordinate range without allocating their backing cells.
+ const page_count = 65_539;
+ const rows_per_page = std.math.maxInt(size.CellCountInt);
+ const nodes = try testing.allocator.alloc(Node, page_count);
+ defer testing.allocator.free(nodes);
+
+ for (nodes, 0..) |*node, i| {
+ node.* = .{
+ .prev = if (i > 0) &nodes[i - 1] else null,
+ .next = if (i + 1 < nodes.len) &nodes[i + 1] else null,
+ .data = .{ .resident = undefined },
+ .serial = @intCast(i),
+ .owned = .heap,
+ };
+ node.data.resident.size = .{
+ .cols = 1,
+ .rows = rows_per_page,
+ };
+ }
+
+ var s: PageList = undefined;
+ s.pages = .{
+ .first = &nodes[0],
+ .last = &nodes[nodes.len - 1],
+ };
+
+ try testing.expect(s.pointFromPin(.screen, .{
+ .node = &nodes[nodes.len - 1],
+ .y = 0,
+ .x = 0,
+ }) == null);
+}
+
test "PageList active after grow" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -5937,8 +8311,8 @@ test "PageList grow allows exceeding max size for active area" {
{
var it = s.pages.first;
while (it) |page| : (it = page.next) {
- page.data.size.rows = 1;
- page.data.capacity.rows = 1;
+ page.page().size.rows = 1;
+ page.page().capacity.rows = 1;
}
// Avoid integrity check failures
@@ -5967,7 +8341,7 @@ test "PageList grow prune required with a single page" {
// This is important because it triggers a scenario where our calculated
// minSize() which is supposed to accommodate 2 pages is no longer true.
while (true) {
- const layout = Page.layout(s.pages.first.?.data.capacity);
+ const layout = Page.layout(s.pages.first.?.capacity());
if (layout.total_size > std_size) break;
_ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes);
}
@@ -5980,7 +8354,7 @@ test "PageList grow prune required with a single page" {
// page.
const rem = rem: {
const page = s.pages.first.?;
- break :rem page.data.capacity.rows - page.data.size.rows;
+ break :rem page.capacity().rows - page.rows();
};
for (0..rem) |_| try testing.expect(try s.grow() == null);
@@ -6205,6 +8579,20 @@ test "PageList scroll delta row back overflow" {
}, s.scrollbar());
}
+test "PageList scroll minimum row delta" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 10, 3, null);
+ defer s.deinit();
+
+ // Create one row of history so scrolling all the way back has an
+ // observable result.
+ try s.growRows(1);
+ s.scroll(.{ .delta_row = std.math.minInt(isize) });
+
+ try testing.expectEqual(Viewport.top, s.viewport);
+}
+
test "PageList scroll delta row forward" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -6870,7 +9258,7 @@ test "PageList: jump zero prompts" {
defer s.deinit();
try s.growRows(3);
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 1);
rac.row.semantic_prompt = .prompt;
@@ -6890,6 +9278,16 @@ test "PageList: jump zero prompts" {
}, s.scrollbar());
}
+test "PageList: jump minimum prompt delta" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 10, 3, null);
+ defer s.deinit();
+
+ s.scroll(.{ .delta_prompt = std.math.minInt(isize) });
+ try testing.expectEqual(Viewport.active, s.viewport);
+}
+
test "Screen: jump back one prompt" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -6898,7 +9296,7 @@ test "Screen: jump back one prompt" {
defer s.deinit();
try s.growRows(3);
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 1);
rac.row.semantic_prompt = .prompt;
@@ -7016,7 +9414,7 @@ test "PageList grow fit in capacity" {
defer s.deinit();
// So we know we're using capacity to grow
- const last = &s.pages.last.?.data;
+ const last = s.pages.last.?.page();
try testing.expect(last.size.rows < last.capacity.rows);
// Grow
@@ -7039,7 +9437,7 @@ test "PageList grow allocate" {
// Grow to capacity
const last_node = s.pages.last.?;
- const last = &s.pages.last.?.data;
+ const last = s.pages.last.?.page();
for (0..last.capacity.rows - last.size.rows) |_| {
try testing.expect(try s.grow() == null);
}
@@ -7058,6 +9456,47 @@ test "PageList grow allocate" {
}
}
+test "PageList Cell screenPoint supports long scrollback" {
+ const testing = std.testing;
+
+ // A modest number of full-size page nodes is enough to exceed the u16
+ // row range without allocating any page backing memory. screenPoint only
+ // reads the linked metadata while calculating the absolute coordinate.
+ const page_count = 307;
+ const rows_per_page = std_capacity.rows;
+ const nodes = try testing.allocator.alloc(Node, page_count);
+ defer testing.allocator.free(nodes);
+
+ for (nodes, 0..) |*node, i| {
+ node.* = .{
+ .prev = if (i > 0) &nodes[i - 1] else null,
+ .next = if (i + 1 < nodes.len) &nodes[i + 1] else null,
+ .data = .{ .resident = undefined },
+ .serial = @intCast(i),
+ .owned = .heap,
+ };
+ node.data.resident.size = .{
+ .cols = 1,
+ .rows = rows_per_page,
+ };
+ }
+
+ const expected_y: u32 = (page_count - 1) * @as(u32, rows_per_page);
+ try testing.expect(expected_y > std.math.maxInt(size.CellCountInt));
+
+ const cell: Cell = .{
+ .node = &nodes[nodes.len - 1],
+ .row = undefined,
+ .cell = undefined,
+ .row_idx = 0,
+ .col_idx = 0,
+ };
+ try testing.expectEqual(point.Point{ .screen = .{
+ .x = 0,
+ .y = expected_y,
+ } }, cell.screenPoint());
+}
+
test "PageList grow prune scrollback" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -7068,14 +9507,14 @@ test "PageList grow prune scrollback" {
// Grow to capacity
const page1_node = s.pages.last.?;
- const page1 = page1_node.data;
+ const page1 = page1_node.page();
for (0..page1.capacity.rows - page1.size.rows) |_| {
try testing.expect(try s.grow() == null);
}
// Grow and allocate one more page. Then fill that page up.
const page2_node = (try s.grow()).?;
- const page2 = page2_node.data;
+ const page2 = page2_node.page();
for (0..page2.capacity.rows - page2.size.rows) |_| {
try testing.expect(try s.grow() == null);
}
@@ -7137,14 +9576,14 @@ test "PageList grow prune scrollback with viewport pin not in pruned page" {
// Grow to capacity of first page
const page1_node = s.pages.last.?;
- const page1 = page1_node.data;
+ const page1 = page1_node.page();
for (0..page1.capacity.rows - page1.size.rows) |_| {
try testing.expect(try s.grow() == null);
}
// Grow and allocate second page, then fill it up
const page2_node = (try s.grow()).?;
- const page2 = page2_node.data;
+ const page2 = page2_node.page();
for (0..page2.capacity.rows - page2.size.rows) |_| {
try testing.expect(try s.grow() == null);
}
@@ -7195,7 +9634,7 @@ test "PageList eraseRows invalidates viewport offset cache" {
defer s.deinit();
// Grow so we take up several pages worth of history
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
{
var cur_page = s.pages.last.?;
for (0..page.capacity.rows * 3) |_| {
@@ -7235,7 +9674,7 @@ test "PageList eraseRow invalidates viewport offset cache" {
defer s.deinit();
// Grow so we take up several pages worth of history
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
{
var cur_page = s.pages.last.?;
for (0..page.capacity.rows * 3) |_| {
@@ -7274,7 +9713,7 @@ test "PageList eraseRowBounded invalidates viewport offset cache" {
defer s.deinit();
// Grow so we take up several pages worth of history
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
{
var cur_page = s.pages.last.?;
for (0..page.capacity.rows * 3) |_| {
@@ -7306,6 +9745,53 @@ test "PageList eraseRowBounded invalidates viewport offset cache" {
}, s.scrollbar());
}
+test "PageList row erasure renews affected page generations" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+ while (s.totalPages() < 2) _ = try s.grow();
+
+ const first = s.pages.first.?;
+ const second = first.next.?;
+ var first_serial = first.serial;
+ var second_serial = second.serial;
+ var activity = s.page_compression.activity_serial;
+
+ try s.eraseRow(.{ .history = .{ .y = 0 } });
+ try testing.expect(!s.nodeIsValid(first, first_serial));
+ try testing.expect(!s.nodeIsValid(second, second_serial));
+ try testing.expect(activity != s.page_compression.activity_serial);
+
+ first_serial = first.serial;
+ second_serial = second.serial;
+ activity = s.page_compression.activity_serial;
+ try s.eraseRowBounded(
+ .{ .history = .{ .y = 0 } },
+ first.rows() + 1,
+ );
+ try testing.expect(!s.nodeIsValid(first, first_serial));
+ try testing.expect(!s.nodeIsValid(second, second_serial));
+ try testing.expect(activity != s.page_compression.activity_serial);
+}
+
+test "PageList trailing row truncation renews page generation" {
+ const testing = std.testing;
+
+ var s = try init(testing.allocator, 80, 24, null);
+ defer s.deinit();
+
+ const node = s.pages.last.?;
+ const old_serial = node.serial;
+ const trimmed = s.trimTrailingBlankRows(1);
+ s.total_rows -= trimmed;
+ try testing.expectEqual(@as(size.CellCountInt, 1), trimmed);
+ try testing.expect(!s.nodeIsValid(node, old_serial));
+
+ _ = try s.grow();
+ try s.expectLivePageSerialsValidForTest();
+}
+
test "PageList eraseRowBounded multi-page invalidates viewport offset cache" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -7314,7 +9800,7 @@ test "PageList eraseRowBounded multi-page invalidates viewport offset cache" {
defer s.deinit();
// Grow so we take up several pages worth of history
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
{
var cur_page = s.pages.last.?;
for (0..page.capacity.rows * 3) |_| {
@@ -7355,7 +9841,7 @@ test "PageList eraseRowBounded full page shift invalidates viewport offset cache
defer s.deinit();
// Grow so we take up several pages worth of history
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
{
var cur_page = s.pages.last.?;
for (0..page.capacity.rows * 4) |_| {
@@ -7398,7 +9884,7 @@ test "PageList eraseRowBounded exhausts pages invalidates viewport offset cache"
defer s.deinit();
// Grow so we take up several pages worth of history
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
{
var cur_page = s.pages.last.?;
for (0..page.capacity.rows * 3) |_| {
@@ -7442,11 +9928,11 @@ test "PageList increaseCapacity to increase styles" {
var s = try init(alloc, 2, 2, 0);
defer s.deinit();
- const original_styles_cap = s.pages.first.?.data.capacity.styles;
+ const original_styles_cap = s.pages.first.?.capacity().styles;
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write all our data so we can assert its the same after
for (0..s.rows) |y| {
@@ -7465,7 +9951,7 @@ test "PageList increaseCapacity to increase styles" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Verify capacity doubled
try testing.expectEqual(
@@ -7493,11 +9979,11 @@ test "PageList increaseCapacity to increase graphemes" {
var s = try init(alloc, 2, 2, 0);
defer s.deinit();
- const original_cap = s.pages.first.?.data.capacity.grapheme_bytes;
+ const original_cap = s.pages.first.?.capacity().grapheme_bytes;
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
@@ -7514,7 +10000,7 @@ test "PageList increaseCapacity to increase graphemes" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
try testing.expectEqual(original_cap * 2, page.capacity.grapheme_bytes);
@@ -7537,11 +10023,11 @@ test "PageList increaseCapacity to increase hyperlinks" {
var s = try init(alloc, 2, 2, 0);
defer s.deinit();
- const original_cap = s.pages.first.?.data.capacity.hyperlink_bytes;
+ const original_cap = s.pages.first.?.capacity().hyperlink_bytes;
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
@@ -7558,7 +10044,7 @@ test "PageList increaseCapacity to increase hyperlinks" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
try testing.expectEqual(original_cap * 2, page.capacity.hyperlink_bytes);
@@ -7581,11 +10067,11 @@ test "PageList increaseCapacity to increase string_bytes" {
var s = try init(alloc, 2, 2, 0);
defer s.deinit();
- const original_cap = s.pages.first.?.data.capacity.string_bytes;
+ const original_cap = s.pages.first.?.capacity().string_bytes;
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
@@ -7602,7 +10088,7 @@ test "PageList increaseCapacity to increase string_bytes" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
try testing.expectEqual(original_cap * 2, page.capacity.string_bytes);
@@ -7657,7 +10143,7 @@ test "PageList increaseCapacity returns OutOfSpace at max capacity" {
) catch |err| {
// Before OutOfSpace, we should have reached maxInt
try testing.expectEqual(error.OutOfSpace, err);
- try testing.expectEqual(max_styles, s.pages.first.?.data.capacity.styles);
+ try testing.expectEqual(max_styles, s.pages.first.?.capacity().styles);
break;
};
}
@@ -7675,7 +10161,7 @@ test "PageList increaseCapacity after col shrink" {
try testing.expectEqual(5, s.cols);
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
try testing.expectEqual(5, page.size.cols);
try testing.expect(page.capacity.cols >= 10);
}
@@ -7684,7 +10170,7 @@ test "PageList increaseCapacity after col shrink" {
_ = try s.increaseCapacity(s.pages.first.?, .styles);
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// size.cols should still be 5, not reverted to capacity.cols
try testing.expectEqual(5, page.size.cols);
try testing.expectEqual(5, s.cols);
@@ -7700,19 +10186,19 @@ test "PageList increaseCapacity multi-page" {
// Grow to create a second page
const page1_node = s.pages.last.?;
- page1_node.data.pauseIntegrityChecks(true);
- for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| {
+ page1_node.page().pauseIntegrityChecks(true);
+ for (0..page1_node.capacity().rows - page1_node.rows()) |_| {
try testing.expect(try s.grow() == null);
}
- page1_node.data.pauseIntegrityChecks(false);
+ page1_node.page().pauseIntegrityChecks(false);
try testing.expect(try s.grow() != null);
// Now we have two pages
try testing.expect(s.pages.first != s.pages.last);
const page2_node = s.pages.last.?;
- const page1_styles_cap = s.pages.first.?.data.capacity.styles;
- const page2_styles_cap = page2_node.data.capacity.styles;
+ const page1_styles_cap = s.pages.first.?.capacity().styles;
+ const page2_styles_cap = page2_node.capacity().styles;
// Increase capacity on the first page only
_ = try s.increaseCapacity(s.pages.first.?, .styles);
@@ -7720,13 +10206,13 @@ test "PageList increaseCapacity multi-page" {
// First page capacity should be doubled
try testing.expectEqual(
page1_styles_cap * 2,
- s.pages.first.?.data.capacity.styles,
+ s.pages.first.?.capacity().styles,
);
// Second page should be unchanged
try testing.expectEqual(
page2_styles_cap,
- s.pages.last.?.data.capacity.styles,
+ s.pages.last.?.capacity().styles,
);
}
@@ -7738,7 +10224,7 @@ test "PageList increaseCapacity preserves dirty flag" {
defer s.deinit();
// Set page dirty flag and mark some rows as dirty
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
page.dirty = true;
const rows = page.rows.ptr(page.memory);
@@ -7751,10 +10237,10 @@ test "PageList increaseCapacity preserves dirty flag" {
const new_node = try s.increaseCapacity(s.pages.first.?, .styles);
// The page dirty flag should be preserved
- try testing.expect(new_node.data.dirty);
+ try testing.expect(new_node.page().dirty);
// Row dirty flags should be preserved
- const new_rows = new_node.data.rows.ptr(new_node.data.memory);
+ const new_rows = new_node.page().rows.ptr(new_node.page().memory);
try testing.expect(new_rows[0].dirty);
try testing.expect(!new_rows[1].dirty);
try testing.expect(new_rows[2].dirty);
@@ -7793,12 +10279,12 @@ test "PageList pageIterator two pages" {
// Grow to capacity
const page1_node = s.pages.last.?;
- const page1 = page1_node.data;
- page1_node.data.pauseIntegrityChecks(true);
+ const page1 = page1_node.page();
+ page1_node.page().pauseIntegrityChecks(true);
for (0..page1.capacity.rows - page1.size.rows) |_| {
try testing.expect(try s.grow() == null);
}
- page1_node.data.pauseIntegrityChecks(false);
+ page1_node.page().pauseIntegrityChecks(false);
try testing.expect(try s.grow() != null);
// Iterate the active area
@@ -7806,9 +10292,9 @@ test "PageList pageIterator two pages" {
{
const chunk = it.next().?;
try testing.expect(chunk.node == s.pages.first.?);
- const start = chunk.node.data.size.rows - s.rows + 1;
+ const start = chunk.node.rows() - s.rows + 1;
try testing.expectEqual(start, chunk.start);
- try testing.expectEqual(chunk.node.data.size.rows, chunk.end);
+ try testing.expectEqual(chunk.node.rows(), chunk.end);
}
{
const chunk = it.next().?;
@@ -7829,12 +10315,12 @@ test "PageList pageIterator history two pages" {
// Grow to capacity
const page1_node = s.pages.last.?;
- const page1 = page1_node.data;
- page1_node.data.pauseIntegrityChecks(true);
+ const page1 = page1_node.page();
+ page1_node.page().pauseIntegrityChecks(true);
for (0..page1.capacity.rows - page1.size.rows) |_| {
try testing.expect(try s.grow() == null);
}
- page1_node.data.pauseIntegrityChecks(false);
+ page1_node.page().pauseIntegrityChecks(false);
try testing.expect(try s.grow() != null);
// Iterate the active area
@@ -7882,12 +10368,12 @@ test "PageList pageIterator reverse two pages" {
// Grow to capacity
const page1_node = s.pages.last.?;
- const page1 = page1_node.data;
- page1_node.data.pauseIntegrityChecks(true);
+ const page1 = page1_node.page();
+ page1_node.page().pauseIntegrityChecks(true);
for (0..page1.capacity.rows - page1.size.rows) |_| {
try testing.expect(try s.grow() == null);
}
- page1_node.data.pauseIntegrityChecks(false);
+ page1_node.page().pauseIntegrityChecks(false);
try testing.expect(try s.grow() != null);
// Iterate the active area
@@ -7904,9 +10390,9 @@ test "PageList pageIterator reverse two pages" {
{
const chunk = it.next().?;
try testing.expect(chunk.node == s.pages.first.?);
- const start = chunk.node.data.size.rows - s.rows + 1;
+ const start = chunk.node.rows() - s.rows + 1;
try testing.expectEqual(start, chunk.start);
- try testing.expectEqual(chunk.node.data.size.rows, chunk.end);
+ try testing.expectEqual(chunk.node.rows(), chunk.end);
count += chunk.end - chunk.start;
}
try testing.expect(it.next() == null);
@@ -7922,12 +10408,12 @@ test "PageList pageIterator reverse history two pages" {
// Grow to capacity
const page1_node = s.pages.last.?;
- const page1 = page1_node.data;
- page1_node.data.pauseIntegrityChecks(true);
+ const page1 = page1_node.page();
+ page1_node.page().pauseIntegrityChecks(true);
for (0..page1.capacity.rows - page1.size.rows) |_| {
try testing.expect(try s.grow() == null);
}
- page1_node.data.pauseIntegrityChecks(false);
+ page1_node.page().pauseIntegrityChecks(false);
try testing.expect(try s.grow() != null);
// Iterate the active area
@@ -7943,6 +10429,76 @@ test "PageList pageIterator reverse history two pages" {
try testing.expect(it.next() == null);
}
+test "PageList PageIterator reverse count includes row zero" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 2, 2, null);
+ defer s.deinit();
+
+ var it: PageIterator = .{
+ .row = s.getTopLeft(.screen),
+ .limit = .{ .count = 1 },
+ .direction = .left_up,
+ };
+ const chunk = it.next().?;
+ try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start);
+ try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end);
+ try testing.expect(it.next() == null);
+}
+
+test "PageList PageIterator count crosses page boundaries" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ const first = s.pages.first.?;
+ first.page().pauseIntegrityChecks(true);
+ while (first.rows() < first.capacity().rows) _ = try s.grow();
+ first.page().pauseIntegrityChecks(false);
+ const second = (try s.grow()).?;
+
+ var down: PageIterator = .{
+ .row = .{ .node = first, .y = first.rows() - 1 },
+ .limit = .{ .count = 2 },
+ .direction = .right_down,
+ };
+ {
+ const chunk = down.next().?;
+ try testing.expectEqual(first, chunk.node);
+ try testing.expectEqual(first.rows() - 1, chunk.start);
+ try testing.expectEqual(first.rows(), chunk.end);
+ }
+ {
+ const chunk = down.next().?;
+ try testing.expectEqual(second, chunk.node);
+ try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start);
+ try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end);
+ }
+ try testing.expect(down.next() == null);
+
+ var up: PageIterator = .{
+ .row = .{ .node = second },
+ .limit = .{ .count = 2 },
+ .direction = .left_up,
+ };
+ {
+ const chunk = up.next().?;
+ try testing.expectEqual(second, chunk.node);
+ try testing.expectEqual(@as(size.CellCountInt, 0), chunk.start);
+ try testing.expectEqual(@as(size.CellCountInt, 1), chunk.end);
+ }
+ {
+ const chunk = up.next().?;
+ try testing.expectEqual(first, chunk.node);
+ try testing.expectEqual(first.rows() - 1, chunk.start);
+ try testing.expectEqual(first.rows(), chunk.end);
+ }
+ try testing.expect(up.next() == null);
+}
+
test "PageList cellIterator" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -7950,7 +10506,7 @@ test "PageList cellIterator" {
var s = try init(alloc, 2, 2, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -8000,7 +10556,7 @@ test "PageList cellIterator reverse" {
var s = try init(alloc, 2, 2, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -8050,7 +10606,7 @@ test "PageList promptIterator left_up" {
var s = try init(alloc, 2, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Normal prompt
{
const rac = page.getRowAndCell(0, 3);
@@ -8107,7 +10663,7 @@ test "PageList promptIterator right_down" {
var s = try init(alloc, 2, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Normal prompt
{
const rac = page.getRowAndCell(0, 3);
@@ -8164,7 +10720,7 @@ test "PageList promptIterator right_down continuation at start" {
var s = try init(alloc, 2, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt continuation at row 0 (no prior rows - simulates trimmed scrollback)
{
@@ -8207,7 +10763,7 @@ test "PageList promptIterator right_down with prompt before continuation" {
var s = try init(alloc, 2, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 2, continuation on rows 3-4
// Starting iteration from row 3 should still find the prompt at row 2
@@ -8246,7 +10802,7 @@ test "PageList promptIterator right_down limit inclusive" {
var s = try init(alloc, 2, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -8278,7 +10834,7 @@ test "PageList promptIterator left_up limit inclusive" {
var s = try init(alloc, 2, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -8311,7 +10867,7 @@ test "PageList highlightSemanticContent prompt" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -8365,7 +10921,7 @@ test "PageList highlightSemanticContent prompt with output" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -8430,7 +10986,7 @@ test "PageList highlightSemanticContent prompt multiline" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt starts on row 5
{
@@ -8486,7 +11042,7 @@ test "PageList highlightSemanticContent prompt only" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5 with only prompt content (no input)
{
@@ -8530,7 +11086,7 @@ test "PageList highlightSemanticContent prompt to end of screen" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Single prompt on row 15, no following prompt
{
@@ -8578,7 +11134,7 @@ test "PageList highlightSemanticContent input basic" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -8633,7 +11189,7 @@ test "PageList highlightSemanticContent input with output" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -8698,7 +11254,7 @@ test "PageList highlightSemanticContent input multiline with continuation" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -8775,7 +11331,7 @@ test "PageList highlightSemanticContent input no input returns null" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5 with only prompt, then immediately output
{
@@ -8823,7 +11379,7 @@ test "PageList highlightSemanticContent input to end of screen" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Single prompt on row 15, no following prompt
{
@@ -8871,7 +11427,7 @@ test "PageList highlightSemanticContent input prompt only returns null" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5 with only prompt content, no input or output
{
@@ -8918,7 +11474,7 @@ test "PageList highlightSemanticContent output basic" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -8989,7 +11545,7 @@ test "PageList highlightSemanticContent output multiline" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -9080,7 +11636,7 @@ test "PageList highlightSemanticContent output stops at next prompt" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5
{
@@ -9165,7 +11721,7 @@ test "PageList highlightSemanticContent output to end of screen" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Single prompt on row 15, no following prompt
{
@@ -9237,7 +11793,7 @@ test "PageList highlightSemanticContent output no output returns null" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5 with only prompt and input, no output
{
@@ -9297,7 +11853,7 @@ test "PageList highlightSemanticContent output skips empty cells" {
var s = try init(alloc, 10, 20, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Prompt on row 5 - only fills first 3 cells, rest are empty with default .output
{
@@ -9376,17 +11932,17 @@ test "PageList erase" {
try testing.expectEqual(@as(usize, 1), s.totalPages());
// Grow so we take up at least 5 pages.
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
var cur_page = s.pages.last.?;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
for (0..page.capacity.rows * 5) |_| {
if (try s.grow()) |new_page| {
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
cur_page = new_page;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
}
}
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
try testing.expectEqual(@as(usize, 6), s.totalPages());
// Our total rows should be large
@@ -9410,17 +11966,17 @@ test "PageList erase reaccounts page size" {
const start_size = s.page_size;
// Grow so we take up at least 5 pages.
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
var cur_page = s.pages.last.?;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
for (0..page.capacity.rows * 5) |_| {
if (try s.grow()) |new_page| {
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
cur_page = new_page;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
}
}
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
try testing.expect(s.page_size > start_size);
// Erase the entire history, we should be back to just our active set.
@@ -9436,17 +11992,17 @@ test "PageList erase row with tracked pin resets to top-left" {
defer s.deinit();
// Grow so we take up at least 5 pages.
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
var cur_page = s.pages.last.?;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
for (0..page.capacity.rows * 5) |_| {
if (try s.grow()) |new_page| {
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
cur_page = new_page;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
}
}
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
// Our total rows should be large
try testing.expect(s.total_rows > s.rows);
@@ -9515,17 +12071,17 @@ test "PageList erase resets viewport to active if moves within active" {
defer s.deinit();
// Grow so we take up at least 5 pages.
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
var cur_page = s.pages.last.?;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
for (0..page.capacity.rows * 5) |_| {
if (try s.grow()) |new_page| {
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
cur_page = new_page;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
}
}
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
// Move our viewport to the top
s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) });
@@ -9544,17 +12100,17 @@ test "PageList erase resets viewport if inside erased page but not active" {
defer s.deinit();
// Grow so we take up at least 5 pages.
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
var cur_page = s.pages.last.?;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
for (0..page.capacity.rows * 5) |_| {
if (try s.grow()) |new_page| {
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
cur_page = new_page;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
}
}
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
// Move our viewport to the top
s.scroll(.{ .delta_row = -@as(isize, @intCast(s.total_rows)) });
@@ -9573,17 +12129,17 @@ test "PageList erase resets viewport to active if top is inside active" {
defer s.deinit();
// Grow so we take up at least 5 pages.
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
var cur_page = s.pages.last.?;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
for (0..page.capacity.rows * 5) |_| {
if (try s.grow()) |new_page| {
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
cur_page = new_page;
- cur_page.data.pauseIntegrityChecks(true);
+ cur_page.page().pauseIntegrityChecks(true);
}
}
- cur_page.data.pauseIntegrityChecks(false);
+ cur_page.page().pauseIntegrityChecks(false);
// Move our viewport to the top
s.scroll(.{ .top = {} });
@@ -9613,7 +12169,7 @@ test "PageList erase a one-row active" {
try testing.expectEqual(@as(usize, 1), s.totalPages());
// Write our letter
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
const rac = page.getRowAndCell(0, y);
rac.cell.* = .{
@@ -9736,13 +12292,13 @@ test "PageList eraseRowBounded full rows two pages" {
// Grow to two pages so our active area straddles
{
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
page.pauseIntegrityChecks(true);
for (0..page.capacity.rows - page.size.rows) |_| _ = try s.grow();
page.pauseIntegrityChecks(false);
try s.growRows(5);
try testing.expectEqual(@as(usize, 2), s.totalPages());
- try testing.expectEqual(@as(usize, 5), s.pages.last.?.data.size.rows);
+ try testing.expectEqual(@as(usize, 5), s.pages.last.?.rows());
}
// Pins
@@ -9757,11 +12313,11 @@ test "PageList eraseRowBounded full rows two pages" {
{
try testing.expectEqual(s.pages.last.?.prev.?, p_first.node);
- try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 1), p_first.y);
+ try testing.expectEqual(@as(usize, p_first.node.rows() - 1), p_first.y);
try testing.expectEqual(@as(usize, 0), p_first.x);
try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node);
- try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y);
+ try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y);
try testing.expectEqual(@as(usize, 0), p_first_out.x);
try testing.expectEqual(s.pages.last.?, p_in.node);
@@ -9784,12 +12340,12 @@ test "PageList eraseRowBounded full rows two pages" {
// In page in first page is shifted
try testing.expectEqual(s.pages.last.?.prev.?, p_first.node);
- try testing.expectEqual(@as(usize, p_first.node.data.size.rows - 2), p_first.y);
+ try testing.expectEqual(@as(usize, p_first.node.rows() - 2), p_first.y);
try testing.expectEqual(@as(usize, 0), p_first.x);
// Out page in first page should not be shifted
try testing.expectEqual(s.pages.last.?.prev.?, p_first_out.node);
- try testing.expectEqual(@as(usize, p_first_out.node.data.size.rows - 2), p_first_out.y);
+ try testing.expectEqual(@as(usize, p_first_out.node.rows() - 2), p_first_out.y);
try testing.expectEqual(@as(usize, 0), p_first_out.x);
// In page is shifted
@@ -9863,7 +12419,7 @@ test "PageList clone partial trimmed left reclaims styles" {
// Style the rows we're trimming
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const style: stylepkg.Style = .{ .flags = .{ .bold = true } };
const style_id = try page.styles.add(page.memory, style);
@@ -9895,7 +12451,7 @@ test "PageList clone partial trimmed left reclaims styles" {
{
try testing.expect(s2.pages.first == s2.pages.last);
- const page = &s2.pages.first.?.data;
+ const page = s2.pages.first.?.page();
try testing.expectEqual(0, page.styles.count());
}
}
@@ -10093,7 +12649,7 @@ test "PageList resize (no reflow) less rows" {
// This is required for our writing below to work
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write into all rows so we don't get trim behavior
for (0..s.rows) |y| {
@@ -10127,7 +12683,7 @@ test "PageList resize (no reflow) one rows" {
// This is required for our writing below to work
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write into all rows so we don't get trim behavior
for (0..s.rows) |y| {
@@ -10161,7 +12717,7 @@ test "PageList resize (no reflow) less rows cursor on bottom" {
// This is required for our writing below to work
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write into all rows so we don't get trim behavior
for (0..s.rows) |y| {
@@ -10213,7 +12769,7 @@ test "PageList resize (no reflow) less rows cursor in scrollback" {
// This is required for our writing below to work
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write into all rows so we don't get trim behavior
for (0..s.rows) |y| {
@@ -10264,7 +12820,7 @@ test "PageList resize (no reflow) less rows trims blank lines" {
var s = try init(alloc, 10, 5, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write codepoint into first line
{
@@ -10323,7 +12879,7 @@ test "PageList resize (no reflow) less rows trims blank lines cursor in blank li
var s = try init(alloc, 10, 5, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write codepoint into first line
{
@@ -10366,7 +12922,7 @@ test "PageList resize (no reflow) less rows trims blank lines erases pages" {
var s = try init(alloc, 100, 5, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Resize to take up two pages
{
@@ -10399,7 +12955,7 @@ test "PageList resize (no reflow) more rows extends blank lines" {
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write codepoint into first line
{
@@ -10479,7 +13035,7 @@ test "PageList resize (no reflow) less cols" {
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |offset| {
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, 5), cells.len);
}
}
@@ -10503,7 +13059,7 @@ test "PageList resize (no reflow) less cols pin in trimmed cols" {
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |offset| {
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, 5), cells.len);
}
@@ -10521,7 +13077,7 @@ test "PageList resize (no reflow) less cols clears graphemes" {
defer s.deinit();
// Add a grapheme.
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(9, 0);
rac.cell.* = .{
@@ -10539,7 +13095,7 @@ test "PageList resize (no reflow) less cols clears graphemes" {
var it = s.pageIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |chunk| {
- try testing.expectEqual(@as(usize, 0), chunk.node.data.graphemeCount());
+ try testing.expectEqual(@as(usize, 0), chunk.node.page().graphemeCount());
}
}
@@ -10558,7 +13114,7 @@ test "PageList resize (no reflow) more cols" {
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |offset| {
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, 10), cells.len);
}
}
@@ -10571,7 +13127,7 @@ test "PageList resize (no reflow) more cols with spacer head" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -10614,7 +13170,7 @@ test "PageList resize (no reflow) more cols with spacer head" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -10654,7 +13210,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" {
// Place a spacer_head at the last column (col 4) on two rows
// to simulate a wide character that didn't fit at the right edge.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Row 0: 'x' at col 0..3, spacer_head at col 4, wrap = true
{
@@ -10693,7 +13249,7 @@ test "PageList resize (no reflow) grow cols fast path with spacer head" {
// Verify the old spacer_head positions are now narrow.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(4, 0);
try testing.expectEqual(pagepkg.Cell.Wide.narrow, rac.cell.wide);
@@ -10749,14 +13305,14 @@ test "PageList resize (no reflow) more cols forces less rows per page" {
var it = s.pages.first;
while (it) |page| : (it = page.next) {
if (page == s.pages.last.?) break;
- try testing.expectEqual(page.data.capacity.rows, page.data.size.rows);
+ try testing.expectEqual(page.capacity().rows, page.rows());
}
}
// Now we need to resize again to a col size that further shrinks
// our last capacity.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
try testing.expect(page.size.rows == page.capacity.rows);
const new_cols = new_cols: {
var new_cols = page.size.cols + 50;
@@ -10779,7 +13335,7 @@ test "PageList resize (no reflow) more cols forces less rows per page" {
var it = s.pages.first;
while (it) |page| : (it = page.next) {
if (page == s.pages.last.?) break;
- try testing.expectEqual(page.data.capacity.rows, page.data.size.rows);
+ try testing.expectEqual(page.capacity().rows, page.rows());
}
}
}
@@ -10803,7 +13359,7 @@ test "PageList resize (no reflow) less cols then more cols" {
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |offset| {
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, 5), cells.len);
}
}
@@ -10823,7 +13379,7 @@ test "PageList resize (no reflow) less rows and cols" {
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |offset| {
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, 5), cells.len);
}
}
@@ -10841,8 +13397,8 @@ test "PageList resize less rows and cols cursor at bottom" {
} }).?);
defer s.untrackPin(cursor_pin);
- // Shrink both axes so the original cursor.y is strictly past the new row
- // count, after resizeWithoutReflow has already reduced self.rows.
+ // Shrink both axes such that the original cursor.y is strictly past the
+ // new row count, so resizeWithoutReflow leaves self.rows < c.y + 1.
try s.resize(.{
.cols = 79,
.rows = 20,
@@ -10859,6 +13415,58 @@ test "PageList resize less rows and cols cursor at bottom" {
} }, s.pointFromPin(.active, cursor_pin.*).?);
}
+test "PageList resize less rows and cols cursor near top pushed to scrollback" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Fill every active row with non-blank content so that shrinking rows
+ // can't trim trailing blank lines and instead pushes the top rows into
+ // scrollback.
+ {
+ var it = s.rowIterator(.right_down, .{ .active = .{} }, null);
+ while (it.next()) |p| {
+ const rac = p.rowAndCell();
+ const cells = p.node.page().getCells(rac.row);
+ for (cells, 0..) |*cell, x| cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = @intCast('A' + (x % 26)) },
+ };
+ }
+ }
+
+ // Cursor near the top of the active area. After we shrink rows the active
+ // area top moves down past this pin, so it ends up in scrollback.
+ const cursor_pin = try s.trackPin(s.pin(.{ .active = .{
+ .x = 0,
+ .y = 0,
+ } }).?);
+ defer s.untrackPin(cursor_pin);
+
+ // Shrink both axes with reflow. resizeWithoutReflow shrinks self.rows
+ // first, leaving the cursor pin above the new active area, then resizeCols
+ // walks .left_up from the cursor pin toward the active-area top.
+ try s.resize(.{
+ .cols = 79,
+ .rows = 20,
+ .reflow = true,
+ .cursor = .{ .x = 0, .y = 0, .pin = cursor_pin },
+ });
+ try testing.expectEqual(@as(usize, 79), s.cols);
+ try testing.expectEqual(@as(usize, 20), s.rows);
+
+ // The active area is anchored to the bottom, so shrinking rows pushed the
+ // top-of-screen cursor into scrollback: it no longer resolves to an
+ // active-area coordinate, but it remains a valid screen pin.
+ try testing.expect(s.pointFromPin(.active, cursor_pin.*) == null);
+ try testing.expect(s.pointFromPin(.screen, cursor_pin.*) != null);
+
+ // Integrity must hold after the resize.
+ s.assertIntegrity();
+}
+
test "PageList resize (no reflow) more rows and less cols" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -10875,7 +13483,7 @@ test "PageList resize (no reflow) more rows and less cols" {
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |offset| {
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, 5), cells.len);
}
}
@@ -10915,7 +13523,7 @@ test "PageList resize (no reflow) empty screen" {
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |offset| {
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, 10), cells.len);
}
}
@@ -10934,7 +13542,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" {
var s = try init(alloc, cap.cols, cap.rows, null);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -10954,7 +13562,7 @@ test "PageList resize (no reflow) more cols forces smaller cap" {
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |offset| {
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, cap2.cols), cells.len);
try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint);
}
@@ -10970,7 +13578,7 @@ test "PageList resize (no reflow) more rows adds blank rows if cursor at bottom"
// Grow to 5 total rows, simulating 3 active + 2 scrollback
try s.growRows(2);
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.totalRows()) |y| {
const rac = page.getRowAndCell(0, y);
rac.cell.* = .{
@@ -11040,7 +13648,7 @@ test "PageList resize reflow more cols no wrapped rows" {
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -11059,7 +13667,7 @@ test "PageList resize reflow more cols no wrapped rows" {
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |offset| {
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, 10), cells.len);
try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint);
}
@@ -11072,7 +13680,7 @@ test "PageList resize reflow more cols wrapped rows" {
var s = try init(alloc, 2, 4, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
if (y % 2 == 0) {
const rac = page.getRowAndCell(0, y);
@@ -11110,7 +13718,7 @@ test "PageList resize reflow more cols wrapped rows" {
// First row should be unwrapped
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(!rac.row.wrap);
try testing.expectEqual(@as(usize, 4), cells.len);
try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint);
@@ -11126,7 +13734,7 @@ test "PageList resize reflow invalidates viewport offset cache" {
defer s.deinit();
try s.growRows(20);
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
for (0..s.rows) |y| {
if (y % 2 == 0) {
const rac = page.getRowAndCell(0, y);
@@ -11188,7 +13796,7 @@ test "PageList resize reflow more cols creates multiple pages" {
// Wrap every other row so every line is wrapped for reflow
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
if (y % 2 == 0) {
const rac = page.getRowAndCell(0, y);
@@ -11220,8 +13828,8 @@ test "PageList resize reflow more cols creates multiple pages" {
count += 1;
// All pages should have the new capacity
- try testing.expectEqual(newcap.cols, page.data.capacity.cols);
- try testing.expectEqual(newcap.rows, page.data.capacity.rows);
+ try testing.expectEqual(newcap.cols, page.capacity().cols);
+ try testing.expectEqual(newcap.rows, page.capacity().rows);
}
// We should have more than one page, meaning we created at least
@@ -11241,7 +13849,7 @@ test "PageList resize reflow more cols wrap across page boundary" {
// Grow to the capacity of the first page.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
page.pauseIntegrityChecks(true);
for (page.size.rows..page.capacity.rows) |_| {
_ = try s.grow();
@@ -11255,7 +13863,7 @@ test "PageList resize reflow more cols wrap across page boundary" {
// At this point, we have some rows on the first page, and some on the second.
// We can now wrap across the boundary condition.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const y = page.size.rows - 1;
{
const rac = page.getRowAndCell(0, y);
@@ -11270,7 +13878,7 @@ test "PageList resize reflow more cols wrap across page boundary" {
}
}
{
- const page2 = &s.pages.last.?.data;
+ const page2 = s.pages.last.?.page();
const y = 0;
{
const rac = page2.getRowAndCell(0, y);
@@ -11372,7 +13980,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon
// Grow to the capacity of the first page.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
page.pauseIntegrityChecks(true);
for (page.size.rows..page.capacity.rows) |_| {
_ = try s.grow();
@@ -11386,7 +13994,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon
// At this point, we have some rows on the first page, and some on the second.
// We can now wrap across the boundary condition.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const y = page.size.rows - 1;
{
const rac = page.getRowAndCell(0, y);
@@ -11401,7 +14009,7 @@ test "PageList resize reflow more cols wrap across page boundary cursor in secon
}
}
{
- const page2 = &s.pages.last.?.data;
+ const page2 = s.pages.last.?.page();
const y = 0;
{
const rac = page2.getRowAndCell(0, y);
@@ -11458,7 +14066,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon
// Grow to the capacity of the first page.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
page.pauseIntegrityChecks(true);
for (page.size.rows..page.capacity.rows) |_| {
_ = try s.grow();
@@ -11472,7 +14080,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon
// At this point, we have some rows on the first page, and some on the second.
// We can now wrap across the boundary condition.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const y = page.size.rows - 1;
{
const rac = page.getRowAndCell(0, y);
@@ -11487,7 +14095,7 @@ test "PageList resize reflow less cols wrap across page boundary cursor in secon
}
}
{
- const page2 = &s.pages.last.?.data;
+ const page2 = s.pages.last.?.page();
const y = 0;
{
const rac = page2.getRowAndCell(0, y);
@@ -11634,7 +14242,7 @@ test "PageList resize reflow more cols cursor in wrapped row" {
var s = try init(alloc, 2, 4, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
{
const rac = page.getRowAndCell(0, 0);
@@ -11685,7 +14293,7 @@ test "PageList resize reflow more cols cursor in not wrapped row" {
var s = try init(alloc, 2, 4, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
{
const rac = page.getRowAndCell(0, 0);
@@ -11736,7 +14344,7 @@ test "PageList resize reflow more cols cursor in wrapped row that isn't unwrappe
var s = try init(alloc, 2, 4, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
{
const rac = page.getRowAndCell(0, 0);
@@ -11802,7 +14410,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const rac = page.getRowAndCell(0, 1);
rac.row.semantic_prompt = .prompt;
}
@@ -11814,7 +14422,7 @@ test "PageList resize reflow more cols no reflow preserves semantic prompt" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const rac = page.getRowAndCell(0, 1);
try testing.expect(rac.row.semantic_prompt == .prompt);
}
@@ -11831,7 +14439,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase"
// Grow to the capacity of the first page and add
// one more row so that we have two pages total.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
page.pauseIntegrityChecks(true);
for (page.size.rows..page.capacity.rows) |_| {
_ = try s.grow();
@@ -11865,7 +14473,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase"
// Almost hit string alloc cap in bottom right of first page.
// Mark the final row as wrapped.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const id = try page.insertHyperlink(.{
.id = .{ .implicit = 0 },
.uri = "a" ** (pagepkg.string_bytes_default - 1),
@@ -11889,7 +14497,7 @@ test "PageList resize reflow exceeds hyperlink memory forcing capacity increase"
// Almost hit string alloc cap in top left of second page.
// Mark the first row as a wrap continuation.
{
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
const id = try page.insertHyperlink(.{
.id = .{ .implicit = 1 },
.uri = "a" ** (pagepkg.string_bytes_default - 1),
@@ -11925,7 +14533,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase"
// Grow to the capacity of the first page and add
// one more row so that we have two pages total.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
page.pauseIntegrityChecks(true);
for (page.size.rows..page.capacity.rows) |_| {
_ = try s.grow();
@@ -11959,7 +14567,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase"
// Almost hit grapheme alloc cap in bottom right of first page.
// Mark the final row as wrapped.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const rac = page.getRowAndCell(page.size.cols - 1, page.size.rows - 1);
rac.row.wrap = true;
rac.cell.* = .{
@@ -11992,7 +14600,7 @@ test "PageList resize reflow exceeds grapheme memory forcing capacity increase"
// Almost hit grapheme alloc cap in top left of second page.
// Mark the first row as a wrap continuation.
{
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
const rac = page.getRowAndCell(0, 0);
rac.row.wrap = true;
rac.cell.* = .{
@@ -12037,7 +14645,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" {
// Grow to the capacity of the first page and add
// one more row so that we have two pages total.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
page.pauseIntegrityChecks(true);
for (page.size.rows..page.capacity.rows) |_| {
_ = try s.grow();
@@ -12055,7 +14663,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" {
// Give each cell in the final row of the first page a unique style.
// Mark the final row as wrapped.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.cols) |x| {
const id = page.styles.add(
page.memory,
@@ -12082,7 +14690,7 @@ test "PageList resize reflow exceeds style memory forcing capacity increase" {
// Do the same for the first row of the second page.
// Mark the first row as a wrap continuation.
{
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
for (0..s.cols) |x| {
const id = page.styles.add(
page.memory,
@@ -12118,7 +14726,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -12162,7 +14770,7 @@ test "PageList resize reflow more cols unwrap wide spacer head" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -12191,7 +14799,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows"
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -12251,7 +14859,7 @@ test "PageList resize reflow more cols unwrap wide spacer head across two rows"
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -12295,7 +14903,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -12338,7 +14946,7 @@ test "PageList resize reflow more cols unwrap still requires wide spacer head" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -12376,7 +14984,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 1);
rac.row.semantic_prompt = .prompt;
@@ -12419,7 +15027,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const rac = page.getRowAndCell(0, 0);
rac.row.semantic_prompt = .prompt;
}
@@ -12431,7 +15039,7 @@ test "PageList resize reflow less cols no reflow preserves semantic prompt on fi
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const rac = page.getRowAndCell(0, 0);
try testing.expect(rac.row.semantic_prompt == .prompt);
}
@@ -12445,7 +15053,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const rac = page.getRowAndCell(0, 0);
rac.row.semantic_prompt = .prompt;
}
@@ -12457,7 +15065,7 @@ test "PageList resize reflow less cols wrap preserves semantic prompt" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const rac = page.getRowAndCell(0, 0);
try testing.expect(rac.row.semantic_prompt == .prompt);
}
@@ -12470,7 +15078,7 @@ test "PageList resize reflow less cols no wrapped rows" {
var s = try init(alloc, 10, 3, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
const end = 4;
assert(end < s.cols);
@@ -12494,7 +15102,7 @@ test "PageList resize reflow less cols no wrapped rows" {
var offset_copy = offset;
offset_copy.x = @intCast(x);
const rac = offset_copy.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(usize, 5), cells.len);
try testing.expectEqual(@as(u21, @intCast(x)), cells[x].content.codepoint);
}
@@ -12508,7 +15116,7 @@ test "PageList resize reflow less cols wrapped rows" {
var s = try init(alloc, 4, 2, null);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -12538,7 +15146,7 @@ test "PageList resize reflow less cols wrapped rows" {
// First row should be wrapped
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
@@ -12546,7 +15154,7 @@ test "PageList resize reflow less cols wrapped rows" {
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(!rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint);
@@ -12555,7 +15163,7 @@ test "PageList resize reflow less cols wrapped rows" {
// First row should be wrapped
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
@@ -12563,7 +15171,7 @@ test "PageList resize reflow less cols wrapped rows" {
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(!rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint);
@@ -12578,7 +15186,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -12608,13 +15216,13 @@ test "PageList resize reflow less cols wrapped rows with graphemes" {
}
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
var it = s.rowIterator(.right_down, .{ .screen = .{} }, null);
{
// First row should be wrapped
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
@@ -12622,7 +15230,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" {
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(!rac.row.wrap);
try testing.expect(rac.row.grapheme);
try testing.expectEqual(@as(usize, 2), cells.len);
@@ -12636,7 +15244,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" {
// First row should be wrapped
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
@@ -12644,7 +15252,7 @@ test "PageList resize reflow less cols wrapped rows with graphemes" {
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(!rac.row.wrap);
try testing.expect(rac.row.grapheme);
try testing.expectEqual(@as(usize, 2), cells.len);
@@ -12663,7 +15271,7 @@ test "PageList resize reflow less cols cursor in wrapped row" {
var s = try init(alloc, 4, 2, null);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -12698,7 +15306,7 @@ test "PageList resize reflow less cols wraps spacer head" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -12756,7 +15364,7 @@ test "PageList resize reflow less cols wraps spacer head" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -12793,7 +15401,7 @@ test "PageList resize reflow less cols cursor goes to scrollback" {
var s = try init(alloc, 4, 2, null);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -12824,7 +15432,7 @@ test "PageList resize reflow less cols cursor in unchanged row" {
var s = try init(alloc, 4, 2, null);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..2) |x| {
const rac = page.getRowAndCell(x, y);
@@ -12858,7 +15466,7 @@ test "PageList resize reflow less cols cursor in blank cell" {
var s = try init(alloc, 6, 2, null);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..2) |x| {
const rac = page.getRowAndCell(x, y);
@@ -12892,7 +15500,7 @@ test "PageList resize reflow less cols cursor in final blank cell" {
var s = try init(alloc, 6, 2, null);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..2) |x| {
const rac = page.getRowAndCell(x, y);
@@ -12926,7 +15534,7 @@ test "PageList resize reflow less cols cursor in wrapped blank cell" {
var s = try init(alloc, 6, 2, null);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..2) |x| {
const rac = page.getRowAndCell(x, y);
@@ -12960,7 +15568,7 @@ test "PageList resize reflow less cols blank lines" {
var s = try init(alloc, 4, 3, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..1) |y| {
for (0..4) |x| {
const rac = page.getRowAndCell(x, y);
@@ -12981,7 +15589,7 @@ test "PageList resize reflow less cols blank lines" {
// First row should be wrapped
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
@@ -12989,7 +15597,7 @@ test "PageList resize reflow less cols blank lines" {
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(!rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint);
@@ -13003,7 +15611,7 @@ test "PageList resize reflow less cols blank lines between" {
var s = try init(alloc, 4, 3, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
for (0..4) |x| {
const rac = page.getRowAndCell(x, 0);
@@ -13037,7 +15645,7 @@ test "PageList resize reflow less cols blank lines between" {
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
@@ -13045,7 +15653,7 @@ test "PageList resize reflow less cols blank lines between" {
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(!rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 2), cells[0].content.codepoint);
@@ -13059,7 +15667,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" {
var s = try init(alloc, 5, 3, 0);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
rac.cell.* = .{
@@ -13084,7 +15692,7 @@ test "PageList resize reflow less cols blank lines between no scrollback" {
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(!rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 'A'), cells[0].content.codepoint);
@@ -13092,13 +15700,13 @@ test "PageList resize reflow less cols blank lines between no scrollback" {
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expectEqual(@as(u21, 0), cells[0].content.codepoint);
}
{
const offset = it.next().?;
const rac = offset.rowAndCell();
- const cells = offset.node.data.getCells(rac.row);
+ const cells = offset.node.page().getCells(rac.row);
try testing.expect(!rac.row.wrap);
try testing.expectEqual(@as(usize, 2), cells.len);
try testing.expectEqual(@as(u21, 'C'), cells[0].content.codepoint);
@@ -13112,7 +15720,7 @@ test "PageList resize reflow less cols cursor not on last line preserves locatio
var s = try init(alloc, 5, 5, 1);
defer s.deinit();
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
for (0..s.rows) |y| {
for (0..2) |x| {
const rac = page.getRowAndCell(x, y);
@@ -13157,7 +15765,7 @@ test "PageList resize reflow less cols copy style" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Create a style
const style: stylepkg.Style = .{ .flags = .{ .bold = true } };
@@ -13191,8 +15799,8 @@ test "PageList resize reflow less cols copy style" {
const style_id = rac.cell.style_id;
try testing.expect(style_id != 0);
- const style = offset.node.data.styles.get(
- offset.node.data.memory,
+ const style = offset.node.page().styles.get(
+ offset.node.page().memory,
style_id,
);
try testing.expect(style.flags.bold);
@@ -13211,7 +15819,7 @@ test "PageList resize reflow less cols to eliminate a wide char" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -13238,7 +15846,7 @@ test "PageList resize reflow less cols to eliminate a wide char" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -13256,7 +15864,7 @@ test "PageList resize reflow less cols to wrap a wide char" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -13290,7 +15898,7 @@ test "PageList resize reflow less cols to wrap a wide char" {
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -13324,7 +15932,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// We want to make the screen look like this:
//
@@ -13383,7 +15991,7 @@ test "PageList resize reflow less cols to wrap a multi-codepoint grapheme with a
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
{
const rac = page.getRowAndCell(0, 0);
@@ -13445,7 +16053,7 @@ test "PageList resize reflow less cols copy kitty placeholder" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write unicode placeholders
for (0..s.cols - 1) |x| {
@@ -13486,7 +16094,7 @@ test "PageList resize reflow more cols clears kitty placeholder" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write unicode placeholders
for (0..s.cols - 1) |x| {
@@ -13529,7 +16137,7 @@ test "PageList resize reflow wrap moves kitty placeholder" {
defer s.deinit();
{
try testing.expect(s.pages.first == s.pages.last);
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write unicode placeholders
for (2..s.cols - 1) |x| {
@@ -13586,20 +16194,44 @@ test "PageList reset invalidates stale untracked refs even if node memory is reu
var s = try init(alloc, 80, 24, null);
defer s.deinit();
- const old_serial = s.pages.first.?.serial;
- try testing.expect(old_serial >= s.page_serial_min);
- try testing.expect(old_serial < s.page_serial);
-
- s.reset();
+ var stale_nodes: [page_preheat * 4]*List.Node = undefined;
+ var stale_serials: [stale_nodes.len]u64 = undefined;
+ var stale_len: usize = 0;
+ var reused: ?struct { *List.Node, u64 } = null;
+
+ while (stale_len < stale_nodes.len and reused == null) {
+ const old_node = s.pages.first.?;
+ const old_serial = old_node.serial;
+ try testing.expect(old_serial >= s.page_serial_epoch);
+ try testing.expect(old_serial < s.page_serial);
+ stale_nodes[stale_len] = old_node;
+ stale_serials[stale_len] = old_serial;
+ stale_len += 1;
+
+ s.reset();
+
+ const new_node = s.pages.first.?;
+ for (stale_nodes[0..stale_len], stale_serials[0..stale_len]) |node, serial| {
+ if (node == new_node) {
+ reused = .{ node, serial };
+ break;
+ }
+ }
+ }
- // The important safety property is that stale serials are rejected before
- // the node pointer is inspected. Reset rebuilds the page list from the
- // pools, so old untracked refs may contain node pointers that are no
- // longer safe to dereference.
- try testing.expect(old_serial < s.page_serial_min);
+ try testing.expect(reused != null);
+ const old_node, const old_serial = reused.?;
+ const new_node = s.pages.first.?;
+ const new_serial = new_node.serial;
- const new_serial = s.pages.first.?.serial;
- try testing.expect(new_serial >= s.page_serial_min);
+ // Reset advances the epoch before rebuilding from the node pool. Reject
+ // the stale generation before inspecting its pointer, even when that exact
+ // address now belongs to a new live generation.
+ try testing.expectEqual(old_node, new_node);
+ try testing.expect(old_serial < s.page_serial_epoch);
+ try testing.expect(!s.nodeIsValid(old_node, old_serial));
+ try testing.expect(s.nodeIsValid(new_node, new_serial));
+ try testing.expect(new_serial >= s.page_serial_epoch);
try testing.expect(new_serial < s.page_serial);
}
@@ -13685,7 +16317,7 @@ test "PageList resize reflow grapheme map capacity exceeded" {
// during reflow. Since each source page can only hold this many graphemes,
// we create two source pages with graphemes that will merge into one
// destination page.
- const grapheme_capacity = s.pages.first.?.data.graphemeCapacity();
+ const grapheme_capacity = s.pages.first.?.page().graphemeCapacity();
// Use slightly more than half the capacity per page, so combined they
// exceed the capacity of a single destination page.
const graphemes_per_page = grapheme_capacity / 2 + grapheme_capacity / 4;
@@ -13693,7 +16325,7 @@ test "PageList resize reflow grapheme map capacity exceeded" {
// Grow to the capacity of the first page and add more rows
// so that we have two pages total.
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
page.pauseIntegrityChecks(true);
for (page.size.rows..page.capacity.rows) |_| {
_ = try s.grow();
@@ -13715,7 +16347,7 @@ test "PageList resize reflow grapheme map capacity exceeded" {
// Add graphemes to the end of the first page (last rows)
{
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
const start_row = page.size.rows - graphemes_per_page;
for (0..graphemes_per_page) |i| {
const y = start_row + i;
@@ -13730,7 +16362,7 @@ test "PageList resize reflow grapheme map capacity exceeded" {
// Add graphemes to the beginning of the second page
{
- const page = &s.pages.last.?.data;
+ const page = s.pages.last.?.page();
const count = @min(graphemes_per_page, page.size.rows);
for (0..count) |y| {
const rac = page.getRowAndCell(0, y);
@@ -13773,7 +16405,7 @@ test "PageList resize grow cols with unwrap fixes viewport pin" {
// Fill all rows with wrapped content (pairs that unwrap when cols increase)
var it = s.pageIterator(.right_down, .{ .screen = .{} }, null);
while (it.next()) |chunk| {
- const page = &chunk.node.data;
+ const page = chunk.node.page();
for (chunk.start..chunk.end) |y| {
const rac = page.getRowAndCell(0, y);
if (y % 2 == 0) {
@@ -13817,16 +16449,16 @@ test "PageList grow reuses non-standard page without leak" {
defer s.deinit();
// Increase the first page capacity to make it non-standard (larger than std_size).
- while (s.pages.first.?.data.memory.len <= std_size) {
+ while (s.pages.first.?.page().memory.len <= std_size) {
_ = try s.increaseCapacity(s.pages.first.?, .grapheme_bytes);
}
// The first page should now have non-standard memory size.
- try testing.expect(s.pages.first.?.data.memory.len > std_size);
+ try testing.expect(s.pages.first.?.page().memory.len > std_size);
// First, fill up the first page's capacity
const first_page = s.pages.first.?;
- while (first_page.data.size.rows < first_page.data.capacity.rows) {
+ while (first_page.rows() < first_page.capacity().rows) {
_ = try s.grow();
}
@@ -13836,23 +16468,23 @@ test "PageList grow reuses non-standard page without leak" {
// Continue growing until we exceed max_size AND the last page is full
while (s.page_size + PagePool.item_size <= s.maxSize() or
- s.pages.last.?.data.size.rows < s.pages.last.?.data.capacity.rows)
+ s.pages.last.?.rows() < s.pages.last.?.capacity().rows)
{
_ = try s.grow();
}
// The first page should still be non-standard
- try testing.expect(s.pages.first.?.data.memory.len > std_size);
+ try testing.expect(s.pages.first.?.page().memory.len > std_size);
// Verify we have enough rows for active area (so prune path isn't skipped)
try testing.expect(s.totalRows() >= s.rows);
// Verify last page is full (so grow will need to allocate/reuse)
- try testing.expect(s.pages.last.?.data.size.rows == s.pages.last.?.data.capacity.rows);
+ try testing.expect(s.pages.last.?.page().size.rows == s.pages.last.?.capacity().rows);
// Remember the first page memory pointer before the reuse attempt
const first_page_ptr = s.pages.first.?;
- const first_page_mem_ptr = s.pages.first.?.data.memory.ptr;
+ const first_page_mem_ptr = s.pages.first.?.page().memory.ptr;
// Create a tracked pin pointing to the non-standard first page
const tracked_pin = try s.trackPin(.{ .node = first_page_ptr, .x = 0, .y = 0 });
@@ -13871,7 +16503,7 @@ test "PageList grow reuses non-standard page without leak" {
// If the non-standard page was properly destroyed and not reused,
// the last page should not have the same memory pointer
- try testing.expect(s.pages.last.?.data.memory.ptr != first_page_mem_ptr);
+ try testing.expect(s.pages.last.?.page().memory.ptr != first_page_mem_ptr);
// The tracked pin should have been moved to the new first page and marked as garbage
try testing.expectEqual(s.pages.first.?, tracked_pin.node);
@@ -13900,19 +16532,19 @@ test "PageList grow non-standard page prune protection" {
defer s.deinit();
// Make the first page non-standard
- while (s.pages.first.?.data.memory.len <= std_size) {
+ while (s.pages.first.?.page().memory.len <= std_size) {
_ = try s.increaseCapacity(
s.pages.first.?,
.grapheme_bytes,
);
}
- try testing.expect(s.pages.first.?.data.memory.len > std_size);
+ try testing.expect(s.pages.first.?.page().memory.len > std_size);
const first_page_node = s.pages.first.?;
- const first_page_cap = first_page_node.data.capacity.rows;
+ const first_page_cap = first_page_node.capacity().rows;
// Fill first page to capacity
- while (first_page_node.data.size.rows < first_page_cap) _ = try s.grow();
+ while (first_page_node.rows() < first_page_cap) _ = try s.grow();
// Grow until we have a second page (first page fills up first)
var second_node: ?*List.Node = null;
@@ -13921,8 +16553,8 @@ test "PageList grow non-standard page prune protection" {
// Fill the second page to capacity so that the next grow() triggers prune
const last_node = s.pages.last.?;
- const second_cap = last_node.data.capacity.rows;
- while (last_node.data.size.rows < second_cap) _ = try s.grow();
+ const second_cap = last_node.capacity().rows;
+ while (last_node.rows() < second_cap) _ = try s.grow();
// Now the last page is full. The next grow must either:
// 1. Prune the first page and reuse it, OR
@@ -13939,7 +16571,7 @@ test "PageList grow non-standard page prune protection" {
try testing.expect(s.totalRows() >= s.rows);
// Verify last page is at capacity (so grow must prune or allocate new)
- try testing.expectEqual(second_cap, last_node.data.size.rows);
+ try testing.expectEqual(second_cap, last_node.rows());
// The next grow should trigger prune consideration.
// Without the fix, this would destroy the non-standard first page,
@@ -13973,12 +16605,12 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" {
// Trim a history row so the first page has spare capacity.
// This triggers the backfill path in resizeWithoutReflowGrowCols.
s.eraseHistory(.{ .history = .{ .y = 0 } });
- try testing.expect(first_page.data.size.rows < first_page.data.capacity.rows);
+ try testing.expect(first_page.rows() < first_page.capacity().rows);
// Ensure the resize takes the slow path (new capacity > current capacity).
const new_cols: size.CellCountInt = cols + 1;
- const adjusted = try second_page.data.capacity.adjust(.{ .cols = new_cols });
- try testing.expect(second_page.data.capacity.cols < adjusted.cols);
+ const adjusted = try second_page.capacity().adjust(.{ .cols = new_cols });
+ try testing.expect(second_page.capacity().cols < adjusted.cols);
// Track a pin in row 0 of the second page. This row will be copied
// to the first page during backfill and the pin must be remapped.
@@ -14005,7 +16637,7 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" {
}
}
try testing.expect(found);
- try testing.expect(tracked.y < tracked.node.data.size.rows);
+ try testing.expect(tracked.y < tracked.node.rows());
// Verify the pin still points to the cell with our marker content.
const cell = tracked.rowAndCell().cell;
@@ -14013,23 +16645,108 @@ test "PageList resize (no reflow) more cols remaps pins in backfill path" {
try testing.expectEqual(marker, cell.content.codepoint);
}
-test "PageList compact std_size page returns null" {
+test "PageList compact pool page produces exact-size heap page" {
const testing = std.testing;
const alloc = testing.allocator;
var s = try init(alloc, 80, 24, 0);
defer s.deinit();
- // A freshly created page should be at std_size
+ // A freshly created page is pool-owned at std_size.
const node = s.pages.first.?;
- try testing.expect(node.data.memory.len <= std_size);
+ try testing.expectEqual(.pool, node.owned);
+ try testing.expect(node.page().memory.len <= std_size);
+ const original_size = node.page().size;
+
+ // Compacting it should produce a much smaller exact-size heap page.
+ const new_node = (try s.compact(node)).?;
+ try testing.expectEqual(.heap, new_node.owned);
+ try testing.expect(new_node.page().memory.len < std_size);
+ try testing.expectEqual(original_size.rows, new_node.rows());
+ try testing.expectEqual(original_size.cols, new_node.cols());
+ try testing.expectEqual(new_node, s.pages.first.?);
+
+ // Our page size accounting should exactly match the compacted
+ // page since it is the only page in the list.
+ try testing.expectEqual(new_node.page().memory.len, s.page_size);
+
+ // Compacting again should be a no-op since it is already exact.
+ try testing.expectEqual(null, try s.compact(new_node));
+}
+
+test "PageList compact then grow allocates new page" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
- // compact should return null since there's nothing to compact
- const result = try s.compact(node);
- try testing.expectEqual(null, result);
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Compact the only page. It now has no spare row capacity.
+ const node = (try s.compact(s.pages.first.?)).?;
+ try testing.expectEqual(node.rows(), node.capacity().rows);
+
+ // Growing must allocate a fresh standard page from the pool,
+ // exercising that a compacted page remains a valid live page.
+ _ = try s.grow();
+ try testing.expect(s.pages.first != s.pages.last);
+ try testing.expectEqual(.pool, s.pages.last.?.owned);
+ try testing.expectEqual(@as(usize, 25), s.totalRows());
+}
+
+test "PageList compact then reset frees heap pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, 0);
+ defer s.deinit();
+
+ // Compact the only page so the list contains a sub-std_size
+ // heap-owned page.
+ const node = (try s.compact(s.pages.first.?)).?;
+ try testing.expectEqual(.heap, node.owned);
+ try testing.expect(node.page().memory.len < std_size);
+
+ // Reset must free the heap page (testing allocator catches leaks
+ // and invalid frees) and rebuild from the pool.
+ s.reset();
+ try testing.expectEqual(.pool, s.pages.first.?.owned);
+ try testing.expectEqual(@as(usize, s.rows), s.totalRows());
+}
+
+test "PageList compact then clone" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Write a marker so we can verify contents survive.
+ {
+ const node = s.pages.first.?;
+ const rac = node.page().getRowAndCell(1, 2);
+ rac.cell.* = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 'X' },
+ };
+ }
- // Page should still be the same
- try testing.expectEqual(node, s.pages.first.?);
+ // Compact so the source list contains a sub-std_size heap page.
+ const node = (try s.compact(s.pages.first.?)).?;
+ try testing.expectEqual(.heap, node.owned);
+ try testing.expect(node.page().memory.len < std_size);
+
+ var s2 = try s.clone(alloc, .{
+ .top = .{ .screen = .{} },
+ });
+ defer s2.deinit();
+ try testing.expectEqual(@as(usize, s.rows), s2.totalRows());
+
+ // Verify the marker survived the clone.
+ {
+ const node2 = s2.pages.first.?;
+ const rac = node2.page().getRowAndCell(1, 2);
+ try testing.expectEqual(@as(u21, 'X'), rac.cell.content.codepoint);
+ }
}
test "PageList compact oversized page" {
@@ -14041,11 +16758,11 @@ test "PageList compact oversized page" {
// Grow until we have multiple pages
const page1_node = s.pages.first.?;
- page1_node.data.pauseIntegrityChecks(true);
- for (0..page1_node.data.capacity.rows - page1_node.data.size.rows) |_| {
+ page1_node.page().pauseIntegrityChecks(true);
+ for (0..page1_node.capacity().rows - page1_node.rows()) |_| {
_ = try s.grow();
}
- page1_node.data.pauseIntegrityChecks(false);
+ page1_node.page().pauseIntegrityChecks(false);
_ = try s.grow();
try testing.expect(s.pages.first != s.pages.last);
@@ -14053,7 +16770,7 @@ test "PageList compact oversized page" {
// Write content to verify it's preserved
{
- const page = &node.data;
+ const page = node.page();
for (0..page.size.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -14070,30 +16787,30 @@ test "PageList compact oversized page" {
defer s.untrackPin(tracked);
// Make the page oversized
- while (node.data.memory.len <= std_size) {
+ while (node.page().memory.len <= std_size) {
node = try s.increaseCapacity(node, .grapheme_bytes);
}
- try testing.expect(node.data.memory.len > std_size);
- const oversized_len = node.data.memory.len;
- const original_size = node.data.size;
+ try testing.expect(node.page().memory.len > std_size);
+ const oversized_len = node.page().memory.len;
+ const original_size = node.page().size;
const second_node = node.next.?;
// Set dirty flag after increaseCapacity
- node.data.dirty = true;
+ node.page().dirty = true;
// Compact the page
const new_node = try s.compact(node);
try testing.expect(new_node != null);
// Verify memory is smaller
- try testing.expect(new_node.?.data.memory.len < oversized_len);
+ try testing.expect(new_node.?.page().memory.len < oversized_len);
// Verify size preserved
- try testing.expectEqual(original_size.rows, new_node.?.data.size.rows);
- try testing.expectEqual(original_size.cols, new_node.?.data.size.cols);
+ try testing.expectEqual(original_size.rows, new_node.?.rows());
+ try testing.expectEqual(original_size.cols, new_node.?.cols());
// Verify dirty flag preserved
- try testing.expect(new_node.?.data.dirty);
+ try testing.expect(new_node.?.page().dirty);
// Verify linked list integrity
try testing.expectEqual(new_node.?, s.pages.first.?);
@@ -14107,7 +16824,7 @@ test "PageList compact oversized page" {
try testing.expectEqual(@as(size.CellCountInt, 10), tracked.y);
// Verify content preserved
- const page = &new_node.?.data;
+ const page = new_node.?.page();
for (0..page.size.rows) |y| {
for (0..s.cols) |x| {
const rac = page.getRowAndCell(x, y);
@@ -14119,7 +16836,73 @@ test "PageList compact oversized page" {
}
}
-test "PageList compact insufficient savings returns null" {
+test "PageList destroyed pool page reuse is zeroed" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, null);
+ defer s.deinit();
+
+ // Create a page and scribble over its entire backing memory,
+ // then destroy it so the buffer returns to the pool free list.
+ const node = try s.createPage(.{ .cap = initialCapacity(80) });
+ node.page().size.rows = 1;
+ const mem_ptr = node.page().memory.ptr;
+ @memset(node.page().memory, 0xAA);
+ s.destroyNode(node);
+
+ // Reusing the buffer must produce a fully valid, zeroed page.
+ const node2 = try s.createPage(.{ .cap = initialCapacity(80) });
+ try testing.expectEqual(mem_ptr, node2.page().memory.ptr);
+ node2.page().size.rows = node2.capacity().rows;
+
+ const cells_len = @as(usize, node2.capacity().cols) *
+ @as(usize, node2.capacity().rows);
+ const cells = node2.page().cells.ptr(node2.page().memory)[0..cells_len];
+ try testing.expect(std.mem.allEqual(
+ u64,
+ @as([]const u64, @ptrCast(cells)),
+ 0,
+ ));
+ node2.page().assertIntegrity();
+ s.destroyNode(node2);
+}
+
+test "PageList increaseCapacity from zero-capacity dimensions" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, 80, 24, 0);
+ defer s.deinit();
+
+ // Compact the only page. A plain page has no styled, grapheme,
+ // or hyperlink content so the exact capacity is zero in every
+ // managed dimension.
+ var node = (try s.compact(s.pages.first.?)).?;
+ try testing.expectEqual(0, node.capacity().styles);
+ try testing.expectEqual(0, node.capacity().grapheme_bytes);
+ try testing.expectEqual(0, node.capacity().string_bytes);
+ try testing.expectEqual(0, node.capacity().hyperlink_bytes);
+
+ // Increasing each dimension from zero must actually grow it.
+ // Regression: 0 * 2 == 0 used to "succeed" without growing,
+ // which turned caller retry loops into infinite loops.
+ node = try s.increaseCapacity(node, .styles);
+ try testing.expect(node.capacity().styles > 0);
+ node = try s.increaseCapacity(node, .grapheme_bytes);
+ try testing.expect(node.capacity().grapheme_bytes > 0);
+ node = try s.increaseCapacity(node, .string_bytes);
+ try testing.expect(node.capacity().string_bytes > 0);
+ node = try s.increaseCapacity(node, .hyperlink_bytes);
+ try testing.expect(node.capacity().hyperlink_bytes > 0);
+
+ // Increasing a non-zero dimension still doubles.
+ const styles = node.capacity().styles;
+ node = try s.increaseCapacity(node, .styles);
+ try testing.expectEqual(styles * 2, node.capacity().styles);
+}
+
+test "PageList compact after increaseCapacity" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -14128,23 +16911,15 @@ test "PageList compact insufficient savings returns null" {
var node = s.pages.first.?;
- // Make the page slightly oversized (just one increase)
- // This might not provide enough savings to justify compaction
+ // Grow the page capacity. The content is unchanged, so compaction
+ // should always shrink it back down to an exact-size heap page.
node = try s.increaseCapacity(node, .grapheme_bytes);
+ const grown_len = node.page().memory.len;
- // If the page is still at or below std_size, compact returns null
- if (node.data.memory.len <= std_size) {
- const result = try s.compact(node);
- try testing.expectEqual(null, result);
- } else {
- // If it did grow beyond std_size, verify that compaction
- // works or returns null based on savings calculation
- const result = try s.compact(node);
- // Either it compacted or determined insufficient savings
- if (result) |new_node| {
- try testing.expect(new_node.data.memory.len < node.data.memory.len);
- }
- }
+ const new_node = (try s.compact(node)).?;
+ try testing.expectEqual(.heap, new_node.owned);
+ try testing.expect(new_node.page().memory.len < grown_len);
+ try testing.expect(new_node.page().memory.len < std_size);
}
test "PageList split at middle row" {
@@ -14154,7 +16929,7 @@ test "PageList split at middle row" {
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write content to rows: row 0 gets codepoint 0, row 1 gets 1, etc.
for (0..page.size.rows) |y| {
@@ -14173,8 +16948,8 @@ test "PageList split at middle row" {
try testing.expect(s.pages.first != null);
try testing.expect(s.pages.first.?.next != null);
- const first_page = &s.pages.first.?.data;
- const second_page = &s.pages.first.?.next.?.data;
+ const first_page = s.pages.first.?.page();
+ const second_page = s.pages.first.?.next.?.page();
// First page should have rows 0-4 (5 rows)
try testing.expectEqual(@as(usize, 5), first_page.size.rows);
@@ -14201,7 +16976,7 @@ test "PageList split at row 0 is no-op" {
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write content to all rows
for (0..page.size.rows) |y| {
@@ -14235,7 +17010,7 @@ test "PageList split at last row" {
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Write content to all rows
for (0..page.size.rows) |y| {
@@ -14254,8 +17029,8 @@ test "PageList split at last row" {
try testing.expect(s.pages.first != null);
try testing.expect(s.pages.first.?.next != null);
- const first_page = &s.pages.first.?.data;
- const second_page = &s.pages.first.?.next.?.data;
+ const first_page = s.pages.first.?.page();
+ const second_page = s.pages.first.?.next.?.page();
// First page should have 9 rows
try testing.expectEqual(@as(usize, 9), first_page.size.rows);
@@ -14441,8 +17216,8 @@ test "PageList split middle page preserves linked list order" {
// Now we have 2 pages
const page1 = s.pages.first.?;
const page2 = s.pages.first.?.next.?;
- try testing.expectEqual(@as(usize, 4), page1.data.size.rows);
- try testing.expectEqual(@as(usize, 8), page2.data.size.rows);
+ try testing.expectEqual(@as(usize, 4), page1.rows());
+ try testing.expectEqual(@as(usize, 8), page2.rows());
// Split page2 at row 4 to create: page1 -> page2 (rows 0-3) -> page3 (rows 4-7)
const split_pin2: Pin = .{ .node = page2, .y = 4, .x = 0 };
@@ -14469,9 +17244,9 @@ test "PageList split middle page preserves linked list order" {
try testing.expect(last.next == null);
// Verify row counts
- try testing.expectEqual(@as(usize, 4), first.data.size.rows);
- try testing.expectEqual(@as(usize, 4), middle.data.size.rows);
- try testing.expectEqual(@as(usize, 4), last.data.size.rows);
+ try testing.expectEqual(@as(usize, 4), first.rows());
+ try testing.expectEqual(@as(usize, 4), middle.rows());
+ try testing.expectEqual(@as(usize, 4), last.rows());
}
test "PageList split last page makes new page the last" {
@@ -14489,7 +17264,7 @@ test "PageList split last page makes new page the last" {
// Now split the last page
const last_before_split = s.pages.last.?;
- try testing.expectEqual(@as(usize, 5), last_before_split.data.size.rows);
+ try testing.expectEqual(@as(usize, 5), last_before_split.rows());
const split_pin2: Pin = .{ .node = last_before_split, .y = 2, .x = 0 };
try s.split(split_pin2);
@@ -14501,8 +17276,8 @@ test "PageList split last page makes new page the last" {
try testing.expect(new_last.next == null);
// Verify row counts: original last has 2 rows, new last has 3 rows
- try testing.expectEqual(@as(usize, 2), last_before_split.data.size.rows);
- try testing.expectEqual(@as(usize, 3), new_last.data.size.rows);
+ try testing.expectEqual(@as(usize, 2), last_before_split.rows());
+ try testing.expectEqual(@as(usize, 3), new_last.rows());
}
test "PageList split first page keeps original as first" {
@@ -14534,9 +17309,9 @@ test "PageList split first page keeps original as first" {
try testing.expectEqual(second_page, inserted.next.?);
// Verify row counts: first has 2, inserted has 3, second has 5
- try testing.expectEqual(@as(usize, 2), s.pages.first.?.data.size.rows);
- try testing.expectEqual(@as(usize, 3), inserted.data.size.rows);
- try testing.expectEqual(@as(usize, 5), second_page.data.size.rows);
+ try testing.expectEqual(@as(usize, 2), s.pages.first.?.rows());
+ try testing.expectEqual(@as(usize, 3), inserted.rows());
+ try testing.expectEqual(@as(usize, 5), second_page.rows());
}
test "PageList split preserves wrap flags" {
@@ -14546,7 +17321,7 @@ test "PageList split preserves wrap flags" {
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Set wrap flags on rows that will be in the second page after split
// Row 5: wrap = true (this is the start of a wrapped line)
@@ -14568,7 +17343,7 @@ test "PageList split preserves wrap flags" {
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
- const second_page = &s.pages.first.?.next.?.data;
+ const second_page = s.pages.first.?.next.?.page();
// Verify wrap flags are preserved in new page
// Original row 5 is now row 0 in second page
@@ -14600,7 +17375,7 @@ test "PageList split preserves styled cells" {
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Create a style and apply it to cells in rows 5-7 (which will be in the second page)
const style: stylepkg.Style = .{ .flags = .{ .bold = true } };
@@ -14623,8 +17398,8 @@ test "PageList split preserves styled cells" {
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
- const first_page = &s.pages.first.?.data;
- const second_page = &s.pages.first.?.next.?.data;
+ const first_page = s.pages.first.?.page();
+ const second_page = s.pages.first.?.next.?.page();
// First page should have no styles (all styled rows moved to second page)
try testing.expectEqual(@as(usize, 0), first_page.styles.count());
@@ -14651,7 +17426,7 @@ test "PageList split preserves grapheme clusters" {
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Add a grapheme cluster to row 6 (will be row 1 in second page after split at 5)
{
@@ -14670,8 +17445,8 @@ test "PageList split preserves grapheme clusters" {
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
- const first_page = &s.pages.first.?.data;
- const second_page = &s.pages.first.?.next.?.data;
+ const first_page = s.pages.first.?.page();
+ const second_page = s.pages.first.?.next.?.page();
// First page should have no graphemes (the grapheme row moved to second page)
try testing.expectEqual(@as(usize, 0), first_page.graphemeCount());
@@ -14699,7 +17474,7 @@ test "PageList split preserves hyperlinks" {
var s = try init(alloc, 10, 10, 0);
defer s.deinit();
- const page = &s.pages.first.?.data;
+ const page = s.pages.first.?.page();
// Add a hyperlink to row 7 (will be row 2 in second page after split at 5)
const hyperlink_id = try page.insertHyperlink(.{
@@ -14719,8 +17494,8 @@ test "PageList split preserves hyperlinks" {
const split_pin: Pin = .{ .node = s.pages.first.?, .y = 5, .x = 0 };
try s.split(split_pin);
- const first_page = &s.pages.first.?.data;
- const second_page = &s.pages.first.?.next.?.data;
+ const first_page = s.pages.first.?.page();
+ const second_page = s.pages.first.?.next.?.page();
// First page should have no hyperlinks (the hyperlink row moved to second page)
try testing.expectEqual(@as(usize, 0), first_page.hyperlink_set.count());
diff --git a/vendor/libghostty-vt/src/terminal/Screen.zig b/vendor/libghostty-vt/src/terminal/Screen.zig
index becda78b..6c4db323 100644
--- a/vendor/libghostty-vt/src/terminal/Screen.zig
+++ b/vendor/libghostty-vt/src/terminal/Screen.zig
@@ -113,7 +113,7 @@ pub const SemanticPrompt = struct {
pub const SemanticClick = union(enum) {
none,
- click_events,
+ click_events: osc.semantic_prompt.ClickEvents,
cl: osc.semantic_prompt.Click,
};
};
@@ -530,20 +530,30 @@ pub fn clone(
}
// We move the top pin back in bounds to the top row.
+ const node = pages.pages.first.?;
break :start try pages.trackPin(.{
- .node = pages.pages.first.?,
- .x = if (sel.rectangle) ordered.tl.x else 0,
+ .node = node,
+ .x = if (sel.rectangle)
+ @min(ordered.tl.x, node.cols() - 1)
+ else
+ 0,
});
};
// If we got to this point it means that the selection is not
// fully out of bounds, so we move the bottom right pin back
// in bounds if it isn't already.
- 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,
- });
+ const end_pin = pin_remap.get(ordered.br) orelse end: {
+ const node = pages.pages.last.?;
+ break :end try pages.trackPin(.{
+ .node = node,
+ .x = if (sel.rectangle)
+ @min(ordered.br.x, node.cols() - 1)
+ else
+ node.cols() - 1,
+ .y = node.rows() - 1,
+ });
+ };
break :sel .{
.bounds = .{ .tracked = .{
@@ -584,7 +594,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
@@ -651,7 +661,7 @@ pub fn cursorCellEndOfPrev(self: *Screen) *pagepkg.Cell {
assert(self.cursor.y > 0);
var page_pin = self.cursor.page_pin.up(1).?;
- page_pin.x = self.pages.cols - 1;
+ page_pin.x = page_pin.node.cols() - 1;
const page_rac = page_pin.rowAndCell();
return page_rac.cell;
}
@@ -808,7 +818,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 +855,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 +883,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 +911,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,
@@ -934,8 +960,8 @@ pub fn cursorScrollAbove(self: *Screen) !void {
// lot cheaper in 99% of cases.
const old_pin = self.cursor.page_pin.*;
- if (try self.pages.grow()) |_| {
- try self.cursorScrollAboveRotate();
+ if (try self.pages.grow()) |new_node| {
+ try self.cursorScrollAboveRotate(new_node);
} else {
// In this case, it means grow() didn't allocate a new page.
@@ -950,11 +976,13 @@ 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 ].
var rows = page.rows.ptr(page.memory.ptr);
+ // Rotating this suffix changes which logical row its coordinates identify.
+ self.pages.invalidateNodeLayout(pin.node);
fastmem.rotateOnceR(Row, rows[pin.y..page.size.rows]);
// Mark the whole page as dirty.
@@ -988,7 +1016,7 @@ pub fn cursorScrollAbove(self: *Screen) !void {
// 1 |5E00000000| | 4
// +----------+ :
// +-------------+
- try self.cursorScrollAboveRotate();
+ try self.cursorScrollAboveRotate(null);
}
}
@@ -1003,7 +1031,10 @@ pub fn cursorScrollAbove(self: *Screen) !void {
}
}
-fn cursorScrollAboveRotate(self: *Screen) !void {
+fn cursorScrollAboveRotate(
+ self: *Screen,
+ fresh_node: ?*PageList.List.Node,
+) !void {
self.cursorChangePin(self.cursor.page_pin.down(1).?);
// Go through each of the pages following our pin, shift all rows
@@ -1011,11 +1042,17 @@ 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 = &current.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);
+ // A newly allocated tail has no earlier references to invalidate.
+ if (fresh_node == null or current != fresh_node.?) {
+ // Rotating this page moves every cached row coordinate down by one.
+ self.pages.invalidateNodeLayout(current);
+ }
+
// Rotate the pages down: [ 0 1 2 3 ] => [ 3 0 1 2 ]
fastmem.rotateOnceR(Row, cur_rows[0..cur_page.size.rows]);
@@ -1033,8 +1070,10 @@ 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 = &current.data;
+ const cur_page = current.page();
const cur_rows = cur_page.rows.ptr(cur_page.memory.ptr);
+ // Rotating the cursor-page suffix changes its cached row coordinates.
+ self.pages.invalidateNodeLayout(current);
fastmem.rotateOnceR(Row, cur_rows[self.cursor.page_pin.y..cur_page.size.rows]);
self.clearCells(
cur_page,
@@ -1055,6 +1094,161 @@ fn cursorScrollAboveRotate(self: *Screen) !void {
self.cursor.page_cell = page_rac.cell;
}
+/// Scroll a full-width scroll region that ends at the cursor row up by
+/// one row. The cursor must be on the bottom row of the region. `limit`
+/// is the number of rows in the region above the cursor (region height
+/// minus one) and must be at least 1.
+///
+/// The top row of the region is discarded (NOT moved into scrollback).
+/// All other rows in the region shift up by one and the cursor row
+/// becomes a blank row, filled with the current background color (like
+/// other scroll operations such as deleteLines).
+///
+/// The cursor stays at the same screen position (the new blank row).
+/// Content outside of the region is unmodified.
+///
+/// This is a very hot path for scroll region usage (e.g. a program
+/// on the alt screen using DECSTBM and scrolling via LF/IND) so this
+/// is optimized for the common case where the full region is within
+/// a single page.
+pub fn cursorScrollRegionUp(self: *Screen, limit: usize) !void {
+ assert(limit >= 1);
+ assert(self.cursor.y >= limit);
+ defer self.assertIntegrity();
+
+ const pin: *Pin = self.cursor.page_pin;
+
+ // If the region crosses a page boundary we take a slower path. This
+ // is rare: it requires the active area to span multiple pages with
+ // the split point inside the scroll region.
+ if (pin.y < limit) return try self.cursorScrollRegionUpSlow(limit);
+
+ // 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.
+ //
+ // 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.
+ {
+ const row: *Row = &rows[0];
+
+ // Whether our blank cell is a plain zero cell. This is true
+ // unless the cursor has a background color set (see blankCell).
+ const blank_is_zero = self.cursor.style_id == style.default_id or
+ self.cursor.style.bg_color == .none;
+
+ if (!row.managedMemory() and blank_is_zero) {
+ // Hot path: the row has no managed memory (styles,
+ // graphemes, hyperlinks) and our blank is zero so this is
+ // a straight zero fill. This is the overwhelmingly common
+ // case for scroll region usage.
+ const cells = page.getCells(row);
+ @memset(@as([]u64, @ptrCast(cells)), 0);
+ } else {
+ // The generic clear handles managed memory and fills the
+ // row with our blank cell, preserving the background color.
+ self.clearCells(page, row, page.getCells(row));
+ }
+ }
+
+ // Rotate the region rows so the now-blank top row moves to the
+ // bottom (the cursor row) and everything else shifts up by one.
+ // Rotating the region changes which logical row its coordinates identify.
+ self.pages.invalidateNodeLayout(pin.node);
+ fastmem.rotateOnce(Row, rows);
+
+ // Mark the whole page as dirty.
+ //
+ // Technically we only need to mark the rotated rows but this is
+ // a hot function, so we want to minimize work.
+ page.dirty = true;
+
+ // If our viewport is a pin and it's within the rotated region
+ // then we need to shift its cached row offset up. See
+ // PageList.eraseRowBounded for details; this mirrors that logic.
+ if (self.pages.viewport == .pin) viewport: {
+ if (self.pages.viewport_pin_row_offset) |*v| {
+ const p = self.pages.viewport_pin;
+ if (p.node != pin.node or
+ p.y < pin.y - limit or
+ p.y > pin.y or
+ p.y == 0) break :viewport;
+ v.* -= 1;
+ }
+ }
+
+ // Update tracked pins within the region since their rows moved up
+ // by one. The cursor's own pin is skipped because the cursor stays
+ // at the region bottom (the new blank row).
+ const pin_keys = self.pages.tracked_pins.keys();
+ for (pin_keys) |p| {
+ if (p.node != pin.node or
+ p == pin or
+ p.y < pin.y - limit or
+ p.y > pin.y) continue;
+ if (p.y == 0) p.x = 0 else p.y -= 1;
+ }
+
+ // The cursor pin is unchanged, but the Row structure at the pin
+ // position now contains the blank row, so we need to refresh our
+ // cached row/cell pointers. We compute them directly from the row
+ // slice we already have rather than going through the pin since
+ // this is a hot path.
+ const cursor_row: *Row = &rows[limit];
+ self.cursor.page_row = cursor_row;
+ self.cursor.page_cell = &page.getCells(cursor_row)[self.cursor.x];
+}
+
+/// Slow path for cursorScrollRegionUp: the scroll region spans
+/// multiple pages so we use the generic PageList erase machinery.
+fn cursorScrollRegionUpSlow(self: *Screen, limit: usize) !void {
+ // The call to eraseRowBounded below will move our tracked cursor
+ // pin up by one row since it is inside the erased region, but we
+ // don't actually want that: the cursor stays put, on the new blank
+ // row at the region bottom. We keep the old pin and put it back
+ // after, exactly like cursorDownScroll does for the no-scrollback
+ // case.
+ //
+ // This matters beyond performance: when the cursor is on the first
+ // row of a page, eraseRowBounded moves the tracked pin to the
+ // previous page. Moving the cursor back down with cursorDown would
+ // then cross pages via cursorChangePin, which migrates the cursor
+ // style refcount from a page that never held it, corrupting the
+ // style accounting.
+ const old_pin = self.cursor.page_pin.*;
+
+ try self.pages.eraseRowBounded(
+ .{ .active = .{ .y = @intCast(self.cursor.y - limit) } },
+ limit,
+ );
+
+ // We don't use `cursorChangePin` here because we aren't actually
+ // changing the pin, we're keeping it the same. Since the page
+ // never changes, the cursor's style ref stays valid and no style
+ // accounting needs to be updated.
+ self.cursor.page_pin.* = old_pin;
+
+ // We do, however, need to refresh the cached page row and cell,
+ // because the row contents at our pin position changed (it now
+ // contains the blank row).
+ const page_rac = self.cursor.page_pin.rowAndCell();
+ self.cursor.page_row = page_rac.row;
+ self.cursor.page_cell = page_rac.cell;
+
+ // eraseRowBounded clears the new row with zero cells so if our
+ // blank cell isn't zero (bg color is set) we need to fill it.
+ const blank = self.blankCell();
+ if (!blank.isZero()) {
+ const cells: [*]Cell = @ptrCast(self.cursor.page_cell);
+ @memset((cells - self.cursor.x)[0..self.pages.cols], blank);
+ }
+}
+
/// Move the cursor down if we're not at the bottom of the screen. Otherwise
/// scroll. Currently only used for testing.
inline fn cursorDownOrScroll(self: *Screen) !void {
@@ -1107,7 +1301,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);
@@ -1162,7 +1356,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;
@@ -1170,7 +1364,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);
}
@@ -1245,12 +1439,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],
);
}
}
@@ -1347,19 +1541,19 @@ pub fn clearRows(
var it = self.pages.pageIterator(.right_down, tl, bl);
while (it.next()) |chunk| {
+ const page = chunk.node.page();
for (chunk.rows()) |*row| {
const cells_offset = row.cells;
- const cells_multi: [*]Cell = row.cells.ptr(chunk.node.data.memory);
- const cells = cells_multi[0..self.pages.cols];
+ const cells = page.getCells(row);
// Clear all cells
if (protected) {
- self.clearUnprotectedCells(&chunk.node.data, row, cells);
+ self.clearUnprotectedCells(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(page, row, cells);
row.* = .{ .cells = cells_offset };
}
@@ -1377,6 +1571,8 @@ pub fn clearCells(
row: *Row,
cells: []Cell,
) void {
+ if (cells.len == 0) return;
+
// This whole operation does unsafe things, so we just want to assert
// the end state.
page.pauseIntegrityChecks(true);
@@ -1408,7 +1604,7 @@ pub fn clearCells(
// If we have no left/right scroll region we can be sure
// that we've cleared all the graphemes, so we clear the
// flag, otherwise we ask the page to update the flag.
- if (cells.len == self.pages.cols) {
+ if (cells.len == page.size.cols) {
row.grapheme = false;
} else {
page.updateRowGraphemeFlag(row);
@@ -1424,7 +1620,7 @@ pub fn clearCells(
// If we have no left/right scroll region we can be sure
// that we've cleared all the hyperlinks, so we clear the
// flag, otherwise we ask the page to update the flag.
- if (cells.len == self.pages.cols) {
+ if (cells.len == page.size.cols) {
row.hyperlink = false;
} else {
page.updateRowHyperlinkFlag(row);
@@ -1432,15 +1628,27 @@ pub fn clearCells(
}
if (row.styled) {
- for (cells) |*cell| {
- if (cell.hasStyling())
- page.styles.release(page.memory, cell.style_id);
+ // Styled cells overwhelmingly come in runs sharing the same
+ // style (e.g. a colored status bar or a highlighted region),
+ // so group them and release each run with a single ref-count
+ // update rather than per cell.
+ var i: usize = 0;
+ while (i < cells.len) {
+ const id = cells[i].style_id;
+ if (id == style.default_id) {
+ i += 1;
+ continue;
+ }
+ var j = i + 1;
+ while (j < cells.len and cells[j].style_id == id) j += 1;
+ page.styles.releaseMultiple(page.memory, id, @intCast(j - i));
+ i = j;
}
// If we have no left/right scroll region we can be sure
// that we've cleared all the styles, so we clear the
// flag, otherwise we ask the page to update the flag.
- if (cells.len == self.pages.cols) {
+ if (cells.len == page.size.cols) {
row.styled = false;
} else {
page.updateRowStyledFlag(row);
@@ -1449,7 +1657,7 @@ pub fn clearCells(
if (comptime build_options.kitty_graphics) {
if (row.kitty_virtual_placeholder and
- cells.len == self.pages.cols)
+ cells.len == page.size.cols)
{
for (cells) |c| {
if (c.codepoint() == kitty.graphics.unicode.placeholder) {
@@ -1525,12 +1733,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.
@@ -1574,12 +1782,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],
);
}
}
@@ -1690,7 +1898,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;
@@ -1727,7 +1935,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);
@@ -1753,7 +1961,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);
@@ -1992,6 +2200,12 @@ pub fn setAttribute(
.unknown => return,
}
+ // If the attribute didn't change our style then we can skip the
+ // style update entirely: our current style ID is already correct.
+ // This is a common case in the wild where programs re-assert the
+ // same style repeatedly (e.g. per span or per line).
+ if (self.cursor.style.eql(old_style)) return;
+
try self.manualStyleUpdate();
}
@@ -2011,7 +2225,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()});
@@ -2062,7 +2276,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,
@@ -2110,13 +2324,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
@@ -2152,8 +2366,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,
@@ -2184,7 +2398,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,
@@ -2214,13 +2428,13 @@ pub fn startHyperlink(
.id = if (id_) |id| .{
.explicit = id,
} else implicit: {
- defer self.cursor.hyperlink_implicit_id += 1;
+ defer self.cursor.hyperlink_implicit_id +%= 1;
break :implicit .{ .implicit = self.cursor.hyperlink_implicit_id };
},
};
errdefer switch (link.id) {
.explicit => {},
- .implicit => self.cursor.hyperlink_implicit_id -= 1,
+ .implicit => self.cursor.hyperlink_implicit_id -%= 1,
};
// Loop until we have enough page memory to add the hyperlink
@@ -2261,9 +2475,6 @@ fn startHyperlinkOnce(
self: *Screen,
source: hyperlink.Hyperlink,
) (Allocator.Error || Page.InsertHyperlinkError)!void {
- // End any prior hyperlink
- self.endHyperlink();
-
// Allocate our new Hyperlink entry in non-page memory. This
// lets us quickly get access to URI, ID.
const link = try self.alloc.create(hyperlink.Hyperlink);
@@ -2271,8 +2482,12 @@ fn startHyperlinkOnce(
link.* = try source.dupe(self.alloc);
errdefer link.deinit(self.alloc);
+ // End any prior hyperlink only after duplicating the new value. The
+ // source slices are allowed to reference our current hyperlink.
+ self.endHyperlink();
+
// 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
@@ -2299,7 +2514,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.?);
@@ -2311,7 +2526,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,
@@ -2349,9 +2564,12 @@ 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();
}
+ // The hyperlink map is fixed-capacity, so reaching this error
+ // means live entries fill the usable map capacity and the page
+ // must grow.
_ = try self.increaseCapacity(
self.cursor.page_pin.node,
.hyperlink_bytes,
@@ -2425,8 +2643,28 @@ pub fn select(self: *Screen, sel_: ?Selection) Allocator.Error!void {
const tracked_sel = if (sel.tracked()) sel else try sel.track(self);
errdefer if (!sel.tracked()) tracked_sel.deinit(self);
- // Untrack prior selection
- if (self.selection) |*old| old.deinit(self);
+ // Untrack prior selection pins that aren't also owned by the replacement.
+ // A caller may pass our current tracked selection back to us by value, so
+ // releasing both old pins unconditionally would leave the replacement
+ // pointing at freed pool entries.
+ if (self.selection) |old| {
+ const new_bounds = tracked_sel.bounds.tracked;
+ switch (old.bounds) {
+ .untracked => old.deinit(self),
+ .tracked => |old_bounds| {
+ if (old_bounds.start != new_bounds.start and
+ old_bounds.start != new_bounds.end)
+ {
+ self.pages.untrackPin(old_bounds.start);
+ }
+ if (old_bounds.end != new_bounds.start and
+ old_bounds.end != new_bounds.end)
+ {
+ self.pages.untrackPin(old_bounds.end);
+ }
+ },
+ }
+ }
self.selection = tracked_sel;
self.dirty.selection = true;
}
@@ -2551,7 +2789,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;
@@ -2579,7 +2817,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];
@@ -2607,7 +2845,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.
@@ -2620,7 +2858,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 = prev.node.cols() - 1;
break :end_pin prev;
}
@@ -2636,7 +2874,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;
}
}
@@ -2826,7 +3064,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;
}
@@ -2846,7 +3084,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;
}
@@ -2898,7 +3136,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;
@@ -3027,7 +3265,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
@@ -3103,7 +3341,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: {
@@ -3253,7 +3491,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,
@@ -3287,7 +3525,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;
}
@@ -3344,7 +3582,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;
@@ -3484,7 +3722,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 = {} });
@@ -3508,15 +3746,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");
@@ -3542,10 +3780,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);
@@ -3564,7 +3802,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);
@@ -3580,7 +3818,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());
@@ -3595,7 +3833,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);
@@ -3608,6 +3846,54 @@ test "Screen cursorCopy hyperlink deref" {
try testing.expect(s2.cursor.hyperlink_id == 0);
}
+test "Screen write regrows compacted page capacity" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try Screen.init(alloc, .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ });
+ defer s.deinit();
+
+ // Compact the active page so every managed capacity dimension is
+ // zero, then reload the cursor since its cached row/cell pointers
+ // point into the replaced page.
+ {
+ const node = (try s.pages.compact(s.cursor.page_pin.node)).?;
+ 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();
+ }
+
+ // Styled write: exercises the manualStyleUpdate single-retry
+ // path. Prior to increaseCapacity handling zero dimensions, the
+ // retry would fail and the style would be dropped.
+ try s.setAttribute(.{ .bold = {} });
+ try s.testWriteString("A");
+
+ // Grapheme write: exercises the appendGrapheme single-retry path.
+ // We can't use testWriteString here because it appends graphemes
+ // directly on the page without the capacity retry.
+ try s.testWriteString("a");
+ try s.appendGrapheme(s.cursorCellLeft(1), 0x0301);
+
+ // Hyperlink: exercises the startHyperlink retry loop, which used
+ // to loop forever when capacity growth from zero didn't grow.
+ try s.startHyperlink("https://example.com/", null);
+ try s.testWriteString("B");
+ s.endHyperlink();
+
+ // Verify the content landed on the page.
+ 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);
+}
+
test "Screen cursorCopy hyperlink deref new page" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -3619,15 +3905,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");
@@ -3653,10 +3939,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);
@@ -3675,7 +3961,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);
@@ -3690,12 +3976,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);
@@ -3715,12 +4001,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);
@@ -3737,7 +4023,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
@@ -3759,7 +4045,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
@@ -3779,7 +4065,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
@@ -3836,7 +4122,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);
@@ -3849,6 +4135,38 @@ test "Screen clearRows active styled line" {
try testing.expectEqualStrings("", str);
}
+test "Screen clearCells empty range" {
+ const testing = std.testing;
+
+ var s = try Screen.init(testing.allocator, .default);
+ defer s.deinit();
+
+ const page = s.cursor.page_pin.node.page();
+ const row = s.cursor.page_row;
+ const cells = page.getCells(row);
+ s.clearCells(page, row, cells[0..0]);
+}
+
+test "Screen clearRows uses stored page width" {
+ const testing = std.testing;
+
+ var s = try Screen.init(testing.allocator, .{
+ .cols = 2,
+ .rows = 1,
+ .max_scrollback = 0,
+ });
+ defer s.deinit();
+
+ try s.testWriteString("AB");
+ const node = s.pages.pages.first.?;
+ s.pages.cols = 4;
+
+ s.clearRows(.{ .screen = .{} }, null, false);
+ const cells = node.page().getCells(&node.page().rows.ptr(node.page().memory)[0]);
+ try testing.expectEqual(@as(usize, 2), cells.len);
+ for (cells) |cell| try testing.expect(cell.isEmpty());
+}
+
test "Screen clearRows protected" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -3974,6 +4292,27 @@ test "Screen eraseRows active partial" {
}
}
+test "Screen: cursorCellEndOfPrev across mixed-width pages" {
+ const testing = std.testing;
+ var s = try init(testing.allocator, .{
+ .cols = 4,
+ .rows = 2,
+ .max_scrollback = 0,
+ });
+ defer s.deinit();
+
+ try s.testWriteString("ABCDE");
+ const first = s.pages.pages.first.?;
+ try s.pages.split(.{ .node = first, .y = 1 });
+ s.cursorReload();
+ const second = first.next.?;
+ first.page().size.cols = 2;
+
+ try testing.expectEqual(second, s.cursor.page_pin.node);
+ const expected = (Pin{ .node = first, .x = 1 }).rowAndCell().cell;
+ try testing.expectEqual(expected, s.cursorCellEndOfPrev());
+}
+
test "Screen: cursorDown across pages preserves style" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -3982,7 +4321,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();
@@ -3992,21 +4331,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,
@@ -4017,7 +4356,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,
@@ -4034,7 +4373,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();
@@ -4044,14 +4383,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,
@@ -4062,7 +4401,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(
@@ -4081,7 +4420,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();
@@ -4091,14 +4430,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,
@@ -4109,7 +4448,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(
@@ -4136,13 +4475,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
@@ -4177,7 +4516,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);
@@ -4191,7 +4530,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();
}
@@ -4295,7 +4634,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;
@@ -4306,7 +4645,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(
@@ -4571,6 +4910,223 @@ test "Screen: scrolling moves selection" {
}
}
+test "Screen: cursorScrollRegionUp simple" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+ try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
+
+ // Scroll a region ending at row 2 (zero-indexed) up by one. This
+ // emulates a scroll region of rows 0-2 with the cursor at the
+ // region bottom.
+ s.cursorAbsolute(1, 2);
+ try s.cursorScrollRegionUp(2);
+
+ // The cursor stays in place, on the new blank row.
+ try testing.expectEqual(@as(size.CellCountInt, 1), s.cursor.x);
+ try testing.expectEqual(@as(size.CellCountInt, 2), s.cursor.y);
+
+ // Rows in the region scrolled, rows below are unchanged, and
+ // nothing was moved into scrollback.
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents);
+ }
+}
+
+test "Screen: cursorScrollRegionUp renews page generation" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+ try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
+ s.cursorAbsolute(0, 2);
+
+ const node = s.cursor.page_pin.node;
+ const serial = node.serial;
+ try s.cursorScrollRegionUp(2);
+
+ try testing.expect(!s.pages.nodeIsValid(node, serial));
+}
+
+test "Screen: cursorScrollRegionUp moves selection" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+ try s.testWriteString("1ABCD\n2EFGH\n3IJKL\n4MNOP\n5QRST");
+
+ // Select the second row.
+ try s.select(Selection.init(
+ s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
+ s.pages.pin(.{ .active = .{ .x = s.pages.cols - 1, .y = 1 } }).?,
+ false,
+ ));
+
+ s.cursorAbsolute(0, 2);
+ try s.cursorScrollRegionUp(2);
+
+ // Our selection should've moved up with its row.
+ {
+ const sel = s.selection.?;
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = 0,
+ .y = 0,
+ } }, s.pages.pointFromPin(.active, sel.start()).?);
+ try testing.expectEqual(point.Point{ .active = .{
+ .x = s.pages.cols - 1,
+ .y = 0,
+ } }, s.pages.pointFromPin(.active, sel.end()).?);
+ }
+
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("2EFGH\n3IJKL\n\n4MNOP\n5QRST", contents);
+ }
+}
+
+test "Screen: cursorScrollRegionUp region spans pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 });
+ defer s.deinit();
+
+ // We need to get the cursor to a new page
+ 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.?.page().pauseIntegrityChecks(false);
+ try s.testWriteString("1A\n2B\n3C\n4D\n5E");
+
+ // At this point:
+ // +----------+ = PAGE 0
+ // ... : :
+ // +-------------+ ACTIVE
+ // 4305 |1A00000000| | 0
+ // 4306 |2B00000000| | 1
+ // 4307 |3C00000000| | 2
+ // +----------+ :
+ // +----------+ : = PAGE 1
+ // 0 |4D00000000| | 3
+ // :^ : : = PIN 0
+ // 1 |5E00000000| | 4
+ // +----------+ :
+ // +-------------+
+
+ // Move the cursor to the first row of the second page and give it
+ // a non-default style. This is important: it verifies that the
+ // cursor's style ref stays accounted on the correct page even
+ // though eraseRowBounded moves the cursor's tracked pin across
+ // the page boundary.
+ s.cursorAbsolute(0, 3);
+ try s.setAttribute(.{ .bold = {} });
+ try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?);
+ try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y);
+
+ // Scroll a region of active rows 1-3 with the cursor at the region
+ // bottom. The region spans the page boundary so this exercises the
+ // slow path.
+ try s.cursorScrollRegionUp(2);
+
+ // The cursor stays in place, on the new blank row.
+ try testing.expectEqual(@as(size.CellCountInt, 0), s.cursor.x);
+ try testing.expectEqual(@as(size.CellCountInt, 3), s.cursor.y);
+
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents);
+ }
+
+ // Our cursor style must remain usable: write a styled cell and
+ // verify the style ref counting is intact on the cursor's page.
+ try s.testWriteString("X");
+ {
+ const page = s.cursor.page_pin.node.page();
+ const styles = page.styles.count();
+ try testing.expectEqual(@as(usize, 1), styles);
+ }
+}
+
+test "Screen: cursorScrollRegionUp region spans pages with background SGR" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 10, .rows = 5, .max_scrollback = 10 });
+ defer s.deinit();
+
+ // 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.?.capacity().rows;
+ s.pages.pages.first.?.page().pauseIntegrityChecks(true);
+ for (0..first_page_size - 3) |_| try s.testWriteString("\n");
+ s.pages.pages.first.?.page().pauseIntegrityChecks(false);
+ try s.testWriteString("1A\n2B\n3C\n4D\n5E");
+
+ s.cursorAbsolute(0, 3);
+ try s.setAttribute(.{ .direct_color_bg = .{ .r = 0xFF, .g = 0, .b = 0 } });
+ try testing.expect(s.cursor.page_pin.node == s.pages.pages.last.?);
+ try testing.expectEqual(@as(usize, 0), s.cursor.page_pin.y);
+
+ try s.cursorScrollRegionUp(2);
+
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .viewport = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("1A\n3C\n4D\n\n5E", contents);
+ }
+
+ // The new blank row must be filled with our background color.
+ for (0..s.pages.cols) |x| {
+ const list_cell = s.pages.getCell(.{ .active = .{
+ .x = @intCast(x),
+ .y = 3,
+ } }).?;
+ try testing.expect(list_cell.cell.content_tag == .bg_color_rgb);
+ try testing.expectEqual(Cell.RGB{
+ .r = 0xFF,
+ .g = 0,
+ .b = 0,
+ }, list_cell.cell.content.color_rgb);
+ }
+}
+
+test "Screen: cursorScrollRegionUp with styled erased row" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 3, .max_scrollback = 0 });
+ defer s.deinit();
+
+ // Write a styled row at the top so the erased row has managed
+ // memory that must be released.
+ try s.setAttribute(.{ .bold = {} });
+ try s.testWriteString("1ABCD");
+ try s.setAttribute(.{ .unset = {} });
+ try s.testWriteString("\n2EFGH\n3IJKL");
+
+ s.cursorAbsolute(0, 2);
+ try s.cursorScrollRegionUp(2);
+
+ {
+ const contents = try s.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(contents);
+ try testing.expectEqualStrings("2EFGH\n3IJKL", contents);
+ }
+
+ // The style should be gone from the page since the only user
+ // was the erased row.
+ const page = s.cursor.page_pin.node.page();
+ try testing.expectEqual(@as(usize, 0), page.styles.count());
+}
+
test "Screen: scrolling moves viewport" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -4759,7 +5315,10 @@ test "Screen: scroll above same page" {
// +----------+ :
// +-------------+
+ const node = s.cursor.page_pin.node;
+ const serial = node.serial;
try s.cursorScrollAbove();
+ try testing.expect(!s.pages.nodeIsValid(node, serial));
// +----------+ = PAGE 0
// 0 |1ABCD00000|
@@ -4805,10 +5364,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");
@@ -4834,7 +5393,13 @@ test "Screen: scroll above same page but cursor on previous page" {
// +----------+ :
// +-------------+
+ const first_node = s.pages.pages.first.?;
+ const second_node = first_node.next.?;
+ const first_serial = first_node.serial;
+ const second_serial = second_node.serial;
try s.cursorScrollAbove();
+ try testing.expect(!s.pages.nodeIsValid(first_node, first_serial));
+ try testing.expect(!s.pages.nodeIsValid(second_node, second_serial));
// +----------+ = PAGE 0
// ... : :
@@ -4886,10 +5451,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");
@@ -4964,8 +5529,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" {
@@ -4976,10 +5541,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");
@@ -4999,7 +5564,10 @@ test "Screen: scroll above creates new page" {
// 4307 |3IJKL00000| | 2
// +----------+ :
// +-------------+
+ const node = s.pages.pages.first.?;
+ const serial = node.serial;
try s.cursorScrollAbove();
+ try testing.expect(!s.pages.nodeIsValid(node, serial));
// +----------+ = PAGE 0
// ... : :
@@ -5048,10 +5616,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.
@@ -5124,10 +5692,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");
@@ -5498,6 +6066,51 @@ test "Screen: clone contains subset of selection" {
}
}
+test "Screen: clone clamps clipped selections to mixed-width pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 4, .rows = 3, .max_scrollback = 0 });
+ defer s.deinit();
+
+ const first = s.pages.pages.first.?;
+ try s.pages.split(.{ .node = first, .y = 2 });
+ try s.pages.split(.{ .node = first, .y = 1 });
+ const middle = first.next.?;
+ const last = middle.next.?;
+ middle.page().size.cols = 2;
+
+ try s.select(Selection.init(
+ .{ .node = first },
+ .{ .node = last, .x = 3 },
+ false,
+ ));
+ var linear = try s.clone(
+ alloc,
+ .{ .screen = .{} },
+ .{ .screen = .{ .y = 1 } },
+ );
+ defer linear.deinit();
+ const linear_end = linear.selection.?.end();
+ _ = linear_end.rowAndCell();
+ try testing.expectEqual(@as(size.CellCountInt, 1), linear_end.x);
+
+ try s.select(Selection.init(
+ .{ .node = first, .x = 3 },
+ .{ .node = last, .x = 3 },
+ true,
+ ));
+ var rectangle = try s.clone(
+ alloc,
+ .{ .screen = .{ .y = 1 } },
+ null,
+ );
+ defer rectangle.deinit();
+ const rectangle_start = rectangle.selection.?.start();
+ _ = rectangle_start.rowAndCell();
+ try testing.expectEqual(@as(size.CellCountInt, 1), rectangle_start.x);
+}
+
test "Screen: clone contains subset of rectangle selection" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -6533,7 +7146,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) {
@@ -7652,6 +8265,49 @@ test "Screen: select untracked" {
try testing.expectEqual(tracked, s.pages.countTrackedPins());
}
+test "Screen: select replaces existing pins" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 10, .rows = 10, .max_scrollback = 0 });
+ defer s.deinit();
+ try s.testWriteString("ABC DEF\n 123\n456");
+
+ const tracked = s.pages.countTrackedPins();
+ try s.select(Selection.init(
+ s.pages.pin(.{ .active = .{ .x = 0, .y = 0 } }).?,
+ s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
+ false,
+ ));
+ try testing.expectEqual(tracked + 2, s.pages.countTrackedPins());
+
+ // Replacing the selection must untrack the prior selection's pins
+ // rather than leak them.
+ try s.select(Selection.init(
+ s.pages.pin(.{ .active = .{ .x = 0, .y = 1 } }).?,
+ s.pages.pin(.{ .active = .{ .x = 2, .y = 1 } }).?,
+ false,
+ ));
+ try testing.expectEqual(tracked + 2, s.pages.countTrackedPins());
+}
+
+test "Screen: reselecting tracked selection preserves its pins" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 10, .rows = 2, .max_scrollback = 0 });
+ defer s.deinit();
+
+ try s.select(Selection.init(
+ s.pages.pin(.{ .active = .{ .x = 1, .y = 0 } }).?,
+ s.pages.pin(.{ .active = .{ .x = 3, .y = 0 } }).?,
+ false,
+ ));
+
+ try s.select(s.selection.?);
+ try testing.expectEqual(Selection.Order.forward, s.selection.?.order(&s));
+}
+
test "Screen: selectAll" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -8342,6 +8998,37 @@ test "Screen: selectLine semantic boundary first cell of row" {
}
}
+test "Screen: selectLine semantic boundary across mixed-width pages" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 4, .rows = 2, .max_scrollback = 0 });
+ defer s.deinit();
+
+ s.cursorSetSemanticContent(.{ .input = .clear_explicit });
+ try s.testWriteString("ABCD");
+ s.cursorSetSemanticContent(.output);
+ try s.testWriteString("E");
+
+ const first = s.pages.pages.first.?;
+ try s.pages.split(.{ .node = first, .y = 1 });
+ const second = first.next.?;
+ first.page().size.cols = 2;
+ s.pages.pauseIntegrityChecks(true);
+ defer s.pages.pauseIntegrityChecks(false);
+
+ try testing.expectEqual(@as(size.CellCountInt, 2), first.cols());
+ try testing.expectEqual(@as(size.CellCountInt, 4), second.cols());
+
+ var sel = s.selectLine(.{ .pin = .{
+ .node = first,
+ .x = 1,
+ } }).?;
+ defer sel.deinit(&s);
+ try testing.expect((Pin{ .node = first, .x = 0 }).eql(sel.start()));
+ try testing.expect((Pin{ .node = first, .x = 1 }).eql(sel.end()));
+}
+
test "Screen: selectLine semantic all same content" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -9141,7 +9828,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);
}
@@ -9276,14 +9963,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");
@@ -9374,25 +10061,77 @@ 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());
}
}
+test "Screen: hyperlink accepts its current values" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+
+ try s.startHyperlink("http://example.com", "current");
+ const current = s.cursor.hyperlink.?;
+ try s.startHyperlink(current.uri, current.id.explicit);
+
+ try testing.expectEqualStrings("http://example.com", s.cursor.hyperlink.?.uri);
+ try testing.expectEqualStrings("current", s.cursor.hyperlink.?.id.explicit);
+}
+
+test "Screen: implicit hyperlink ID wraps" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var s = try init(alloc, .{ .cols = 5, .rows = 5, .max_scrollback = 0 });
+ defer s.deinit();
+
+ s.cursor.hyperlink_implicit_id = std.math.maxInt(size.OffsetInt);
+ try s.startHyperlink("http://example.com", null);
+
+ try testing.expectEqual(@as(size.OffsetInt, 0), s.cursor.hyperlink_implicit_id);
+ try testing.expectEqual(
+ std.math.maxInt(size.OffsetInt),
+ s.cursor.hyperlink.?.id.implicit,
+ );
+
+ // A failed allocation must roll the wrapped counter back to its
+ // original value as well.
+ s.endHyperlink();
+ s.cursor.hyperlink_implicit_id = std.math.maxInt(size.OffsetInt);
+ var failing = testing.FailingAllocator.init(alloc, .{});
+ failing.fail_index = failing.alloc_index;
+ {
+ const original_alloc = s.alloc;
+ defer s.alloc = original_alloc;
+ s.alloc = failing.allocator();
+ try testing.expectError(
+ error.OutOfMemory,
+ s.startHyperlink("http://example.com", null),
+ );
+ }
+ try testing.expectEqual(
+ std.math.maxInt(size.OffsetInt),
+ s.cursor.hyperlink_implicit_id,
+ );
+}
+
test "Screen: hyperlink reuse" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -9402,7 +10141,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());
}
@@ -9415,14 +10154,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());
}
}
@@ -9442,7 +10181,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());
}
@@ -9450,14 +10189,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());
}
}
@@ -9478,8 +10217,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) {
@@ -9491,9 +10230,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" {
@@ -9516,7 +10255,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,
@@ -9545,7 +10284,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);
}
@@ -9569,7 +10308,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);
@@ -9589,7 +10328,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());
}
}
@@ -9619,7 +10358,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,
@@ -9649,7 +10388,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());
@@ -9688,11 +10427,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
@@ -9703,8 +10442,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);
@@ -9712,13 +10451,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
@@ -9739,9 +10478,9 @@ test "Screen: cursorDown to page with insufficient capacity" {
// cursor movement functions). The bug pattern:
//
// 1. cursorDown creates a by-value copy of the pin via page_pin.down(n)
- // 2. cursorChangePin is called, which may trigger adjustCapacity
+ // 2. cursorChangePin is called, which may trigger increaseCapacity
// if the target page's style map is full
- // 3. adjustCapacity frees the old page and creates a new one
+ // 3. increaseCapacity frees the old page and creates a new one
// 4. The local pin copy still points to the freed page
// 5. rowAndCell() on the stale pin accesses freed memory
@@ -9753,18 +10492,18 @@ 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
- // with a style set, adjustCapacity will be triggered.
+ // with a style set, increaseCapacity will be triggered.
{
new_page.pauseIntegrityChecks(true);
defer new_page.pauseIntegrityChecks(false);
@@ -9790,10 +10529,10 @@ 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 adjustCapacity, causing rowAndCell()
+ // becomes stale after increaseCapacity, causing rowAndCell()
// to access freed memory.
s.cursorDown(1);
@@ -9825,7 +10564,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
@@ -9856,7 +10595,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);
@@ -9882,7 +10621,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,
@@ -9890,7 +10629,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/vendor/libghostty-vt/src/terminal/Selection.zig b/vendor/libghostty-vt/src/terminal/Selection.zig
index 5258210c..be6854e5 100644
--- a/vendor/libghostty-vt/src/terminal/Selection.zig
+++ b/vendor/libghostty-vt/src/terminal/Selection.zig
@@ -155,12 +155,12 @@ pub fn topLeft(self: Selection, s: *const Screen) Pin {
.reverse => self.end(),
.mirrored_forward => pin: {
var p = self.start();
- p.x = self.end().x;
+ p.x = @min(self.end().x, p.node.cols() - 1);
break :pin p;
},
.mirrored_reverse => pin: {
var p = self.end();
- p.x = self.start().x;
+ p.x = @min(self.start().x, p.node.cols() - 1);
break :pin p;
},
};
@@ -173,12 +173,12 @@ pub fn bottomRight(self: Selection, s: *const Screen) Pin {
.reverse => self.start(),
.mirrored_forward => pin: {
var p = self.end();
- p.x = self.start().x;
+ p.x = @min(self.start().x, p.node.cols() - 1);
break :pin p;
},
.mirrored_reverse => pin: {
var p = self.start();
- p.x = self.end().x;
+ p.x = @min(self.end().x, p.node.cols() - 1);
break :pin p;
},
};
@@ -326,6 +326,7 @@ pub fn containedRowCached(
br: point.Coordinate,
p: point.Coordinate,
) ?Selection {
+ _ = s;
if (p.y < tl.y or p.y > br.y) return null;
// Rectangle case: we can return early as the x range will always be the
@@ -333,12 +334,12 @@ pub fn containedRowCached(
if (self.rectangle) return init(
start: {
var copy: Pin = pin;
- copy.x = tl.x;
+ copy.x = @min(tl.x, copy.node.cols() - 1);
break :start copy;
},
end: {
var copy: Pin = pin;
- copy.x = br.x;
+ copy.x = @min(br.x, copy.node.cols() - 1);
break :end copy;
},
true,
@@ -355,7 +356,7 @@ pub fn containedRowCached(
tl_pin,
end: {
var copy: Pin = pin;
- copy.x = s.pages.cols - 1;
+ copy.x = copy.node.cols() - 1;
break :end copy;
},
false,
@@ -387,7 +388,7 @@ pub fn containedRowCached(
},
end: {
var copy: Pin = pin;
- copy.x = s.pages.cols - 1;
+ copy.x = copy.node.cols() - 1;
break :end copy;
},
false,
@@ -432,7 +433,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 +495,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 +506,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,
}
}
@@ -1054,6 +1055,32 @@ test "Selection: order, standard" {
}
}
+test "Selection: rectangle corners clamp across mixed-width pages" {
+ const testing = std.testing;
+ var s = try Screen.init(testing.allocator, .{
+ .cols = 4,
+ .rows = 2,
+ .max_scrollback = 0,
+ });
+ defer s.deinit();
+
+ const first = s.pages.pages.first.?;
+ try s.pages.split(.{ .node = first, .y = 1 });
+ const second = first.next.?;
+ second.page().size.cols = 2;
+
+ const sel = Selection.init(
+ .{ .node = first, .x = 3 },
+ .{ .node = second, .x = 1 },
+ true,
+ );
+ try testing.expectEqual(.mirrored_forward, sel.order(&s));
+
+ const bottom_right = sel.bottomRight(&s);
+ _ = bottom_right.rowAndCell();
+ try testing.expect((Pin{ .node = second, .x = 1 }).eql(bottom_right));
+}
+
test "Selection: order, rectangle" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -1602,3 +1629,46 @@ test "Selection: containedRow" {
).?);
}
}
+
+test "Selection: containedRow clamps mixed-width pages" {
+ const testing = std.testing;
+ var s = try Screen.init(testing.allocator, .{
+ .cols = 4,
+ .rows = 3,
+ .max_scrollback = 0,
+ });
+ defer s.deinit();
+
+ const first = s.pages.pages.first.?;
+ try s.pages.split(.{ .node = first, .y = 2 });
+ try s.pages.split(.{ .node = first, .y = 1 });
+ const middle = first.next.?;
+ const last = middle.next.?;
+ middle.page().size.cols = 2;
+
+ const linear = Selection.init(
+ .{ .node = first, .x = 1 },
+ .{ .node = last, .x = 1 },
+ false,
+ );
+ const linear_row = linear.containedRow(
+ &s,
+ .{ .node = middle },
+ ).?;
+ _ = linear_row.end().rowAndCell();
+ try testing.expect((Pin{ .node = middle }).eql(linear_row.start()));
+ try testing.expect((Pin{ .node = middle, .x = 1 }).eql(linear_row.end()));
+
+ const rectangle = Selection.init(
+ .{ .node = first, .x = 1 },
+ .{ .node = last, .x = 3 },
+ true,
+ );
+ const rectangle_row = rectangle.containedRow(
+ &s,
+ .{ .node = middle },
+ ).?;
+ _ = rectangle_row.end().rowAndCell();
+ try testing.expect((Pin{ .node = middle, .x = 1 }).eql(rectangle_row.start()));
+ try testing.expect((Pin{ .node = middle, .x = 1 }).eql(rectangle_row.end()));
+}
diff --git a/vendor/libghostty-vt/src/terminal/SelectionGesture.zig b/vendor/libghostty-vt/src/terminal/SelectionGesture.zig
index f0e11f63..2be1e8aa 100644
--- a/vendor/libghostty-vt/src/terminal/SelectionGesture.zig
+++ b/vendor/libghostty-vt/src/terminal/SelectionGesture.zig
@@ -228,7 +228,8 @@ pub fn validatedLeftClickPin(
}
pub const Press = struct {
- /// The time when the press event occurred. Use a monotonic timer.
+ /// The time when the press event occurred. Prefer a monotonic timer;
+ /// backwards timestamps reset the repeat sequence.
/// This can be null if you're on a system that doesn't support
/// time for some reason. In that case, we only support single clicks.
time: ?Time,
@@ -387,8 +388,8 @@ pub fn drag(
.cell => dragSelection(
click_pin.*,
d.pin,
- @intFromFloat(@max(0, self.left_click_xpos)),
- @intFromFloat(@max(0, d.xpos)),
+ pixelFromFloat(self.left_click_xpos),
+ pixelFromFloat(d.xpos),
d.rectangle,
d.geometry,
),
@@ -630,7 +631,8 @@ fn pressRepeat(
const time = p.time orelse return error.PressRequiresReset;
{
const prev_time = self.left_click_time orelse return error.PressRequiresReset;
- const since = timeSince(time, prev_time);
+ const since = timeSince(time, prev_time) orelse
+ return error.PressRequiresReset;
if (since > p.repeat_interval) return error.PressRequiresReset;
}
@@ -664,11 +666,29 @@ fn pressRepeat(
self.left_click_behavior = p.behaviors[self.left_click_count - 1];
}
-fn timeSince(time: Time, prev_time: Time) u64 {
- if (comptime freestanding_wasm) return time -| prev_time;
+fn timeSince(time: Time, prev_time: Time) ?u64 {
+ if (comptime freestanding_wasm) {
+ if (time < prev_time) return null;
+ return time - prev_time;
+ }
+
+ if (time.order(prev_time) == .lt) return null;
return time.since(prev_time);
}
+/// Convert a caller-provided floating-point position to a pixel coordinate.
+/// Negative and NaN values clamp to the origin, matching the drag behavior
+/// outside the left edge of the surface.
+fn pixelFromFloat(value: f64) u32 {
+ if (std.math.isNan(value) or value <= 0) return 0;
+
+ // @intFromFloat requires a value representable by the destination type.
+ // Saturate first so positive infinity and oversized coordinates are safe.
+ const max: f64 = @floatFromInt(std.math.maxInt(u32));
+ if (value >= max) return std.math.maxInt(u32);
+ return @intFromFloat(value);
+}
+
fn pressSelection(
self: *const SelectionGesture,
screen: *Screen,
@@ -713,6 +733,8 @@ fn dragSelection(
// Rectangular selections are handled similarly, except that
// entire columns are considered rather than individual cells.
+ if (geometry.columns == 0 or geometry.cell_width == 0) return null;
+
// We only include cells in the selection if the threshold point lies
// between the start and end points of the selection. A threshold of
// 60% of the cell width was chosen empirically because it felt good.
@@ -721,7 +743,12 @@ fn dragSelection(
));
// We use this to clamp the pixel positions below.
- const max_x = geometry.columns * geometry.cell_width - 1;
+ const pixel_span = std.math.mul(
+ u32,
+ geometry.columns,
+ geometry.cell_width,
+ ) catch std.math.maxInt(u32);
+ const max_x = pixel_span - 1;
// We need to know how far across in the cell the drag pos is, so
// we subtract the padding and then take it modulo the cell width.
@@ -935,6 +962,16 @@ fn testPress(t: *Terminal, x: u16, y: u32, time: ?std.time.Instant) Press {
};
}
+fn testInstant(ns: u64) std.time.Instant {
+ return switch (builtin.os.tag) {
+ .windows, .uefi, .wasi => .{ .timestamp = ns },
+ else => .{ .timestamp = .{
+ .sec = @intCast(ns / std.time.ns_per_s),
+ .nsec = @intCast(ns % std.time.ns_per_s),
+ } },
+ };
+}
+
fn testDrag(t: *Terminal, x: u16, y: u32, xpos: f64, ypos: f64) Drag {
return .{
.pin = t.screens.active.pages.pin(.{ .active = .{
@@ -1532,6 +1569,58 @@ test "SelectionGesture drag returns selection and records autoscroll" {
try testing.expectEqual(.down, gesture.left_drag_autoscroll);
}
+test "SelectionGesture drag clamps unrepresentable positions" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
+ defer t.deinit(testing.allocator);
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ var press_event = testPress(&t, 1, 1, try std.time.Instant.now());
+ press_event.xpos = 10;
+ _ = try gesture.press(&t, press_event);
+
+ const positive = gesture.drag(
+ &t,
+ testDrag(&t, 1, 1, std.math.inf(f64), 50),
+ ).?;
+ try testing.expect((testPin(&t, 1, 1)).eql(positive.start()));
+ try testing.expect((testPin(&t, 1, 1)).eql(positive.end()));
+
+ try testing.expectEqual(null, gesture.drag(
+ &t,
+ testDrag(&t, 1, 1, std.math.nan(f64), 50),
+ ));
+}
+
+test "SelectionGesture drag saturates overflowing geometry" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
+ defer t.deinit(testing.allocator);
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now()));
+ var drag_event = testDrag(&t, 1, 1, 10, 50);
+ drag_event.geometry.columns = std.math.maxInt(u32);
+ drag_event.geometry.cell_width = std.math.maxInt(u32);
+ try testing.expectEqual(null, gesture.drag(&t, drag_event));
+}
+
+test "SelectionGesture drag rejects empty geometry" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
+ defer t.deinit(testing.allocator);
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ _ = try gesture.press(&t, testPress(&t, 1, 1, try std.time.Instant.now()));
+ var drag_event = testDrag(&t, 1, 1, 10, 50);
+ drag_event.geometry.columns = 0;
+ drag_event.geometry.cell_width = 0;
+ try testing.expectEqual(null, gesture.drag(&t, drag_event));
+}
+
test "SelectionGesture release clears autoscroll and records drag" {
var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
@@ -1965,6 +2054,22 @@ test "SelectionGesture expired repeat resets click count" {
try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
}
+test "SelectionGesture backwards repeat time resets click count" {
+ var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
+ defer t.deinit(testing.allocator);
+
+ var gesture: SelectionGesture = .init;
+ defer gesture.deinit(&t);
+
+ const earlier = testInstant(std.time.ns_per_s);
+ const later = testInstant(2 * std.time.ns_per_s);
+ _ = try gesture.press(&t, testPress(&t, 1, 1, later));
+ _ = try gesture.press(&t, testPress(&t, 1, 1, earlier));
+
+ try testing.expectEqual(@as(u3, 1), gesture.left_click_count);
+ try testing.expectEqual(.eq, gesture.left_click_time.?.order(earlier));
+}
+
test "SelectionGesture screen switch resets click count" {
var t = try Terminal.init(testing.allocator, .{ .cols = 5, .rows = 5 });
defer t.deinit(testing.allocator);
diff --git a/vendor/libghostty-vt/src/terminal/Tabstops.zig b/vendor/libghostty-vt/src/terminal/Tabstops.zig
index 68138cbf..8dbe6789 100644
--- a/vendor/libghostty-vt/src/terminal/Tabstops.zig
+++ b/vendor/libghostty-vt/src/terminal/Tabstops.zig
@@ -170,11 +170,11 @@ pub fn reset(self: *Tabstops, interval: usize) void {
@memset(&self.prealloc_stops, 0);
@memset(self.dynamic_stops, 0);
- if (interval > 0) {
- var i: usize = interval;
- while (i < self.cols - 1) : (i += interval) {
- self.set(i);
- }
+ if (interval == 0 or self.cols <= 1) return;
+
+ var i: usize = interval;
+ while (i < self.cols - 1) : (i += interval) {
+ self.set(i);
}
}
@@ -230,6 +230,13 @@ test "Tabstops: interval" {
try testing.expect(t.get(8));
}
+test "Tabstops: interval with zero columns" {
+ var t: Tabstops = try init(testing.allocator, 0, 8);
+ defer t.deinit(testing.allocator);
+
+ try testing.expectEqual(@as(usize, 0), t.cols);
+}
+
test "Tabstops: count on 80" {
// https://superuser.com/questions/710019/why-there-are-11-tabstops-on-a-80-column-console
diff --git a/vendor/libghostty-vt/src/terminal/Terminal.zig b/vendor/libghostty-vt/src/terminal/Terminal.zig
index d41e4750..08920127 100644
--- a/vendor/libghostty-vt/src/terminal/Terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/Terminal.zig
@@ -17,6 +17,7 @@ const modespkg = @import("modes.zig");
const charsets = @import("charsets.zig");
const csi = @import("csi.zig");
const hyperlink = @import("hyperlink.zig");
+const glyph = @import("apc/glyph.zig");
const kitty = @import("kitty.zig");
const osc = @import("osc.zig");
const point = @import("point.zig");
@@ -29,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;
@@ -81,6 +83,9 @@ modes: modespkg.ModeState = .{},
/// The most recently set mouse shape for the terminal.
mouse_shape: mouse.Shape = .text,
+/// Per-session Glyph Protocol registrations.
+glyph_glossary: glyph.Glossary = .empty,
+
/// These are just a packed set of flags we may set on the terminal.
flags: packed struct {
// This supports a Kitty extension where programs using semantic
@@ -146,6 +151,49 @@ pub const Colors = struct {
};
};
+/// Returns the current color for an xterm OSC color target.
+///
+/// Unsupported dynamic and special colors return null. The cursor color
+/// follows xterm-style reporting and falls back to the foreground color when
+/// no explicit cursor color is set.
+pub fn colorForXterm(self: *const Terminal, target: osc.color.Target) ?color.RGB {
+ return switch (target) {
+ .palette => |i| self.colors.palette.current[i],
+ .dynamic => |dynamic| switch (dynamic) {
+ .foreground => self.colors.foreground.get(),
+ .background => self.colors.background.get(),
+ .cursor => self.colors.cursor.get() orelse
+ self.colors.foreground.get(),
+ .pointer_foreground,
+ .pointer_background,
+ .tektronix_foreground,
+ .tektronix_background,
+ .highlight_background,
+ .tektronix_cursor,
+ .highlight_foreground,
+ => null,
+ },
+ .special => null,
+ };
+}
+
+/// Returns the current color for a Kitty color protocol key.
+///
+/// Only palette, foreground, background, and cursor colors are backed by
+/// Terminal state. Unsupported keys, or supported dynamic colors without a
+/// value, return null.
+pub fn colorForKitty(self: *const Terminal, key: kitty.color.Kind) ?color.RGB {
+ return switch (key) {
+ .palette => |palette| self.colors.palette.current[palette],
+ .special => |special| switch (special) {
+ .foreground => self.colors.foreground.get(),
+ .background => self.colors.background.get(),
+ .cursor => self.colors.cursor.get(),
+ else => null,
+ },
+ };
+}
+
/// This is a set of dirty flags the renderer can use to determine
/// what parts of the screen need to be redrawn. It is up to the renderer
/// to clear these flags.
@@ -165,6 +213,11 @@ pub const Dirty = packed struct {
/// Set when the pre-edit is modified.
preedit: bool = false,
+
+ /// Set when Glyph Protocol registrations may have changed. Registered
+ /// glyphs can affect already-visible PUA cells, so this requires a full
+ /// render-state rebuild.
+ glyph_glossary: bool = false,
};
/// Scrolling region is the area of the screen designated where scrolling
@@ -256,6 +309,7 @@ pub fn deinit(self: *Terminal, alloc: Allocator) void {
self.screens.deinit(alloc);
self.pwd.deinit(alloc);
self.title.deinit(alloc);
+ self.glyph_glossary.deinit(alloc);
self.* = undefined;
}
@@ -311,6 +365,520 @@ pub fn printRepeat(self: *Terminal, count_req: usize) !void {
}
}
+/// Print multiple codepoints to the terminal at once. This is
+/// semantically identical to calling `print` for each codepoint in
+/// order, but is much faster because it can batch cell writes and
+/// hoist per-codepoint checks out of the hot loop.
+///
+/// The codepoints must all be printable: it is illegal for any
+/// codepoint in this slice to be a C0 control character. Therefore,
+/// this should only be called as a result of a proper VT parser
+/// (like our own).
+///
+/// This is optimized for the common case: ASCII, soft-wrap, etc.
+/// Sequences of codepoints that require special handling (e.g. wide characters,
+/// grapheme clustering) are handled correctly but fall back to the
+/// slower per-codepoint path. They're less common and this is optimized
+/// for the aforementioned cases.
+pub fn printSlice(self: *Terminal, cps: []const u32) !void {
+ var i: usize = 0;
+ while (i < cps.len) {
+ // Try the fast-path print first. This will return the number of
+ // codepoints it consumed.
+ const consumed = try self.printSliceFast(cps[i..]);
+ if (consumed > 0) {
+ i += consumed;
+ continue;
+ }
+
+ // Consuming zero bytes means that the fast path can't handle
+ // the next codepoint or the terminal is in a state we can't
+ // fast-path. Fall back to the slow cp-by-cp print then try
+ // fast paths again.
+ try self.print(@intCast(cps[i]));
+ i += 1;
+ }
+}
+
+/// Attempt to print a prefix of `cps` using a batched fast path that
+/// writes cells directly. Returns the number of codepoints consumed.
+/// A return value of zero means the caller must print the first
+/// codepoint via the normal `print` path.
+///
+/// The fast path handles runs of narrow (width 1) and wide (width 2)
+/// codepoints being written to simple cells. Everything else (zero
+/// width codepoints, grapheme cluster continuations, insert mode,
+/// charset mapping, hyperlinks, complex cells, etc.) is rejected so
+/// `print` can handle it with full generality.
+fn printSliceFast(self: *Terminal, cps: []const u32) !usize {
+ // Only the main display is supported.
+ if (self.status_display != .main) return 0;
+
+ // Modes that require per-codepoint handling in print(). Wraparound
+ // is required (its the default) so that our row-fill logic below can
+ // assume soft-wrap semantics. Insert mode shifts cells per print.
+ if (self.modes.get(.insert)) return 0;
+ if (!self.modes.get(.wraparound)) return 0;
+
+ const screen: *Screen = self.screens.active;
+
+ // Charset must map ASCII as-is (true unless a DEC special charset
+ // is actively invoked, which is rare).
+ if (screen.charset.single_shift != null) return 0;
+ switch (screen.charset.charsets.get(screen.charset.gl)) {
+ .utf8, .ascii => {},
+ else => return 0,
+ }
+
+ // Hyperlinks require per-cell map bookkeeping.
+ if (screen.cursor.hyperlink_id != 0) return 0;
+
+ // Codepoints in [0x10, 0xFF] are always narrow (width 1, matching
+ // the c <= 0xFF fast path in print) and can never interact with
+ // grapheme clustering (which requires a codepoint > 0xFF).
+ //
+ // Codepoints above 0xFF are batchable if their width is 1 or 2
+ // (excluding zero-width characters such as combining marks, ZWJ,
+ // and variation selectors) and, when grapheme clustering (mode
+ // 2027) is enabled, if they are a grapheme break from the
+ // previously printed codepoint (so print would never attach them
+ // to the previous cell).
+ const grapheme_cluster = self.modes.get(.grapheme_cluster);
+
+ // When grapheme clustering is enabled and a left margin is set,
+ // print() consults the cell left of the margin after wrapping,
+ // which we can't reason about here. Restrict the fast path to
+ // the [0x10, 0xFF] range in that case (those never cluster).
+ const allow_unicode = !grapheme_cluster or self.scrolling_region.left == 0;
+
+ // Codepoints in [0x10, 0xFF] are always narrow: print()
+ // hardcodes width 1 for c <= 0xFF (no width table lookup).
+ // They also can never interact with grapheme clustering,
+ // which print() only performs for c > 0xFF, so they're
+ // immediately eligible for the narrow fill with no further
+ // checks.
+ const cp0 = cps[0];
+ if (cp0 <= 0xFF) {
+ // C0 control characters (0x00-0x0F) aren't printable. The
+ // stream never sends these (they're routed to execute), but
+ // printSlice is a public API so defer to print() for safety.
+ if (cp0 < 0x10) return 0;
+ return self.printSliceFill(
+ .narrow,
+ cps,
+ grapheme_cluster,
+ allow_unicode,
+ );
+ }
+
+ if (!allow_unicode) return 0;
+ if (comptime build_options.kitty_graphics) {
+ // The Kitty graphics placeholder requires row bookkeeping.
+ if (cp0 == kitty.graphics.unicode.placeholder) return 0;
+ }
+
+ // The first codepoint requires care when grapheme clustering is
+ // enabled: print() examines the previous *cell* which can hold
+ // state (grapheme data) that we can't cheaply reason about here.
+ // Note this includes the pending-wrap state: print() may attach
+ // to the pending cell *instead of wrapping*. We only take the
+ // first codepoint if the cursor is at column zero with no pending
+ // wrap, where print() skips clustering entirely.
+ if (grapheme_cluster) {
+ if (screen.cursor.pending_wrap or screen.cursor.x != 0) return 0;
+ }
+
+ // The width lookup is a runtime value while printSliceFill is
+ // specialized at comptime by width class, so this switch selects
+ // between the two instantiations rather than passing the width
+ // through as an argument.
+ return switch (unicode.table.get(@intCast(cp0)).width) {
+ 1 => self.printSliceFill(
+ .narrow,
+ cps,
+ grapheme_cluster,
+ allow_unicode,
+ ),
+ 2 => self.printSliceFill(
+ .wide,
+ cps,
+ grapheme_cluster,
+ allow_unicode,
+ ),
+ else => 0,
+ };
+}
+
+/// The width class of a printSlice batch. Each batch contains only
+/// codepoints of a single width class because they fill cells
+/// differently: wide codepoints occupy a (wide, spacer_tail) cell
+/// pair while narrow codepoints occupy a single cell.
+const PrintSliceWidth = enum(u1) {
+ narrow,
+ wide,
+
+ /// The number of cells each codepoint of this width class occupies.
+ fn cellsPerCp(comptime self: PrintSliceWidth) usize {
+ return switch (self) {
+ .narrow => 1,
+ .wide => 2,
+ };
+ }
+};
+
+/// Whether a codepoint above 0xFF is eligible for the batched print
+/// fast path with the given width class.
+inline fn printSliceEligible(cp: u32, comptime width: PrintSliceWidth) bool {
+ assert(cp > 0xFF);
+ if (comptime build_options.kitty_graphics) {
+ if (cp == kitty.graphics.unicode.placeholder) return false;
+ }
+
+ return unicode.table.get(@intCast(cp)).width == comptime @as(u2, switch (width) {
+ .narrow => 1,
+ .wide => 2,
+ });
+}
+
+/// The row-filling portion of the printSlice fast path, specialized by
+/// width class. The first codepoint must already be validated by the
+/// caller (printSliceFast).
+fn printSliceFill(
+ self: *Terminal,
+ comptime width: PrintSliceWidth,
+ cps: []const u32,
+ grapheme_cluster: bool,
+ allow_unicode: bool,
+) !usize {
+ const screen: *Screen = self.screens.active;
+
+ // Our fast path can only handle "simple" cells. A simple cell is
+ // a codepoint cell (no grapheme data or bg-color tag), narrow, and
+ // not a hyperlink. The mask covers every field that must match
+ // the expected value (see printSliceCheckExpected) exactly.
+ const SimpleMask = pagepkg.Mask(Cell, &.{
+ "content_tag",
+ "style_id",
+ "wide",
+ "hyperlink",
+ }, 4);
+
+ // The bit offset of the codepoint content within a Cell, used to
+ // construct cell values from a template without field assignments.
+ const cp_shift = @bitOffsetOf(Cell, "content");
+
+ // Determine the run of codepoints in the same width class that we
+ // can batch. For codepoints after the first, the previous codepoint
+ // in the run is always written as a fresh, single-codepoint cell,
+ // so the grapheme break check against it is exact.
+ const run_len: usize = run: {
+ var idx: usize = 1;
+
+ // Vectorized scan for the narrow class: codepoints in
+ // [0x10, 0xFF] are always eligible with no further checks
+ // and dominate real-world input, so scan for the first
+ // codepoint outside that range several lanes at a time.
+ // Anything else (including eligible unicode) proceeds via
+ // the scalar loop below.
+ if (comptime width == .narrow) {
+ const lanes = 8;
+ const V = @Vector(lanes, u32);
+ const lo: V = @splat(0x10);
+ const hi: V = @splat(0xFF);
+ while (idx + lanes <= cps.len) {
+ const v: V = cps[idx..][0..lanes].*;
+ const in_range = (v >= lo) & (v <= hi);
+ if (!@reduce(.And, in_range)) {
+ const bits: std.meta.Int(.unsigned, lanes) = @bitCast(in_range);
+ idx += @ctz(~bits);
+ break;
+ }
+ idx += lanes;
+ }
+ }
+
+ while (idx < cps.len) : (idx += 1) {
+ const cp = cps[idx];
+ if (comptime width == .narrow) {
+ if (cp >= 0x10 and cp <= 0xFF) continue;
+ }
+ if (cp > 0xFF and allow_unicode and printSliceEligible(cp, width)) {
+ if (!grapheme_cluster) continue;
+ var state: uucode.grapheme.BreakState = .default;
+ if (unicode.graphemeBreak(@intCast(cps[idx - 1]), @intCast(cp), &state)) continue;
+ }
+ break :run idx;
+ }
+ break :run cps.len;
+ };
+ assert(run_len > 0);
+
+ // After doing any printing, wrapping, scrolling, etc. we want to
+ // ensure that our screen remains in a consistent state.
+ defer screen.assertIntegrity();
+
+ // The number of cells each codepoint occupies.
+ const cells_per_cp: usize = comptime width.cellsPerCp();
+
+ var printed: usize = 0;
+ outer: while (printed < run_len) {
+ // If we're soft-wrapping, handle that first so that our cursor
+ // is in the row/column that will receive the next codepoint.
+ if (screen.cursor.pending_wrap) try self.printWrap();
+
+ // Our right margin depends on where our cursor is now,
+ // matching the logic in print().
+ const right_limit: usize = if (screen.cursor.x > self.scrolling_region.right)
+ self.cols
+ else
+ self.scrolling_region.right + 1;
+
+ // A degenerate 1-wide region can't hold a wide char; print()
+ // has special handling so fall back to it.
+ if (comptime width == .wide) {
+ if (right_limit - self.scrolling_region.left <= 1) break;
+ }
+
+ const cursor = &screen.cursor;
+ const avail: usize = right_limit - cursor.x;
+ assert(avail > 0);
+
+ // 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 = .{
+ .content_tag = .codepoint,
+ .content = .{ .codepoint = 0 },
+ .style_id = style_id,
+ .wide = .narrow,
+ .protected = cursor.protected,
+ .semantic_content = cursor.semantic_content,
+ };
+ const template_bits: u64 = @bitCast(template);
+ const check_expected: u64 = printSliceCheckExpected(style_id);
+
+ if (comptime width == .wide) {
+ if (avail == 1) {
+ // Only one cell left in the row: print() writes a
+ // spacer head (or a blank narrow cell if we're inside
+ // a right margin) and wraps. We require a simple cell,
+ // otherwise fall back to print() for the cleanup.
+ if (!SimpleMask.eqlScalar(cells[0], check_expected)) break;
+
+ var spacer = template;
+ if (right_limit == self.cols) {
+ cursor.page_row.wrap = true;
+ spacer.wide = .spacer_head;
+ }
+ cursor.page_row.dirty = true;
+ if (style_id != style.default_id) cursor.page_row.styled = true;
+ cells[0] = spacer;
+ try self.printWrap();
+ continue :outer;
+ }
+ }
+
+ // Number of codepoints and cells we're writing to this row.
+ const count = @min(avail / cells_per_cp, run_len - printed);
+ assert(count > 0);
+ const cell_count = count * cells_per_cp;
+
+ // Wide cells always come in (wide, spacer_tail) pairs.
+ const spacer_bits: u64 = if (comptime width == .wide) spacer: {
+ var spacer = template;
+ spacer.wide = .spacer_tail;
+ break :spacer @bitCast(spacer);
+ } else undefined;
+ const wide_bits: u64 = if (comptime width == .wide) wb: {
+ var w = template;
+ w.wide = .wide;
+ break :wb @bitCast(w);
+ } else undefined;
+
+ var k: usize = 0; // cells written
+ fill: while (k < cell_count) {
+ // Find the run of simple cells so the store loop below is
+ // branch-free (and vectorizable). This is an early-exit
+ // search loop that LLVM won't auto-vectorize, and reused
+ // rows typically match the whole way through, so scan
+ // several cells at a time manually.
+ var simple = k;
+ simple: {
+ while (simple + SimpleMask.group_len <= cell_count) {
+ const p = SimpleMask.eqlPrefix(
+ cells[0..cell_count],
+ simple,
+ check_expected,
+ );
+ simple += p;
+ if (p != SimpleMask.group_len) break :simple;
+ }
+ while (simple < cell_count) : (simple += 1) {
+ if (!SimpleMask.eqlScalar(
+ cells[simple],
+ check_expected,
+ )) break;
+ }
+ }
+
+ if (comptime width == .wide) {
+ // We can only write whole (wide, spacer) pairs.
+ const pair_end = k + (simple - k) / 2 * 2;
+ var idx = k;
+ while (idx < pair_end) : (idx += 2) {
+ cells[idx] = @bitCast(
+ wide_bits | (@as(u64, cps[printed + idx / 2]) << cp_shift),
+ );
+ cells[idx + 1] = @bitCast(spacer_bits);
+ }
+ // If the simple run ended mid-pair we stop at the pair
+ // boundary and handle the offending cell below.
+ k = pair_end;
+ if (simple != pair_end) {
+ // The first cell of the next pair is simple but the
+ // second isn't; handle both via the general path.
+ simple = pair_end;
+ }
+ } else {
+ for (k..simple) |idx| {
+ cells[idx] = @bitCast(
+ template_bits | (@as(u64, cps[printed + idx]) << cp_shift),
+ );
+ }
+ k = simple;
+ }
+ if (k >= cell_count) break;
+
+ // Bulk path for runs of cells that differ from the
+ // expected simple cell only by their style: this is the
+ // common case when styled text overwrites previously
+ // styled (or default-styled) rows, e.g. TUI redraws.
+ // These runs are handled wholesale: one scan to find the
+ // run of identical old styles, two ref-count updates,
+ // and a branch-free fill.
+ if (comptime width == .narrow) bulk: {
+ const first = SimpleMask.pattern(cells[k]);
+
+ // The old cell must be a plain narrow codepoint cell
+ // with no hyperlink whose only difference is the
+ // style id (see printSliceCheckExpected: every other
+ // masked field must be zero).
+ const style_shift = @bitOffsetOf(Cell, "style_id");
+ const old_style: style.Id = @truncate(first >> style_shift);
+ if (first != printSliceCheckExpected(old_style)) break :bulk;
+ assert(old_style != style_id); // it failed the simple check
+
+ // Find the run of cells with identical masked bits.
+ var m = k + 1;
+ scan: {
+ while (m + SimpleMask.group_len <= cell_count) {
+ const p = SimpleMask.eqlPrefix(
+ cells[0..cell_count],
+ m,
+ first,
+ );
+ m += p;
+ if (p != SimpleMask.group_len) break :scan;
+ }
+ while (m < cell_count) : (m += 1) {
+ if (!SimpleMask.eqlScalar(cells[m], first)) break;
+ }
+ }
+
+ // Fix up the style ref counts for the whole run at
+ // once. Each of the old cells held a reference to
+ // old_style so the release is safe by construction.
+ const n = m - k;
+ if (old_style != style.default_id) {
+ page.styles.releaseMultiple(page.memory, old_style, @intCast(n));
+ }
+ if (style_id != style.default_id) {
+ page.styles.useMultiple(page.memory, style_id, @intCast(n));
+ }
+
+ for (k..m) |idx| {
+ cells[idx] = @bitCast(
+ template_bits | (@as(u64, cps[printed + idx]) << cp_shift),
+ );
+ }
+ k = m;
+ continue :fill;
+ }
+
+ // General path for cells that failed the masked check:
+ // style-only mismatches are handled inline; anything that
+ // needs cleanup (wide chars and their spacers, grapheme
+ // data, hyperlinks) falls back to print().
+ const general_count: usize = cells_per_cp;
+ for (0..general_count) |offset| {
+ const cell = &cells[k + offset];
+ if (cell.wide != .narrow or
+ cell.hasGrapheme() or
+ cell.hyperlink) break :fill;
+ }
+ for (0..general_count) |offset| {
+ const cell = &cells[k + offset];
+ if (cell.style_id != style_id) {
+ if (cell.style_id != style.default_id) {
+ page.styles.release(page.memory, cell.style_id);
+ }
+ if (style_id != style.default_id) {
+ page.styles.use(page.memory, style_id);
+ }
+ }
+ }
+ if (comptime width == .wide) {
+ cells[k] = @bitCast(
+ wide_bits | (@as(u64, cps[printed + k / 2]) << cp_shift),
+ );
+ cells[k + 1] = @bitCast(spacer_bits);
+ } else {
+ cells[k] = @bitCast(
+ template_bits | (@as(u64, cps[printed + k]) << cp_shift),
+ );
+ }
+ k += cells_per_cp;
+ }
+
+ if (k > 0) {
+ assert(k % cells_per_cp == 0);
+ cursor.page_row.dirty = true;
+ if (style_id != style.default_id) cursor.page_row.styled = true;
+ self.previous_char = @intCast(cps[printed + k / cells_per_cp - 1]);
+ printed += k / cells_per_cp;
+
+ // Advance the cursor. If we filled through the right limit
+ // then the cursor stays on the last cell with the pending
+ // wrap flag set, matching print().
+ if (cursor.x + k >= right_limit) {
+ assert(cursor.x + k == right_limit);
+ screen.cursorRight(@intCast(k - 1));
+ cursor.pending_wrap = true;
+ } else {
+ screen.cursorRight(@intCast(k));
+ }
+ }
+
+ // We hit a cell that requires the slow path. The cursor is
+ // exactly at that cell so return and let the caller print the
+ // next codepoint via print().
+ if (k < cell_count) break;
+ }
+
+ return printed;
+}
+
+/// The expected value of a simple cell (per SimpleMask in
+/// printSliceFill) that already has the given style (so no
+/// ref-counting is needed).
+inline fn printSliceCheckExpected(style_id: style.Id) u64 {
+ var e: Cell = @bitCast(@as(u64, 0));
+ e.style_id = style_id;
+ return @bitCast(e);
+}
+
pub fn print(self: *Terminal, c: u21) !void {
// log.debug("print={x} y={} x={}", .{ c, self.screens.active.cursor.y, self.screens.active.cursor.x });
@@ -381,10 +949,16 @@ 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));
+ // With mode 2027 disabled, zero-width codepoints are
+ // attached without applying grapheme boundary rules. If
+ // the mode is enabled later, an existing cell can
+ // therefore contain one or more breaks. Feed those breaks
+ // into the state machine so it can reset its context and
+ // determine the boundary for the new codepoint.
+ _ = unicode.graphemeBreak(previous_codepoint, cp2, &state);
previous_codepoint = cp2;
}
}
@@ -396,33 +970,8 @@ pub fn print(self: *Terminal, c: u21) !void {
// If we can NOT break, this means that "c" is part of a grapheme
// with the previous char.
if (!grapheme_break) {
- var desired_wide: enum { no_change, wide, narrow } = .no_change;
-
- // If this is an emoji variation selector then we need to modify
- // the cell width accordingly. VS16 makes the character wide and
- // VS15 makes it narrow.
- if (c == 0xFE0F or c == 0xFE0E) {
- const prev_props = unicode.table.get(previous_codepoint);
- // Check if it is a valid variation sequence in
- // emoji-variation-sequences.txt, and if not, ignore the char.
- if (!prev_props.emoji_vs_base) return;
-
- switch (c) {
- 0xFE0F => desired_wide = .wide,
- 0xFE0E => desired_wide = .narrow,
- else => unreachable,
- }
- } else if (!unicode.table.get(c).width_zero_in_grapheme) {
- // If we have a code point that contributes to the width of a
- // grapheme, it necessarily means that we're at least at width
- // 2, since the first code point must be at least width 1 to
- // start. (Note that Prepend code points could effectively mean
- // the first code point should be width 0, but we don't handle
- // that yet.)
- desired_wide = .wide;
- }
-
- switch (desired_wide) {
+ switch (unicode.graphemeWidthEffect(previous_codepoint, c)) {
+ .ignore => return,
.wide => wide: {
if (prev.cell.wide == .wide) break :wide;
@@ -463,19 +1012,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
@@ -539,7 +1088,7 @@ pub fn print(self: *Terminal, c: u21) !void {
break :narrow;
},
- else => {},
+ .no_change => {},
}
log.debug("c={X} grapheme attach to left={} primary_cp={X}", .{
@@ -742,7 +1291,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],
);
@@ -768,7 +1317,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],
);
@@ -791,7 +1340,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);
}
@@ -800,7 +1349,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) {
@@ -823,7 +1372,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) {
@@ -852,7 +1401,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);
}
@@ -1216,10 +1765,8 @@ pub fn semanticPrompt(
// within a prompt area to SGR mouse events and defers to the
// shell to handle them.
if (cmd.readOption(.click_events)) |v| {
- if (v) {
- screen.semantic_prompt.click = .click_events;
- break :click;
- }
+ screen.semantic_prompt.click = .{ .click_events = v };
+ break :click;
}
// If click_events was not set or disabled, fallback to `cl`.
@@ -1479,10 +2026,19 @@ pub fn index(self: *Terminal) !void {
screen.kitty_images.dirty = true;
}
- // If our scrolling region is at the top, we create scrollback.
+ // If our scrolling region is at the top, we create scrollback,
+ // but only if our screen retains scrollback. If our screen
+ // doesn't retain scrollback (e.g. the alternate screen) then
+ // creating scrollback is pure overhead: the rows are never
+ // visible and are simply pruned later. In that case we use the
+ // in-place region scroll below, unless the region is a single
+ // row (a one row screen) which cursorScrollRegionUp can't
+ // handle (and cursorDownScroll special-cases).
if (self.scrolling_region.top == 0 and
self.scrolling_region.left == 0 and
- self.scrolling_region.right == self.cols - 1)
+ self.scrolling_region.right == self.cols - 1 and
+ (!screen.no_scrollback or
+ self.scrolling_region.bottom == 0))
{
try screen.cursorScrollAbove();
return;
@@ -1490,49 +2046,18 @@ pub fn index(self: *Terminal) !void {
// Slow path for left and right scrolling region margins.
if (self.scrolling_region.left != 0 or
- self.scrolling_region.right != self.cols - 1 or
-
- // PERF(mitchellh): If we have an SGR background set then
- // we need to preserve that background in our erased rows.
- // scrollUp does that but eraseRowBounded below does not.
- // However, scrollUp is WAY slower. We should optimize this
- // case to work in the eraseRowBounded codepath and remove
- // this check.
- !screen.blankCell().isZero())
+ self.scrolling_region.right != self.cols - 1)
{
try self.scrollUp(1);
return;
}
- // Otherwise use a fast path function from PageList to efficiently
- // scroll the contents of the scrolling region.
-
- // Preserve old cursor just for assertions
- const old_cursor = screen.cursor;
-
- try screen.pages.eraseRowBounded(
- .{ .active = .{ .y = self.scrolling_region.top } },
+ // Otherwise use a fast path function to efficiently scroll
+ // the contents of the scrolling region.
+ try screen.cursorScrollRegionUp(
self.scrolling_region.bottom - self.scrolling_region.top,
);
- // eraseRow and eraseRowBounded will end up moving the cursor pin
- // up by 1, so we need to move it back down. A `cursorReload`
- // would be better option but this is more efficient and this is
- // a super hot path so we do this instead.
- assert(screen.cursor.x == old_cursor.x);
- assert(screen.cursor.y == old_cursor.y);
- screen.cursor.y -= 1;
- screen.cursorDown(1);
-
- // The operations above can prune our cursor style so we need to
- // update. This should never fail because the above can only FREE
- // memory.
- screen.manualStyleUpdate() catch |err| {
- std.log.warn("deleteLines manualStyleUpdate err={}", .{err});
- screen.cursor.style = .{};
- screen.manualStyleUpdate() catch unreachable;
- };
-
return;
}
@@ -1601,8 +2126,8 @@ pub fn setCursorPos(self: *Terminal, row_req: usize, col_req: usize) void {
// Calculate our new x/y
const row = if (row_req == 0) 1 else row_req;
const col = if (col_req == 0) 1 else col_req;
- const x = @min(params.x_max, col + params.x_offset) -| 1;
- const y = @min(params.y_max, row + params.y_offset) -| 1;
+ const x = @min(params.x_max, col +| params.x_offset) -| 1;
+ const y = @min(params.y_max, row +| params.y_offset) -| 1;
// If the y is unchanged then this is fast pointer math
if (y == self.screens.active.cursor.y) {
@@ -1691,9 +2216,17 @@ pub fn scrollUp(self: *Terminal, count: usize) !void {
// If our scroll region is at the top and we have no left/right
// margins then we move the scrolled out text into the scrollback.
+ //
+ // If our screen doesn't retain scrollback (e.g. the alternate
+ // screen) then creating scrollback is pure overhead, so we use the
+ // deleteLines path below instead, unless the region is the full
+ // screen where cursorScrollAbove has a specialized fast path
+ // (cursorDownScroll) for scrolling without scrollback.
if (self.scrolling_region.top == 0 and
self.scrolling_region.left == 0 and
- self.scrolling_region.right == self.cols - 1)
+ self.scrolling_region.right == self.cols - 1 and
+ (!self.screens.active.no_scrollback or
+ self.scrolling_region.bottom == self.rows - 1))
{
// Scrolling dirties the images because it updates their placements pins.
if (comptime build_options.kitty_graphics) {
@@ -1731,10 +2264,18 @@ pub const ScrollViewport = union(Tag) {
/// Scroll by some delta amount, up is negative.
delta: isize,
+ /// Scroll to the given absolute row offset from the top of the
+ /// scrollable area. A value of zero is the top row. The requested
+ /// row becomes the first visible row of the viewport, clamped so
+ /// the viewport never scrolls beyond the top of the active area.
+ /// This is the same row space as PageList.Scrollbar offset.
+ row: usize,
+
pub const Tag = lib.Enum(lib.target, &.{
"top",
"bottom",
"delta",
+ "row",
});
const c_union = lib.TaggedUnion(
@@ -1755,9 +2296,80 @@ pub fn scrollViewport(self: *Terminal, behavior: ScrollViewport) void {
.top => .{ .top = {} },
.bottom => .{ .active = {} },
.delta => |delta| .{ .delta_row = delta },
+ .row => |row| .{ .row = row },
});
}
+/// 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
@@ -1817,6 +2429,21 @@ fn rowWillBeShifted(
}
}
+/// Renew every live page generation in an inclusive range before a full-width
+/// line operation moves logical rows between their coordinates.
+fn invalidateFullWidthRowRange(
+ self: *Terminal,
+ first: *PageList.List.Node,
+ last: *PageList.List.Node,
+) void {
+ var node = first;
+ while (true) : (node = node.next.?) {
+ // Full-width line movement remaps cached row coordinates in this page.
+ self.screens.active.pages.invalidateNodeLayout(node);
+ if (node == last) break;
+ }
+}
+
// TODO(qwerasd): `insertLines` and `deleteLines` are 99% identical,
// the majority of their logic can (and should) be abstracted in to
// a single shared helper function, probably on `Screen` not here.
@@ -1892,6 +2519,12 @@ pub fn insertLines(self: *Terminal, count: usize) void {
};
defer self.screens.active.pages.untrackPin(cur_p);
+ // Partial-width margins edit cells in stable rows; full-width moves rows.
+ if (!left_right) self.invalidateFullWidthRowRange(
+ self.screens.active.cursor.page_pin.node,
+ cur_p.node,
+ );
+
// Our current y position relative to the cursor
var y: usize = rem;
@@ -1906,8 +2539,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) {
@@ -1925,8 +2558,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,
@@ -1989,11 +2622,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,
@@ -2005,8 +2638,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,
@@ -2087,6 +2720,12 @@ pub fn deleteLines(self: *Terminal, count: usize) void {
};
defer self.screens.active.pages.untrackPin(cur_p);
+ // Partial-width margins edit cells in stable rows; full-width moves rows.
+ if (!left_right) self.invalidateFullWidthRowRange(
+ cur_p.node,
+ cur_p.down(rem - 1).?.node,
+ );
+
// Our current y position relative to the cursor
var y: usize = 0;
@@ -2101,8 +2740,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) {
@@ -2120,8 +2759,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,
@@ -2177,11 +2816,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,
@@ -2193,8 +2832,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,
@@ -2248,7 +2887,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.
@@ -2331,7 +2970,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;
@@ -2408,7 +3047,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],
);
@@ -2416,7 +3055,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],
);
@@ -2488,7 +3127,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],
);
@@ -2496,7 +3135,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],
);
@@ -2670,7 +3309,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];
@@ -2720,6 +3359,22 @@ pub fn kittyGraphics(
return kitty.graphics.execute(alloc, self, cmd);
}
+/// Execute a Glyph Protocol APC command against this terminal's per-session
+/// glossary. The returned response, if any, should be sent back to the pty as
+/// a complete APC sequence via `Response.formatWire`.
+pub fn glyphProtocol(
+ self: *Terminal,
+ alloc: Allocator,
+ req: *const glyph.Request,
+) ?glyph.Response {
+ const resp = glyph.execute(alloc, &self.glyph_glossary, req);
+ switch (req.*) {
+ .register, .clear => self.flags.dirty.glyph_glossary = true,
+ .support, .query => {},
+ }
+ return resp;
+}
+
/// Set the storage size limit for Kitty graphics across all screens.
pub fn setKittyGraphicsSizeLimit(
self: *Terminal,
@@ -2890,8 +3545,13 @@ pub fn resize(
// Resize our tabstops
if (self.cols != cols) {
+ const tabstops: Tabstops = try .init(
+ alloc,
+ cols,
+ TABSTOP_INTERVAL,
+ );
self.tabstops.deinit(alloc);
- self.tabstops = try .init(alloc, cols, 8);
+ self.tabstops = tabstops;
}
// Resize primary screen, which supports reflow
@@ -2926,13 +3586,33 @@ pub fn resize(
};
}
+test "Terminal: resize preserves tabstops on allocation failure" {
+ var failing = testing.FailingAllocator.init(testing.allocator, .{});
+ const alloc = failing.allocator();
+ var t = try init(alloc, .{ .cols = 10, .rows = 1 });
+ defer t.deinit(alloc);
+
+ failing.fail_index = failing.alloc_index;
+ try testing.expectError(error.OutOfMemory, t.resize(alloc, 513, 1));
+
+ try testing.expectEqual(@as(size.CellCountInt, 10), t.cols);
+ try testing.expect(t.tabstops.get(8));
+}
+
/// Set the pwd for the terminal.
pub fn setPwd(self: *Terminal, pwd: []const u8) !void {
- self.pwd.clearRetainingCapacity();
- if (pwd.len > 0) {
- try self.pwd.appendSlice(self.gpa(), pwd);
- try self.pwd.append(self.gpa(), 0);
+ if (pwd.len == 0) {
+ self.pwd.clearRetainingCapacity();
+ return;
}
+
+ const capacity = std.math.add(usize, pwd.len, 1) catch
+ return error.OutOfMemory;
+ try self.pwd.ensureTotalCapacity(self.gpa(), capacity);
+
+ self.pwd.items.len = capacity;
+ std.mem.copyForwards(u8, self.pwd.items[0..pwd.len], pwd);
+ self.pwd.items[pwd.len] = 0;
}
/// Returns the pwd for the terminal, if any. The memory is owned by the
@@ -2942,13 +3622,41 @@ pub fn getPwd(self: *const Terminal) ?[:0]const u8 {
return self.pwd.items[0 .. self.pwd.items.len - 1 :0];
}
+test "Terminal: setPwd preserves a sentinel on allocation failure" {
+ var failing = testing.FailingAllocator.init(testing.allocator, .{});
+ const alloc = failing.allocator();
+ var t = try init(alloc, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(alloc);
+
+ try t.pwd.ensureTotalCapacityPrecise(alloc, 3);
+ failing.fail_index = failing.alloc_index;
+ try testing.expectError(error.OutOfMemory, t.setPwd("pwd"));
+ try testing.expect(t.getPwd() == null);
+}
+
+test "Terminal: setPwd accepts its current value" {
+ var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(testing.allocator);
+
+ try t.setPwd("file:///tmp");
+ try t.setPwd(t.getPwd().?);
+ try testing.expectEqualStrings("file:///tmp", t.getPwd().?);
+}
+
/// Set the title for the terminal, as set by escape sequences (e.g. OSC 0/2).
pub fn setTitle(self: *Terminal, t: []const u8) !void {
- self.title.clearRetainingCapacity();
- if (t.len > 0) {
- try self.title.appendSlice(self.gpa(), t);
- try self.title.append(self.gpa(), 0);
+ if (t.len == 0) {
+ self.title.clearRetainingCapacity();
+ return;
}
+
+ const capacity = std.math.add(usize, t.len, 1) catch
+ return error.OutOfMemory;
+ try self.title.ensureTotalCapacity(self.gpa(), capacity);
+
+ self.title.items.len = capacity;
+ std.mem.copyForwards(u8, self.title.items[0..t.len], t);
+ self.title.items[t.len] = 0;
}
/// Returns the title for the terminal, if any. The memory is owned by the
@@ -2958,6 +3666,27 @@ pub fn getTitle(self: *const Terminal) ?[:0]const u8 {
return self.title.items[0 .. self.title.items.len - 1 :0];
}
+test "Terminal: setTitle preserves a sentinel on allocation failure" {
+ var failing = testing.FailingAllocator.init(testing.allocator, .{});
+ const alloc = failing.allocator();
+ var t = try init(alloc, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(alloc);
+
+ try t.title.ensureTotalCapacityPrecise(alloc, 5);
+ failing.fail_index = failing.alloc_index;
+ try testing.expectError(error.OutOfMemory, t.setTitle("title"));
+ try testing.expect(t.getTitle() == null);
+}
+
+test "Terminal: setTitle accepts its current value" {
+ var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(testing.allocator);
+
+ try t.setTitle("Ghostty");
+ try t.setTitle(t.getTitle().?);
+ try testing.expectEqualStrings("Ghostty", t.getTitle().?);
+}
+
/// Switch to the given screen type (alternate or primary).
///
/// This does NOT handle behaviors such as clearing the screen,
@@ -3171,6 +3900,7 @@ pub fn fullReset(self: *Terminal) void {
self.previous_char = null;
self.pwd.clearRetainingCapacity();
self.title.clearRetainingCapacity();
+ self.glyph_glossary.clearAndFree(self.gpa());
self.status_display = .main;
self.scrolling_region = .{
.top = 0,
@@ -3193,6 +3923,24 @@ fn clearDirty(t: *Terminal) void {
t.screens.active.pages.clearDirty();
}
+test "Terminal: setCursorPos saturates overflowing origin offsets" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(alloc);
+
+ t.scrolling_region = .{
+ .top = 2,
+ .bottom = 7,
+ .left = 3,
+ .right = 8,
+ };
+ t.modes.set(.origin, true);
+
+ t.setCursorPos(std.math.maxInt(usize), std.math.maxInt(usize));
+ try testing.expectEqual(@as(size.CellCountInt, 8), t.screens.active.cursor.x);
+ try testing.expectEqual(@as(size.CellCountInt, 7), t.screens.active.cursor.y);
+}
+
test "Terminal: input with no control characters" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .cols = 40, .rows = 40 });
@@ -3288,19 +4036,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" {
@@ -3536,7 +4284,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());
}
@@ -3547,7 +4295,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());
}
@@ -3566,7 +4314,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());
}
@@ -3577,7 +4325,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());
}
@@ -3608,7 +4356,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);
}
{
@@ -3617,7 +4365,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 } }).?;
@@ -3625,7 +4373,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);
}
{
@@ -3634,7 +4382,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 } }).?;
@@ -3642,7 +4390,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 } }).?;
@@ -3650,12 +4398,66 @@ 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 } }));
}
+test "Terminal: enabling grapheme mode handles stored breaks" {
+ var t = try init(testing.allocator, .{ .cols = 5, .rows = 1 });
+ defer t.deinit(testing.allocator);
+
+ t.modes.set(.grapheme_cluster, false);
+ try t.print('a');
+ try t.print(0x200B); // Zero width space is stored on the prior cell.
+
+ t.modes.set(.grapheme_cluster, true);
+ try t.print(0x0301);
+
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("a\xE2\x80\x8B\xCC\x81", str);
+}
+
+// Terminal.print receives one codepoint at a time, so it can't use
+// unicode.graphemeWidth directly; that API requires a complete buffered
+// cluster or string end. This keeps the streaming printer's cursor advance
+// in sync with the buffered measurement API for representative clusters.
+fn expectGraphemeWidthParity(cps: []const u21) !void {
+ var t = try init(testing.allocator, .{ .cols = 80, .rows = 5 });
+ defer t.deinit(testing.allocator);
+
+ t.modes.set(.grapheme_cluster, true);
+
+ var expected: usize = 0;
+ var i: usize = 0;
+ while (i < cps.len) {
+ const result = unicode.graphemeWidth(u21, cps[i..]);
+ try testing.expect(result.len > 0);
+ i += result.len;
+ expected += result.width;
+ }
+
+ for (cps) |cp| try t.print(cp);
+ try testing.expectEqual(@as(usize, 0), t.screens.active.cursor.y);
+ try testing.expectEqual(expected, t.screens.active.cursor.x);
+}
+
+test "Terminal: graphemeWidth parity" {
+ try expectGraphemeWidthParity(&.{ 0x2764, 0xFE0F });
+ try expectGraphemeWidthParity(&.{ 'x', 0xFE0F, 0xFE0F });
+ try expectGraphemeWidthParity(&.{ 0x231A, 0xFE0E, 0xFE0F });
+ try expectGraphemeWidthParity(&.{ 0x1F3F4, 0x200D, 0x2620, 0xFE0F });
+ try expectGraphemeWidthParity(&.{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 });
+ try expectGraphemeWidthParity(&.{ 0x23, 0xFE0F, 0x20E3 });
+ try expectGraphemeWidthParity(&.{ '1', 0x20E3 });
+ try expectGraphemeWidthParity(&.{ 0x1F44B, 0x1F3FF });
+ try expectGraphemeWidthParity(&.{ 0x1F1E6, 0x1F1E7, 0x1F1E8 });
+ try expectGraphemeWidthParity(&.{ 'a', 'b' });
+ try expectGraphemeWidthParity(&.{ 0x0301, 0x0302 });
+}
+
test "Terminal: VS16 doesn't make character with 2027 disabled" {
var t = try init(testing.allocator, .{ .rows = 5, .cols = 5 });
defer t.deinit(testing.allocator);
@@ -3678,7 +4480,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);
}
}
@@ -3759,7 +4561,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" {
@@ -3792,7 +4594,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);
}
{
@@ -4000,7 +4802,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);
}
}
@@ -4035,7 +4837,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);
}
}
@@ -4094,7 +4896,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);
}
{
@@ -4146,7 +4948,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);
}
@@ -4197,7 +4999,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);
}
{
@@ -4248,7 +5050,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);
}
@@ -4286,7 +5088,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);
}
{
@@ -4325,7 +5127,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);
}
}
@@ -4356,7 +5158,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);
}
{
@@ -4365,7 +5167,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);
}
}
@@ -4458,7 +5260,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);
}
}
@@ -4487,7 +5289,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);
}
{
@@ -4535,7 +5337,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);
}
{
@@ -4556,7 +5358,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();
}
@@ -4594,7 +5396,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);
}
{
@@ -4627,7 +5429,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);
}
{
@@ -4689,7 +5491,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
@@ -4729,7 +5531,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
@@ -4780,7 +5582,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);
}
{
@@ -5228,7 +6030,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);
}
@@ -5255,7 +6057,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);
}
@@ -5282,7 +6084,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| {
@@ -5317,7 +6119,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| {
@@ -5327,7 +6129,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);
}
@@ -5351,7 +6153,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;
@@ -6092,8 +6894,11 @@ test "Terminal: insertLines simple" {
try t.printString("GHI");
t.setCursorPos(2, 2);
+ const node = t.screens.active.cursor.page_pin.node;
+ const serial = node.serial;
t.clearDirty();
t.insertLines(1);
+ try testing.expect(!t.screens.active.pages.nodeIsValid(node, serial));
try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
@@ -6166,7 +6971,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);
@@ -6250,7 +7055,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();
@@ -6271,11 +7076,15 @@ test "Terminal: insertLines across page boundary marks all shifted rows dirty" {
try t.printString("5EEEE");
// Verify we now have a second page
- try testing.expect(first_page.next != null);
+ const second_page = first_page.next.?;
+ const first_serial = first_page.serial;
+ const second_serial = second_page.serial;
t.setCursorPos(1, 1);
t.clearDirty();
t.insertLines(1);
+ try testing.expect(!t.screens.active.pages.nodeIsValid(first_page, first_serial));
+ try testing.expect(!t.screens.active.pages.nodeIsValid(second_page, second_serial));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
@@ -6582,9 +7391,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| {
@@ -6596,7 +7405,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);
}
}
@@ -6633,7 +7442,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);
}
}
@@ -6735,7 +7544,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| {
@@ -6747,9 +7556,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| {
@@ -6759,7 +7568,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);
}
}
@@ -6775,9 +7584,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| {
@@ -6787,7 +7596,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| {
@@ -6799,9 +7608,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());
}
}
@@ -7082,9 +7891,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| {
@@ -7096,7 +7905,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);
}
}
@@ -7206,9 +8015,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| {
@@ -7218,7 +8027,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| {
@@ -7230,9 +8039,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());
}
}
@@ -7246,7 +8055,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| {
@@ -7258,9 +8067,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| {
@@ -7270,7 +8079,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);
}
}
@@ -7504,7 +8313,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);
@@ -7581,7 +8390,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);
@@ -7611,7 +8420,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);
@@ -7638,7 +8447,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);
@@ -7941,7 +8750,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);
}
{
@@ -7953,7 +8762,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);
}
}
@@ -8115,7 +8924,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);
}
{
@@ -8127,7 +8936,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);
}
}
@@ -8164,9 +8973,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());
}
}
@@ -8381,6 +9190,128 @@ test "Terminal: index bottom of scroll region blank line preserves SGR" {
}
}
+test "Terminal: index bottom of scroll region with top margin and background SGR" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+
+ try t.printString("1\n2\n3\n4\n5");
+ t.setTopAndBottomMargin(2, 4);
+ t.setCursorPos(4, 1);
+ try t.setAttribute(.{ .direct_color_bg = .{
+ .r = 0xFF,
+ .g = 0,
+ .b = 0,
+ } });
+ try t.index();
+
+ // The region (rows 2-4) scrolled up, rows outside are unchanged.
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1\n3\n4\n\n5", str);
+ }
+
+ // The cursor is on the new blank row.
+ try testing.expectEqual(@as(usize, 3), t.screens.active.cursor.y);
+
+ // The new blank row must be filled with our background color.
+ for (0..t.cols) |x| {
+ const list_cell = t.screens.active.pages.getCell(.{ .active = .{
+ .x = @intCast(x),
+ .y = 3,
+ } }).?;
+ try testing.expect(list_cell.cell.content_tag == .bg_color_rgb);
+ try testing.expectEqual(Cell.RGB{
+ .r = 0xFF,
+ .g = 0,
+ .b = 0,
+ }, list_cell.cell.content.color_rgb);
+ }
+}
+
+test "Terminal: index bottom of alt screen full region" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 3, .cols = 5 });
+ defer t.deinit(alloc);
+
+ try t.switchScreenMode(.@"1049", true);
+ try t.printString("A\nB\nC");
+ try t.index();
+ t.carriageReturn();
+ try t.print('D');
+
+ // Content scrolled up and the scrolled-out row is discarded, NOT
+ // moved into scrollback (the alt screen has none).
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("B\nC\nD", str);
+ }
+ {
+ const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("B\nC\nD", str);
+ }
+
+ // Primary screen is untouched.
+ try t.switchScreenMode(.@"1049", false);
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("", str);
+ }
+}
+
+test "Terminal: index bottom of alt screen top region" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+
+ try t.switchScreenMode(.@"1049", true);
+ try t.printString("1\n2\n3\n4\n5");
+
+ // Region at the top of the screen, excluding the last row. On the
+ // alt screen this must NOT create scrollback.
+ t.setTopAndBottomMargin(1, 4);
+ t.setCursorPos(4, 1);
+ try t.index();
+ try t.print('X');
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("2\n3\n4\nX\n5", str);
+ }
+ {
+ const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("2\n3\n4\nX\n5", str);
+ }
+}
+
+test "Terminal: scrollUp top region no scrollback" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .rows = 5, .cols = 5, .max_scrollback = 0 });
+ defer t.deinit(alloc);
+
+ try t.printString("A\nB\nC\nD\nE");
+ t.setTopAndBottomMargin(1, 3);
+ try t.scrollUp(1);
+
+ // The region scrolled and the scrolled-out row is discarded.
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("B\nC\n\nD\nE", str);
+ }
+ {
+ const str = try t.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("B\nC\n\nD\nE", str);
+ }
+}
+
test "Terminal: cursorUp basic" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
@@ -8857,8 +9788,11 @@ test "Terminal: deleteLines simple" {
try t.printString("GHI");
t.setCursorPos(2, 2);
+ const node = t.screens.active.cursor.page_pin.node;
+ const serial = node.serial;
t.clearDirty();
t.deleteLines(1);
+ try testing.expect(!t.screens.active.pages.nodeIsValid(node, serial));
try testing.expect(!t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
@@ -8919,7 +9853,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();
@@ -8940,11 +9874,15 @@ test "Terminal: deleteLines across page boundary marks all shifted rows dirty" {
try t.printString("5EEEE");
// Verify we now have a second page
- try testing.expect(first_page.next != null);
+ const second_page = first_page.next.?;
+ const first_serial = first_page.serial;
+ const second_serial = second_page.serial;
t.setCursorPos(1, 1);
t.clearDirty();
t.deleteLines(1);
+ try testing.expect(!t.screens.active.pages.nodeIsValid(first_page, first_serial));
+ try testing.expect(!t.screens.active.pages.nodeIsValid(second_page, second_serial));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 1 } }));
@@ -9534,7 +10472,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);
}
}
@@ -9558,7 +10496,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());
}
@@ -9580,7 +10518,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());
}
@@ -9971,7 +10909,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);
@@ -10007,7 +10945,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);
@@ -10075,7 +11013,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| {
@@ -10085,7 +11023,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);
}
}
@@ -10115,7 +11053,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);
}
}
@@ -10688,7 +11626,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);
@@ -10740,7 +11678,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);
@@ -10779,7 +11717,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
@@ -10940,14 +11878,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
@@ -11545,6 +12483,234 @@ test "Terminal: printRepeat no previous character" {
}
}
+test "Terminal: printSlice simple ascii" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 10, .rows = 3 });
+ defer t.deinit(alloc);
+
+ try t.printSlice(&.{ 'h', 'e', 'l', 'l', 'o' });
+ try testing.expectEqual(@as(usize, 5), t.screens.active.cursor.x);
+ try testing.expectEqual(@as(u21, 'o'), t.previous_char.?);
+ try testing.expect(t.isDirty(.{ .active = .{ .x = 0, .y = 0 } }));
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("hello", str);
+ }
+}
+
+test "Terminal: printSlice wraps and scrolls" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 5, .rows = 2 });
+ defer t.deinit(alloc);
+
+ // 12 chars: fills row 1 (5), row 2 (5), wraps+scrolls, 2 more.
+ try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e', 'f', 'g', 'h', 'i', 'j', 'k', 'l' });
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("fghij\nkl", str);
+ }
+ try testing.expectEqual(@as(usize, 2), t.screens.active.cursor.x);
+ try testing.expect(!t.screens.active.cursor.pending_wrap);
+}
+
+test "Terminal: printSlice pending wrap state" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 5, .rows = 2 });
+ defer t.deinit(alloc);
+
+ try t.printSlice(&.{ 'a', 'b', 'c', 'd', 'e' });
+ try testing.expectEqual(@as(usize, 4), t.screens.active.cursor.x);
+ try testing.expect(t.screens.active.cursor.pending_wrap);
+
+ {
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("abcde", str);
+ }
+}
+
+/// Differential testing helper: applies the same logical print
+/// operations to two terminals, one using per-codepoint print() and
+/// the other using printSlice() with random chunking, verifying that
+/// the results are identical.
+fn testPrintSliceDifferential(
+ alloc: Allocator,
+ rand: std.Random,
+ ops: usize,
+ cols: size.CellCountInt,
+ rows: size.CellCountInt,
+) !void {
+ var t1 = try init(alloc, .{
+ .cols = cols,
+ .rows = rows,
+ });
+ defer t1.deinit(alloc);
+ var t2 = try init(alloc, .{
+ .cols = cols,
+ .rows = rows,
+ });
+ defer t2.deinit(alloc);
+
+ // Alphabet of interesting codepoints: ascii, latin-1, combining
+ // marks, CJK (wide), emoji (wide), ZWJ, variation selectors.
+ const alphabet = [_]u21{
+ 'a', 'b', 'Z', '0', ' ', 0x10, 0x1F, 0x7F,
+ 'é', 0xFF, 0x301, 0x4E00, 0x4E01, 0x1F600, 0x200D, 0xFE0F,
+ 'x', 'y', 0x1F9D1, 0x0308, 0xAD, 0x3042, 0xAC00, 'q',
+ 'r', 's', 't', 'u', 'v', 'w', '1', '2',
+ 0x1F1E6, 0x1F1E7, 0x1100, 0x1161, 0x11A8, 0x200C, 0x0430, 0x03B1,
+ };
+
+ var cps_buf: [64]u32 = undefined;
+ var last_n: usize = 0;
+
+ for (0..ops) |_| {
+ switch (rand.intRangeAtMost(u8, 0, 20)) {
+ // Print a run of codepoints (most common op).
+ 0...9 => {
+ const n = rand.intRangeAtMost(usize, 1, cps_buf.len);
+ last_n = n;
+ for (cps_buf[0..n]) |*cp| {
+ cp.* = alphabet[rand.intRangeLessThan(usize, 0, alphabet.len)];
+ }
+
+ // t1: per-codepoint print
+ for (cps_buf[0..n]) |cp| try t1.print(@intCast(cp));
+
+ // t2: printSlice with random chunking
+ var i: usize = 0;
+ while (i < n) {
+ const chunk = rand.intRangeAtMost(usize, 1, n - i);
+ try t2.printSlice(cps_buf[i..][0..chunk]);
+ i += chunk;
+ }
+ },
+ 10 => {
+ t1.carriageReturn();
+ t2.carriageReturn();
+ try t1.linefeed();
+ try t2.linefeed();
+ },
+ 11 => {
+ const row = rand.intRangeAtMost(usize, 1, rows);
+ const col = rand.intRangeAtMost(usize, 1, cols);
+ t1.setCursorPos(row, col);
+ t2.setCursorPos(row, col);
+ },
+ 12 => {
+ const attr: sgr.Attribute = switch (rand.intRangeAtMost(u8, 0, 3)) {
+ 0 => .{ .unset = {} },
+ 1 => .{ .bold = {} },
+ 2 => .{ .direct_color_fg = .{
+ .r = rand.int(u8),
+ .g = rand.int(u8),
+ .b = rand.int(u8),
+ } },
+ 3 => .{ .@"8_fg" = .red },
+ else => unreachable,
+ };
+ try t1.setAttribute(attr);
+ try t2.setAttribute(attr);
+ },
+ 13 => {
+ const v = rand.boolean();
+ t1.modes.set(.insert, v);
+ t2.modes.set(.insert, v);
+ },
+ 14 => {
+ const v = rand.boolean();
+ t1.modes.set(.wraparound, v);
+ t2.modes.set(.wraparound, v);
+ },
+ 15 => {
+ const v = rand.boolean();
+ t1.modes.set(.grapheme_cluster, v);
+ t2.modes.set(.grapheme_cluster, v);
+ },
+ 16 => {
+ // Margins.
+ t1.modes.set(.enable_left_and_right_margin, true);
+ t2.modes.set(.enable_left_and_right_margin, true);
+ const left = rand.intRangeAtMost(usize, 1, cols / 2);
+ const right = rand.intRangeAtMost(usize, cols / 2, cols);
+ t1.setLeftAndRightMargin(left, right);
+ t2.setLeftAndRightMargin(left, right);
+ },
+ 17 => {
+ t1.setLeftAndRightMargin(0, 0);
+ t2.setLeftAndRightMargin(0, 0);
+ },
+ 18 => {
+ try t1.screens.active.startHyperlink("http://example.com", null);
+ try t2.screens.active.startHyperlink("http://example.com", null);
+ },
+ 19 => {
+ t1.screens.active.endHyperlink();
+ t2.screens.active.endHyperlink();
+ },
+ 20 => {
+ const set: charsets.Charset = if (rand.boolean())
+ .dec_special
+ else
+ .utf8;
+ t1.configureCharset(.G0, set);
+ t2.configureCharset(.G0, set);
+ },
+ else => unreachable,
+ }
+
+ // Cursor state must match exactly after every op.
+ try testing.expectEqual(t1.screens.active.cursor.x, t2.screens.active.cursor.x);
+ try testing.expectEqual(t1.screens.active.cursor.y, t2.screens.active.cursor.y);
+ try testing.expectEqual(
+ t1.screens.active.cursor.pending_wrap,
+ t2.screens.active.cursor.pending_wrap,
+ );
+
+ // Full screen contents must match after every op. On failure,
+ // dump diagnostics that make the failure reproducible.
+ {
+ const str1 = try t1.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(str1);
+ const str2 = try t2.screens.active.dumpStringAlloc(alloc, .{ .screen = .{} });
+ defer alloc.free(str2);
+ testing.expectEqualStrings(str1, str2) catch |err| {
+ std.debug.print("last print cps: {any}\n", .{cps_buf[0..last_n]});
+ std.debug.print("modes: 2027={} insert={} wrap={} sr.left={} sr.right={} cols={}\n", .{
+ t1.modes.get(.grapheme_cluster),
+ t1.modes.get(.insert),
+ t1.modes.get(.wraparound),
+ t1.scrolling_region.left,
+ t1.scrolling_region.right,
+ cols,
+ });
+ return err;
+ };
+ }
+ }
+
+ // Page integrity (styles refcounts, grapheme maps, etc.) must hold.
+ 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" {
+ const alloc = testing.allocator;
+
+ // Multiple seeds and terminal sizes for coverage, including a
+ // tiny terminal to stress wrap/scroll edge cases.
+ var prng = std.Random.DefaultPrng.init(0xC0FFEE);
+ const rand = prng.random();
+ try testPrintSliceDifferential(alloc, rand, 500, 80, 24);
+ try testPrintSliceDifferential(alloc, rand, 500, 10, 4);
+ try testPrintSliceDifferential(alloc, rand, 500, 5, 2);
+ try testPrintSliceDifferential(alloc, rand, 200, 2, 2);
+}
+
test "Terminal: printAttributes" {
const alloc = testing.allocator;
var t = try init(alloc, .{ .rows = 5, .cols = 5 });
@@ -12394,7 +13560,24 @@ test "Terminal: OSC133A click_events=1 sets click to click_events" {
.options_unvalidated = "click_events=1",
});
- try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click);
+ try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .absolute }, t.screens.active.semantic_prompt.click);
+}
+
+test "Terminal: OSC133A click_events=2 sets click to click_events (relative)" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 10, .rows = 5 });
+ defer t.deinit(alloc);
+
+ // Verify default state is none
+ try testing.expectEqual(.none, t.screens.active.semantic_prompt.click);
+
+ // OSC 133;A with click_events=2
+ try t.semanticPrompt(.{
+ .action = .fresh_line_new_prompt,
+ .options_unvalidated = "click_events=2",
+ });
+
+ try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .relative }, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A click_events=0 does not set click_events" {
@@ -12451,7 +13634,7 @@ test "Terminal: OSC133A click_events=1 takes priority over cl" {
});
// click_events should take priority
- try testing.expectEqual(.click_events, t.screens.active.semantic_prompt.click);
+ try testing.expectEqual(Screen.SemanticPrompt.SemanticClick{ .click_events = .absolute }, t.screens.active.semantic_prompt.click);
}
test "Terminal: OSC133A click_events=0 falls back to cl" {
@@ -12988,7 +14171,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);
}
@@ -13003,7 +14186,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);
}
@@ -13075,7 +14258,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);
}
@@ -13090,7 +14273,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);
}
@@ -13183,3 +14366,35 @@ test "Terminal: deleteLines wide char at right margin with full clear" {
// violation in clearCells.
try t.scrollUp(t.rows);
}
+
+test "Terminal: glyph APC stores session glossary entries" {
+ const alloc = testing.allocator;
+ var t = try init(alloc, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(alloc);
+
+ var register_parser = glyph.CommandParser.init(alloc, 1024 * 1024);
+ defer register_parser.deinit();
+ for ("r;cp=e0a0;AAAAAAAAAAAAAA==") |byte| try register_parser.feed(byte);
+ var register_req = try register_parser.complete(alloc);
+ defer register_req.deinit(alloc);
+
+ try testing.expectEqual(glyph.Response{
+ .register = .{ .cp = 0xE0A0 },
+ }, t.glyphProtocol(alloc, &register_req).?);
+ try testing.expect(t.glyph_glossary.contains(0xE0A0));
+ try testing.expect(t.flags.dirty.glyph_glossary);
+
+ var query_parser = glyph.CommandParser.init(alloc, 1024 * 1024);
+ defer query_parser.deinit();
+ for ("q;cp=e0a0") |byte| try query_parser.feed(byte);
+ var query_req = try query_parser.complete(alloc);
+ defer query_req.deinit(alloc);
+
+ try testing.expectEqual(glyph.Response{ .query = .{
+ .cp = 0xE0A0,
+ .status = .{ .glossary = true },
+ } }, t.glyphProtocol(alloc, &query_req).?);
+
+ t.fullReset();
+ try testing.expect(!t.glyph_glossary.contains(0xE0A0));
+}
diff --git a/vendor/libghostty-vt/src/terminal/apc.zig b/vendor/libghostty-vt/src/terminal/apc.zig
index 7e6f08a7..121f906a 100644
--- a/vendor/libghostty-vt/src/terminal/apc.zig
+++ b/vendor/libghostty-vt/src/terminal/apc.zig
@@ -2,6 +2,7 @@ const std = @import("std");
const build_options = @import("terminal_options");
const Allocator = std.mem.Allocator;
+pub const glyph = @import("apc/glyph.zig");
const kitty_gfx = @import("kitty/graphics.zig");
const log = std.log.scoped(.terminal_apc);
@@ -18,15 +19,27 @@ pub const Handler = struct {
/// use `.initFull`.
max_bytes: std.EnumMap(Protocol, usize) = .initFullWith(.{
.kitty = Protocol.defaultMaxBytes(.kitty),
+ .glyph = Protocol.defaultMaxBytes(.glyph),
}),
+ /// Protocols recognized by this APC handler. When a protocol is absent,
+ /// matching APC sequences are ignored so callers see the same behavior as
+ /// an unsupported protocol: no command execution and no response.
+ enabled: std.EnumSet(Protocol) = .initFull(),
+
pub fn deinit(self: *Handler) void {
self.state.deinit();
}
pub fn start(self: *Handler) void {
self.state.deinit();
- self.state = .{ .identify = {} };
+ self.state = .{ .identify = .{} };
+ }
+
+ /// Enable or disable APC protocol recognition for future APC sequences.
+ /// This does not affect any APC command already being parsed.
+ pub fn enable(self: *Handler, protocol: Protocol, enabled: bool) void {
+ self.enabled.setPresent(protocol, enabled);
}
pub fn feed(self: *Handler, alloc: Allocator, byte: u8) void {
@@ -38,21 +51,50 @@ pub const Handler = struct {
.ignore => return,
// We identify the APC command by the first byte.
- .identify => {
- switch (byte) {
- // Kitty graphics protocol
- 'G' => self.state = if (comptime build_options.kitty_graphics)
- .{ .kitty = .init(
+ .identify => |*id| id: {
+ // Kitty graphics is detected immediately on the `G` byte,
+ // since commands begin immediately after with no termination
+ // character after the 'G'.
+ if (comptime build_options.kitty_graphics) {
+ if (id.len == 0 and
+ byte == 'G' and
+ self.enabled.contains(.kitty))
+ {
+ self.state = .{ .kitty = .init(
alloc,
self.max_bytes.get(.kitty) orelse
Protocol.defaultMaxBytes(.kitty),
- ) }
- else
- .ignore,
+ ) };
+ break :id;
+ }
+ }
- // Unknown
- else => self.state = .ignore,
+ // If we hit `;` then identify...
+ if (byte == ';') {
+ const str = id.buf[0..id.len];
+ if (std.mem.eql(u8, str, "25a1") and
+ self.enabled.contains(.glyph))
+ {
+ self.state = .{ .glyph = .init(
+ alloc,
+ self.max_bytes.get(.glyph) orelse
+ Protocol.defaultMaxBytes(.glyph),
+ ) };
+ } else {
+ self.state = .ignore;
+ }
+
+ break :id;
}
+
+ // If we're out of space to buffer then we're done.
+ if (id.len >= id.buf.len) {
+ self.state = .ignore;
+ break :id;
+ }
+
+ id.buf[id.len] = byte;
+ id.len += 1;
},
.kitty => |*p| if (comptime build_options.kitty_graphics) {
@@ -62,6 +104,53 @@ pub const Handler = struct {
self.state = .ignore;
};
} else unreachable,
+
+ .glyph => |*p| p.feed(byte) catch |err| {
+ log.warn("glyph protocol error: {}", .{err});
+ p.deinit();
+ self.state = .ignore;
+ },
+ }
+ }
+
+ /// Feed a slice of bytes to the handler. This is equivalent to
+ /// calling feed for each byte in order, but protocol payload bytes
+ /// are passed through in bulk so large payloads (e.g. Kitty graphics
+ /// images) avoid per-byte dispatch overhead.
+ pub fn feedSlice(self: *Handler, alloc: Allocator, bytes: []const u8) void {
+ var rem = bytes;
+ while (rem.len > 0) {
+ switch (self.state) {
+ .inactive => unreachable,
+
+ // We're ignoring this APC command; drop the whole slice.
+ .ignore => return,
+
+ // Identification consumes at most a few bytes; step
+ // through them one at a time until the state changes.
+ .identify => {
+ self.feed(alloc, rem[0]);
+ rem = rem[1..];
+ },
+
+ .kitty => |*p| if (comptime build_options.kitty_graphics) {
+ p.feedSlice(rem) catch |err| {
+ log.warn("kitty graphics protocol error: {}", .{err});
+ p.deinit();
+ self.state = .ignore;
+ };
+ return;
+ } else unreachable,
+
+ .glyph => |*p| {
+ p.feedSlice(rem) catch |err| {
+ log.warn("glyph protocol error: {}", .{err});
+ p.deinit();
+ self.state = .ignore;
+ };
+ return;
+ },
+ }
}
}
@@ -86,21 +175,40 @@ pub const Handler = struct {
break :kitty .{ .kitty = command };
},
+
+ .glyph => |*p| glyph_cmd: {
+ const command = p.complete(p.alloc) catch |err| {
+ log.warn("glyph protocol error: {}", .{err});
+ break :glyph_cmd null;
+ };
+
+ break :glyph_cmd .{ .glyph = command };
+ },
};
}
};
pub const State = union(enum) {
/// We're not in the middle of an APC command yet.
- inactive: void,
+ inactive,
/// We got an unrecognized APC sequence or the APC sequence we
/// recognized became invalid. We're just dropping bytes.
- ignore: void,
-
- /// We're waiting to identify the APC sequence. This is done by
- /// inspecting the first byte of the sequence.
- identify: void,
+ ignore,
+
+ /// We're waiting to identify the APC sequence. The way this is done
+ /// is pretty fluid depending on supported APC protocols, but for now
+ /// our rule is:
+ ///
+ /// * 'G' - immediate transition to Kitty graphics protocol
+ /// * Buffer up to `;` and the bytes before dictate the protocol.
+ /// If we overflow then we're immediately invalid because we don't
+ /// support anything longer than this.
+ ///
+ identify: struct {
+ len: u3 = 0,
+ buf: [4]u8 = undefined,
+ },
/// Kitty graphics protocol
kitty: if (build_options.kitty_graphics)
@@ -108,9 +216,13 @@ pub const State = union(enum) {
else
void,
+ /// Glyph protocol
+ glyph: glyph.CommandParser,
+
pub fn deinit(self: *State) void {
switch (self.*) {
.inactive, .ignore, .identify => {},
+ .glyph => |*v| v.deinit(),
.kitty => |*v| if (comptime build_options.kitty_graphics)
v.deinit()
else
@@ -122,6 +234,7 @@ pub const State = union(enum) {
/// Possible APC command types.
pub const Protocol = enum {
kitty,
+ glyph,
/// Returns the default maximum bytes for the given protocol.
pub fn defaultMaxBytes(self: Protocol) usize {
@@ -129,6 +242,10 @@ pub const Protocol = enum {
// Kitty graphics payloads can be very large (e.g. full images
// encoded as base64), so the default is set to 65 MiB.
.kitty => 65 * 1024 * 1024,
+ // Glyph protocol messages carry single glyf outlines which
+ // are small, but base64 encoding inflates them. 1 MiB is
+ // generous for any single simple-glyph record.
+ .glyph => 1 * 1024 * 1024,
};
}
};
@@ -140,12 +257,16 @@ pub const Command = union(Protocol) {
else
void,
+ glyph: glyph.Request,
+
pub fn deinit(self: *Command, alloc: Allocator) void {
switch (self.*) {
.kitty => |*v| if (comptime build_options.kitty_graphics)
v.deinit(alloc)
else
unreachable,
+
+ .glyph => |*v| v.deinit(alloc),
}
}
};
@@ -246,3 +367,156 @@ test "valid Kitty command" {
defer cmd.deinit(alloc);
try testing.expect(cmd == .kitty);
}
+
+test "identify with unrecognized command" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.start();
+ for ("abcd;payload") |c| h.feed(alloc, c);
+ try testing.expect(h.end() == null);
+}
+
+test "identify buffer overflow" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.start();
+ for ("abcde;payload") |c| h.feed(alloc, c);
+ try testing.expect(h.end() == null);
+}
+
+test "identify with no input" {
+ const testing = std.testing;
+
+ var h: Handler = .{};
+ h.start();
+ try testing.expect(h.end() == null);
+}
+
+test "identify with unknown partial input" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.start();
+ for ("25a") |c| h.feed(alloc, c);
+ try testing.expect(h.end() == null);
+}
+
+test "garbage glyph command" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.start();
+ for ("25a1;X") |c| h.feed(alloc, c);
+
+ try testing.expect(h.end() == null);
+}
+
+test "valid glyph command" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.start();
+ for ("25a1;q;cp=E0A0") |c| h.feed(alloc, c);
+
+ var cmd = h.end().?;
+ defer cmd.deinit(alloc);
+ try testing.expect(cmd == .glyph);
+ try testing.expect(cmd.glyph == .query);
+}
+
+test "feedSlice valid Kitty command" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.start();
+ h.feedSlice(alloc, "Gf=24,s=10,v=20;aGVsbG8=");
+
+ var cmd = h.end().?;
+ defer cmd.deinit(alloc);
+ try testing.expect(cmd == .kitty);
+
+ // The payload is base64-decoded by the parser on completion.
+ try testing.expectEqualStrings("hello", cmd.kitty.data);
+}
+
+test "feedSlice identify split across slices" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.start();
+ h.feedSlice(alloc, "G");
+ h.feedSlice(alloc, "f=24,s=10,");
+ h.feedSlice(alloc, "v=20;aGVsbG8=");
+
+ var cmd = h.end().?;
+ defer cmd.deinit(alloc);
+ try testing.expect(cmd == .kitty);
+
+ // The payload is base64-decoded by the parser on completion.
+ try testing.expectEqualStrings("hello", cmd.kitty.data);
+}
+
+test "feedSlice unknown APC command is ignored" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.start();
+ h.feedSlice(alloc, "Xabcdef1234");
+ try testing.expect(h.state == .ignore);
+ h.feedSlice(alloc, "more data that is dropped");
+ try testing.expect(h.end() == null);
+}
+
+test "feedSlice valid glyph command" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.start();
+ h.feedSlice(alloc, "25a1;q;cp=E0A0");
+
+ var cmd = h.end().?;
+ defer cmd.deinit(alloc);
+ try testing.expect(cmd == .glyph);
+ try testing.expect(cmd.glyph == .query);
+}
+
+test "feedSlice kitty max bytes exceeded" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{ .max_bytes = .init(.{ .kitty = 4 }) };
+ defer h.deinit();
+ h.start();
+ h.feedSlice(alloc, "Ga=t;abcd");
+ try testing.expect(h.state != .ignore);
+ h.feedSlice(alloc, "e");
+ try testing.expect(h.state == .ignore);
+}
+
+test "disabled glyph command is ignored" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var h: Handler = .{};
+ h.enable(.glyph, false);
+ h.start();
+ for ("25a1;q;cp=e0a0") |c| h.feed(alloc, c);
+ try testing.expect(h.end() == null);
+}
diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph.zig b/vendor/libghostty-vt/src/terminal/apc/glyph.zig
new file mode 100644
index 00000000..727e1ab4
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/apc/glyph.zig
@@ -0,0 +1,164 @@
+//! # Glyph Protocol
+//!
+//! The Glyph Protocol lets applications register custom glyphs with the
+//! terminal at runtime and query whether a given codepoint is already
+//! covered by a system font or a prior registration. It eliminates the
+//! requirement for users to install patched fonts (e.g. Nerd Fonts) in
+//! order to render icons in TUIs.
+//!
+//! This file documents the current wire protocol surface parsed and formatted
+//! by the glyph APC modules.
+//!
+//! ## Transport
+//!
+//! Messages use APC (Application Program Command) framing.
+//! Terminals that do not implement the protocol can safely ignore APC
+//! sequences. Every message is prefixed with the identifier `25a1`
+//! (U+25A1 WHITE SQUARE — the canonical tofu symbol).
+//!
+//! ## Framing
+//!
+//! ```
+//! ESC _ 25a1 ; <verb> [ ; key=value ]* [ ; <payload> ] ESC \
+//! ```
+//!
+//! Four verbs are defined:
+//!
+//! - `s` — support query
+//! - `q` — codepoint query
+//! - `r` — register a glyph
+//! - `c` — clear registrations
+//!
+//! ## Support (`s`)
+//!
+//! Detects whether the terminal implements Glyph Protocol and which
+//! payload formats it supports.
+//!
+//! Request: `ESC _ 25a1 ; s ESC \`
+//! Response: `ESC _ 25a1 ; s ; fmt=<list> ESC \`
+//!
+//! `fmt` is a comma-separated list of supported payload format names:
+//! - `glyf` — TrueType simple glyphs (required in v1)
+//! - `colrv0` — COLR v0 layered flat-colour glyphs
+//! - `colrv1` — COLR v1 paint-graph glyphs
+//!
+//! Order is not significant. An empty `fmt=` means the terminal recognizes
+//! Glyph Protocol but currently advertises no payload formats. Clients must
+//! ignore unknown format names.
+//!
+//! Any reply confirms support; no reply within a timeout means the
+//! terminal does not implement the protocol.
+//!
+//! ## Query (`q`)
+//!
+//! Asks whether a codepoint is renderable and by whom.
+//!
+//! Request: `ESC _ 25a1 ; q ; cp=<hex> ESC \`
+//! Response: `ESC _ 25a1 ; q ; cp=<hex> ; status=<list> ESC \`
+//!
+//! `status` is a comma-separated list of coverage names:
+//! - empty — nothing renders this codepoint (tofu)
+//! - `system` — a system font covers it
+//! - `glossary` — a session registration covers it
+//! - `system,glossary` — both; the registration shadows the system font
+//!
+//! Non-PUA codepoints can only report empty or `system`. Clients must ignore
+//! unknown coverage names.
+//!
+//! ## Register (`r`)
+//!
+//! Registers a glyph outline at a Private Use Area codepoint.
+//!
+//! Request:
+//! `ESC _ 25a1 ; r ; cp=<hex> [; fmt=glyf] [; reply=<0|1|2>]
+//! [; upm=<int>] [; aw=<int>] [; lh=<int>] [; width=<1|2>]
+//! [; size=<height|advance|contain|cover|stretch>]
+//! [; align=<start|center|end>,<start|center|end|baseline>]
+//! [; pad=<top>,<right>,<bottom>,<left>] ; <base64-payload> ESC \`
+//!
+//! Response:
+//! `ESC _ 25a1 ; r ; cp=<hex> ; status=0 ESC \`
+//! On error: `status=<nonzero> ; reason=<code>`
+//!
+//! Parameters:
+//! - `cp` — target codepoint (hex). Must be in a PUA range:
+//! U+E000–U+F8FF, U+F0000–U+FFFFD, or U+100000–U+10FFFD.
+//! Non-PUA values are rejected with `reason=out_of_namespace`.
+//! - `fmt` — payload format. Default `glyf`; `colrv0` and `colrv1`
+//! are optional and advertised via the `s` reply.
+//! - `reply` — response verbosity:
+//! `1` (default) = success + failure replies
+//! `2` = failure replies only (silent success)
+//! `0` = no replies (fire-and-forget)
+//! - `upm` — units-per-em for the coordinate space. Default 1000.
+//! - `aw` — authored advance width in upm units. Default `upm`.
+//! - `lh` — authored line height in upm units. Default `upm`.
+//! - `width` — Unicode/wcwidth cell width. Must be `1` or `2`; default `1`.
+//! This is authoritative for cursor advance, wrapping, and
+//! selection geometry.
+//! - `size` — scale policy. Default `height`.
+//! - `align` — horizontal and vertical placement within the render span.
+//! Default `center,center`.
+//! - `pad` — fractional insets from the render span edges. Default
+//! `0,0,0,0`; degenerate padding is treated as no padding.
+//! - payload — base64-encoded payload for the selected `fmt`.
+//!
+//! The `glyf` subset accepted:
+//! - Simple glyphs only (no composites).
+//! - Standard flag encoding (on-curve, off-curve, x/y-short, repeat).
+//! - No hinting instructions.
+//! - Coordinates are in the `upm` space, Y-up, with `y=0` at the baseline;
+//! the terminal scales and positions at render time using `aw`, `lh`,
+//! `width`, `size`, `align`, and `pad`.
+//!
+//! `colrv0` and `colrv1` wrap OpenType `COLR`/`CPAL` data together with the
+//! simple-glyph outlines they reference. `colrv0` uses layered flat colours;
+//! `colrv1` uses the OpenType paint graph and may omit `CPAL` if it does not
+//! reference palette indices.
+//!
+//! A second `r` on the same `cp` overwrites the previous registration.
+//! `glyf` outlines render in the current foreground colour.
+//!
+//! ## Clear (`c`)
+//!
+//! Removes registrations.
+//!
+//! Single slot: `ESC _ 25a1 ; c ; cp=<hex> ESC \`
+//! All slots: `ESC _ 25a1 ; c ESC \`
+//!
+//! The terminal acks with `status=0` even if the slot was already empty.
+//! Clear replies do not echo `cp`. `cp` must be in a PUA range; non-PUA values return
+//! `reason=out_of_namespace`.
+//!
+//! ## Glossary Capacity
+//!
+//! Each session holds at most 1024 registrations keyed by codepoint.
+//! Registrations live for the session duration. A 1025th registration
+//! evicts the oldest entry (FIFO). Sessions are isolated: two tabs may
+//! independently register the same codepoint.
+//!
+//! ## Security: PUA-Only Restriction
+//!
+//! Registration is restricted to the three Unicode Private Use Areas to
+//! prevent glyph-spoofing attacks. PUA codepoints never appear in normal
+//! text (filenames, URLs, commands), so a registered glyph cannot alter
+//! how real text is perceived. The cell buffer always stores the original
+//! codepoint — copy/paste, search, and hyperlink detection return the
+//! codepoint the application emitted, never the rendered glyph.
+//!
+//! Reference: <https://raw.githubusercontent.com/raphamorim/rio/779dba839dbb76c551f2efa852b82a2ed669101b/specs/glyph-protocol.md>
+
+const std = @import("std");
+
+pub const request = @import("glyph/request.zig");
+pub const response = @import("glyph/response.zig");
+pub const execute = @import("glyph/execute.zig").execute;
+
+pub const CommandParser = request.CommandParser;
+pub const Request = request.Request;
+pub const Response = response.Response;
+pub const Glossary = @import("glyph/Glossary.zig");
+
+test {
+ std.testing.refAllDecls(@This());
+}
diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md b/vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md
new file mode 100644
index 00000000..c81029c0
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/apc/glyph/AGENTS.md
@@ -0,0 +1,9 @@
+# Glyph Protocol
+
+- The specification source of truth is:
+ <https://github.com/raphamorim/rio/blob/main/specs/glyph-protocol.md>
+- A summary of the specification is available in
+ `src/terminal/apc/glyph.zig` at the top.
+- Reference the specification whenever any changes are made to
+ this folder. Prefer the local specification over fetching the
+ latest, unless it is lacking information.
diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig
new file mode 100644
index 00000000..28458dbb
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/apc/glyph/Glossary.zig
@@ -0,0 +1,444 @@
+/// Glossary is the per-terminal storage for Glyph Protocol
+/// codepoints. We use the word Glossary to match up with the spec which
+/// also uses this word.
+const Glossary = @This();
+
+const std = @import("std");
+const assert = std.debug.assert;
+const Allocator = std.mem.Allocator;
+const CircBuf = @import("../../../datastruct/circ_buf.zig").CircBuf;
+const FontGlyph = @import("../../../font/Glyph.zig");
+const Glyf = @import("../../../font/opentype/glyf.zig").Glyf;
+
+const request = @import("request.zig");
+const RegisterReq = request.Request.Register;
+
+const DesignMetrics = FontGlyph.DesignMetrics;
+const Constraint = FontGlyph.RenderOptions.Constraint;
+
+/// Maximum entries allowed in the glossary before eviction.
+/// Defined by the specification.
+pub const max_entries = 1024;
+
+/// An empty glossary with no registered glyphs.
+pub const empty: Glossary = .{ .entries = .empty };
+
+/// Errors that can occur while registering a glossary entry.
+pub const RegisterError = Allocator.Error || error{OutOfNamespace};
+
+/// Errors that can occur while clearing glossary entries.
+pub const ClearError = error{OutOfNamespace};
+
+/// The set of entries in the glossary keyed by the codepoint.
+///
+/// The array hash map preserves insertion order and has O(N)
+/// orderedRemove, so we use it as a FIFO too for eviction when
+/// the glossary is full. Since the specification limits the protocol
+/// to 1024 maximum entries, ordered removal should never be that
+/// expensive.
+///
+/// I'm also operating under the assumption that full glossaries
+/// for a session will be rare, so the eviction cost shouldn't
+/// happen regularly.
+entries: std.AutoArrayHashMapUnmanaged(u21, Entry),
+
+/// Release all glyph entries and hash map storage owned by the glossary.
+pub fn deinit(self: *Glossary, alloc: Allocator) void {
+ for (self.entries.values()) |*entry| entry.deinit(alloc);
+ self.entries.deinit(alloc);
+ self.* = undefined;
+}
+
+/// Register the given glyph entry.
+///
+/// This will act according to the glyph specification
+pub fn register(
+ self: *Glossary,
+ alloc: Allocator,
+ cp: u21,
+ entry: Entry,
+) RegisterError!void {
+ // Validate codepoint according to spec.
+ if (!isPrivateUse(cp)) return error.OutOfNamespace;
+
+ const gop = try self.entries.getOrPut(alloc, cp);
+ if (gop.found_existing) {
+ // Found an existing entry, we need to shift the FIFO so
+ // that this is now the most recent (at the end). This is
+ // O(N) but N is usually small and max N is bounded by the spec.
+ gop.value_ptr.*.deinit(alloc);
+ assert(self.entries.orderedRemove(cp));
+
+ // We already had enough capacity for this key before removing it, so
+ // reinserting the replacement cannot require another allocation.
+ self.entries.putAssumeCapacity(cp, entry);
+ return;
+ }
+
+ // Array hash maps preserve insertion order so always immediately insert.
+ gop.value_ptr.* = entry;
+
+ // Fast, typical path: we fit within the glossary, just return.
+ if (self.entries.count() <= max_entries) return;
+
+ // Slow path: we need to evict.
+ self.entries.values()[0].deinit(alloc);
+ self.entries.orderedRemoveAt(0);
+}
+
+/// Delete a single entry from the glossary. If the entry doesn't exist,
+/// then this does nothing and is safe.
+pub fn delete(
+ self: *Glossary,
+ alloc: Allocator,
+ cp: u21,
+) ClearError!void {
+ if (!isPrivateUse(cp)) return error.OutOfNamespace;
+ const kv = self.entries.fetchOrderedRemove(cp) orelse return;
+ var entry = kv.value;
+ entry.deinit(alloc);
+}
+
+/// Clear all entries from the glossary and free up any underlying
+/// storage.
+pub fn clearAndFree(self: *Glossary, alloc: Allocator) void {
+ for (self.entries.values()) |*entry| entry.deinit(alloc);
+ self.entries.deinit(alloc);
+ self.entries = .empty;
+}
+
+/// Contains returns true if the codepoint is covered by the glossary.
+pub fn contains(self: *Glossary, cp: u21) bool {
+ return self.entries.contains(cp);
+}
+
+/// A single glyph registration entry.
+pub const Entry = struct {
+ /// Stored glyph payload variants.
+ pub const Glyph = union(enum) {
+ glyf: Glyf.Outline,
+ };
+
+ /// The glyph itself. The tagged union only has glyf right now but
+ /// will eventually expand to support COLR and maybe other formats.
+ /// These are stored as raw outlines; rasterization is delayed to
+ /// renderers. The outlines have been validated.
+ glyph: Glyph,
+
+ /// Authored metrics for the glyph's design coordinate space.
+ design: DesignMetrics,
+
+ /// Unicode cell width requested by the registration.
+ width: request.Width,
+
+ /// Normalized scale, alignment, and padding behavior for rasterization.
+ constraint: Constraint,
+
+ /// Errors that can occur while constructing a glossary entry from a
+ /// register request.
+ pub const InitError = RegisterReq.DecodeError || error{
+ /// The register request is missing a required option or has an invalid
+ /// explicitly-provided option value.
+ InvalidOptions,
+
+ /// The requested payload format is not supported by this glossary.
+ UnsupportedFormat,
+ };
+
+ /// Initialize a glossary entry from a register request.
+ ///
+ /// This validates the request fields needed to construct the entry,
+ /// decodes the base64 glyph payload, and stores the decoded outline. The
+ /// returned entry owns decoded glyph memory and must be released with
+ /// `deinit`.
+ pub fn init(alloc: Allocator, req: RegisterReq) Entry.InitError!Entry {
+ // Validate format
+ const fmt = req.get(.fmt) orelse return error.InvalidOptions;
+ const design: DesignMetrics = .{
+ .units_per_em = req.get(.upm) orelse return error.InvalidOptions,
+ .advance_width = req.get(.aw) orelse return error.InvalidOptions,
+ .line_height = req.get(.lh) orelse return error.InvalidOptions,
+ };
+ if (design.units_per_em == 0 or
+ design.advance_width == 0 or
+ design.line_height == 0) return error.InvalidOptions;
+ const width = req.get(.width) orelse return error.InvalidOptions;
+
+ // Get our constraints
+ const constraint = try constraintFromRegister(req);
+
+ // Decode the payload into some usable glyph format for
+ // future rasterization.
+ const glyph: Glyph = switch (fmt) {
+ .glyf => .{ .glyf = try req.decodeGlyfPayload(alloc) },
+ .colrv0, .colrv1 => return error.UnsupportedFormat,
+ };
+
+ // No more errors, since we never do glyph cleanup above.
+ errdefer comptime unreachable;
+
+ return .{
+ .glyph = glyph,
+ .design = design,
+ .width = width,
+ .constraint = constraint,
+ };
+ }
+
+ /// Release memory owned by this entry.
+ pub fn deinit(self: *Entry, alloc: Allocator) void {
+ switch (self.glyph) {
+ .glyf => |*outline| outline.deinit(alloc),
+ }
+ self.* = undefined;
+ }
+
+ /// Return the renderer constraint for a register request.
+ ///
+ /// Glyph Protocol §8.5 defines sizing, alignment, and padding in terms of
+ /// the authored extent and render span. This function is the single
+ /// normalization point for how protocol sizing choices map to the
+ /// renderer-neutral constraint stored here.
+ fn constraintFromRegister(
+ req: RegisterReq,
+ ) error{InvalidOptions}!Constraint {
+ // Register.get applies the Glyph Protocol §6.1 defaults when options
+ // are omitted: size=height, align=center,center, and pad=0,0,0,0.
+ const size = req.get(.size) orelse return error.InvalidOptions;
+ const alignment = req.get(.@"align") orelse return error.InvalidOptions;
+ const pad = req.get(.pad) orelse return error.InvalidOptions;
+
+ return .{
+ .size = switch (size) {
+ // The rasterizer's base transform already maps the design em
+ // to the cell height. That is the closest existing behavior to
+ // the protocol's default height-driven mode.
+ .height => .none,
+ // There is no width-driven, aspect-preserving constraint mode
+ // today. Leave the base transform intact rather than forcing a
+ // fit/contain policy that would unexpectedly prevent overflow.
+ .advance => .none,
+ // Constraint.cover currently scales preserving aspect ratio to
+ // the available bounds, which is the best existing match for
+ // the protocol's contain mode.
+ .contain => .cover,
+ // There is no true protocol-cover equivalent that chooses the
+ // larger axis scale, so use the nearest named renderer policy.
+ .cover => .cover,
+ .stretch => .stretch,
+ },
+ .align_horizontal = switch (alignment.horizontal) {
+ .start => .start,
+ .center => .center,
+ .end => .end,
+ },
+ .align_vertical = switch (alignment.vertical) {
+ .start => .start,
+ .center => .center,
+ .end => .end,
+ // The current constraint API has no baseline alignment mode.
+ // Start is the closest stable default because the glyf
+ // rasterizer's coordinate model already treats y=0 as the
+ // baseline/bottom before constraints are applied.
+ .baseline => .start,
+ },
+ .pad_top = pad.top,
+ .pad_right = pad.right,
+ .pad_bottom = pad.bottom,
+ .pad_left = pad.left,
+ };
+ }
+};
+
+/// Return true if `cp` is in one of the Unicode Private Use Areas.
+fn isPrivateUse(cp: u21) bool {
+ return (cp >= 0xE000 and cp <= 0xF8FF) or
+ (cp >= 0xF0000 and cp <= 0xFFFFD) or
+ (cp >= 0x100000 and cp <= 0x10FFFD);
+}
+
+fn testParseRegister(alloc: Allocator, data: []const u8) !RegisterReq {
+ const raw = try alloc.dupe(u8, data);
+ errdefer alloc.free(raw);
+
+ const req = try request.Request.parse(alloc, raw);
+ switch (req) {
+ .register => |reg| return reg,
+ else => unreachable,
+ }
+}
+
+// Base64-encoded glyf payload from the "glyf: decode triangle" test in
+// font/opentype/glyf.zig. This is a real simple-glyph record with one contour
+// and three on-curve points.
+const test_triangle_glyf_payload = "AAEAZABkA4QDhAACAAABAQEB9P5wAyADhPzgAAA=";
+
+fn testRegisterReq(alloc: Allocator, cp: u21) !RegisterReq {
+ const data = try std.fmt.allocPrint(
+ alloc,
+ "r;cp={x};upm=2048;aw=1024;lh=1536;width=2;size=stretch;align=end,start;pad=0.1,0.2,0.3,0.4;{s}",
+ .{ cp, test_triangle_glyf_payload },
+ );
+ errdefer alloc.free(data);
+
+ const req = try request.Request.parse(alloc, data);
+ switch (req) {
+ .register => |reg| return reg,
+ else => unreachable,
+ }
+}
+
+fn testRegisterEntry(alloc: Allocator, cp: u21) !Entry {
+ const req = try testRegisterReq(alloc, cp);
+ defer alloc.free(req.raw);
+ return try Entry.init(alloc, req);
+}
+
+test "Entry init decodes glyf payload and applies register fields" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ const req = try testRegisterReq(alloc, 0xE000);
+ defer alloc.free(req.raw);
+
+ var entry = try Entry.init(alloc, req);
+ defer entry.deinit(alloc);
+
+ try testing.expectEqual(@as(u32, 2048), entry.design.units_per_em);
+ try testing.expectEqual(@as(u32, 1024), entry.design.advance_width);
+ try testing.expectEqual(@as(u32, 1536), entry.design.line_height);
+ try testing.expectEqual(request.Width.wide, entry.width);
+ try testing.expectEqual(Constraint.Size.stretch, entry.constraint.size);
+ try testing.expectEqual(Constraint.Align.end, entry.constraint.align_horizontal);
+ try testing.expectEqual(Constraint.Align.start, entry.constraint.align_vertical);
+ try testing.expectEqual(@as(f64, 0.1), entry.constraint.pad_top);
+ try testing.expectEqual(@as(f64, 0.2), entry.constraint.pad_right);
+ try testing.expectEqual(@as(f64, 0.3), entry.constraint.pad_bottom);
+ try testing.expectEqual(@as(f64, 0.4), entry.constraint.pad_left);
+
+ try testing.expectEqual(@as(usize, 3), entry.glyph.glyf.points.len);
+ try testing.expectEqual(@as(usize, 1), entry.glyph.glyf.contours.len);
+}
+
+test "Entry init rejects invalid register payload" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ const req = try testParseRegister(alloc, "r;cp=e000;%%%not-base64%%%");
+ defer alloc.free(req.raw);
+
+ try testing.expectError(error.MalformedPayload, Entry.init(alloc, req));
+}
+
+test "Glossary register overwrites and moves entry to newest position" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
+ try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001));
+ try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
+
+ try testing.expectEqual(@as(usize, 2), glossary.entries.count());
+ try testing.expectEqual(@as(u21, 0xE001), glossary.entries.keys()[0]);
+ try testing.expectEqual(@as(u21, 0xE000), glossary.entries.keys()[1]);
+}
+
+test "Glossary register evicts oldest entry" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ for (0..max_entries + 1) |i| {
+ const cp: u21 = @intCast(0xE000 + i);
+ try glossary.register(alloc, cp, try testRegisterEntry(alloc, cp));
+ }
+
+ try testing.expectEqual(@as(usize, max_entries), glossary.entries.count());
+ try testing.expect(!glossary.entries.contains(0xE000));
+ try testing.expect(glossary.entries.contains(0xE001));
+ try testing.expect(glossary.entries.contains(0xE000 + max_entries));
+}
+
+test "Glossary register rejects non-PUA codepoint" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var entry = try testRegisterEntry(alloc, 0xE000);
+ defer entry.deinit(alloc);
+ try testing.expectError(error.OutOfNamespace, glossary.register(alloc, 'A', entry));
+}
+
+test "Glossary delete removes one PUA slot and ignores empty PUA slot" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
+ try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001));
+
+ try glossary.delete(alloc, 0xE000);
+ try testing.expectEqual(@as(usize, 1), glossary.entries.count());
+ try testing.expect(!glossary.contains(0xE000));
+ try testing.expect(glossary.contains(0xE001));
+
+ try glossary.delete(alloc, 0xE000);
+ try testing.expectEqual(@as(usize, 1), glossary.entries.count());
+ try testing.expect(glossary.contains(0xE001));
+}
+
+test "Glossary delete rejects non-PUA codepoint" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
+ try testing.expectError(error.OutOfNamespace, glossary.delete(alloc, 'A'));
+ try testing.expectEqual(@as(usize, 1), glossary.entries.count());
+ try testing.expect(glossary.contains(0xE000));
+}
+
+test "Glossary clearAndFree removes all slots and remains reusable" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
+ try glossary.register(alloc, 0xE001, try testRegisterEntry(alloc, 0xE001));
+
+ glossary.clearAndFree(alloc);
+ try testing.expectEqual(@as(usize, 0), glossary.entries.count());
+ try testing.expect(!glossary.contains(0xE000));
+ try testing.expect(!glossary.contains(0xE001));
+
+ try glossary.register(alloc, 0xE002, try testRegisterEntry(alloc, 0xE002));
+ try testing.expectEqual(@as(usize, 1), glossary.entries.count());
+ try testing.expect(glossary.contains(0xE002));
+}
+
+test "Glossary contains reports registered slots" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ try testing.expect(!glossary.contains(0xE000));
+
+ try glossary.register(alloc, 0xE000, try testRegisterEntry(alloc, 0xE000));
+ try testing.expect(glossary.contains(0xE000));
+ try testing.expect(!glossary.contains(0xE001));
+}
diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig
new file mode 100644
index 00000000..059029a6
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/apc/glyph/execute.zig
@@ -0,0 +1,367 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+
+const request = @import("request.zig");
+const response = @import("response.zig");
+const Glossary = @import("Glossary.zig");
+const Request = request.Request;
+const Response = response.Response;
+
+const log = std.log.scoped(.glyph);
+
+/// Payload formats we support. Hardcoded because the support is
+/// fixed.
+pub const supported_formats: response.Response.Support.Formats = .{
+ .glyf = true,
+};
+
+/// Execute a Glyph protocol request against the given state.
+///
+/// This will never fail, but the response may indiciate an error and
+/// the terminal state may not be updated to reflect the command. This will
+/// never put the terminal in a corrupt or non-recoverable state.
+///
+/// For example, allocation errors can happen, but they're wrapped up in
+/// an out of memory response.
+///
+/// Query responses only report glossary coverage. Callers that can determine
+/// system font coverage must update the returned query response before sending
+/// it to the client.
+pub fn execute(
+ alloc: Allocator,
+ glossary: *Glossary,
+ req: *const Request,
+) ?Response {
+ log.debug("executing glyph protocol request: {t}", .{req.*});
+ return switch (req.*) {
+ .support => .{ .support = .{ .fmt = supported_formats } },
+ .query => |qry| query(glossary, qry),
+ .register => |reg| register(alloc, glossary, reg),
+ .clear => |clr| clear(alloc, glossary, clr),
+ };
+}
+
+fn query(
+ glossary: *Glossary,
+ qry: Request.Query,
+) ?Response {
+ const cp = qry.get(.cp) orelse return null;
+ return .{ .query = .{
+ .cp = cp,
+ .status = .{
+ .glossary = glossary.contains(cp),
+ },
+ } };
+}
+
+fn register(
+ alloc: Allocator,
+ glossary: *Glossary,
+ reg: Request.Register,
+) ?Response {
+ const reply = reg.get(.reply) orelse .all;
+ const cp = registerFallible(alloc, glossary, reg) catch |err| return switch (reply) {
+ .none => null,
+ .all, .failures => .{ .register = .{
+ .cp = reg.get(.cp) orelse 0,
+ .status = .err,
+ .reason = switch (err) {
+ error.OutOfMemory => .{ .other = "out_of_memory" },
+ error.OutOfNamespace => .out_of_namespace,
+ error.PayloadTooLarge => .payload_too_large,
+ error.MalformedPayload => .malformed_payload,
+ error.CompositeUnsupported => .composite_unsupported,
+ error.HintingUnsupported => .hinting_unsupported,
+ error.InvalidOptions,
+ error.UnsupportedFormat,
+ => .malformed_payload,
+ },
+ } },
+ };
+
+ return switch (reply) {
+ .none, .failures => null,
+ .all => .{ .register = .{ .cp = cp } },
+ };
+}
+
+fn registerFallible(
+ alloc: Allocator,
+ glossary: *Glossary,
+ reg: Request.Register,
+) (Glossary.Entry.InitError || Glossary.RegisterError)!u21 {
+ const cp = reg.get(.cp) orelse
+ return error.MalformedPayload;
+
+ var entry = try Glossary.Entry.init(alloc, reg);
+ errdefer entry.deinit(alloc);
+
+ try glossary.register(alloc, cp, entry);
+ return cp;
+}
+
+fn clear(
+ alloc: Allocator,
+ glossary: *Glossary,
+ clr: Request.Clear,
+) ?Response {
+ if (clr.get(.cp)) |cp| {
+ glossary.delete(alloc, cp) catch |err| return .{ .clear = .{
+ .status = .err,
+ .reason = switch (err) {
+ error.OutOfNamespace => "out_of_namespace",
+ },
+ } };
+ } else if (clr.has(.cp)) {
+ return .{ .clear = .{
+ .status = .err,
+ .reason = "malformed_payload",
+ } };
+ } else {
+ glossary.clearAndFree(alloc);
+ }
+
+ return .{ .clear = .{} };
+}
+
+fn testParse(alloc: Allocator, data: []const u8) !Request {
+ var parser = request.CommandParser.init(alloc, 1024 * 1024);
+ defer parser.deinit();
+ for (data) |byte| try parser.feed(byte);
+ return try parser.complete(alloc);
+}
+
+fn testExecute(alloc: Allocator, glossary: *Glossary, req: *const Request) ?Response {
+ return execute(alloc, glossary, req);
+}
+
+test "execute register stores glyph and returns success" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var req = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
+ defer req.deinit(alloc);
+
+ try testing.expectEqual(Response{
+ .register = .{ .cp = 0xE0A0 },
+ }, testExecute(alloc, &glossary, &req).?);
+ try testing.expect(glossary.contains(0xE0A0));
+}
+
+test "execute register reply failures suppresses success" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var req = try testParse(alloc, "r;cp=e0a0;reply=2;AAAAAAAAAAAAAA==");
+ defer req.deinit(alloc);
+
+ try testing.expect(testExecute(alloc, &glossary, &req) == null);
+ try testing.expect(glossary.contains(0xE0A0));
+}
+
+test "execute register reply none suppresses failure" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var req = try testParse(alloc, "r;cp=41;reply=0;%%%not-base64%%%");
+ defer req.deinit(alloc);
+
+ try testing.expect(testExecute(alloc, &glossary, &req) == null);
+ try testing.expect(!glossary.contains('A'));
+}
+
+test "execute register rejects non-PUA" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var req = try testParse(alloc, "r;cp=41;AAAAAAAAAAAAAA==");
+ defer req.deinit(alloc);
+
+ try testing.expectEqual(Response{
+ .register = .{
+ .cp = 'A',
+ .status = .err,
+ .reason = .out_of_namespace,
+ },
+ }, testExecute(alloc, &glossary, &req).?);
+ try testing.expect(!glossary.contains('A'));
+}
+
+test "execute register reports malformed payload" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var req = try testParse(alloc, "r;cp=e0a0;%%%not-base64%%%");
+ defer req.deinit(alloc);
+
+ try testing.expectEqual(Response{
+ .register = .{
+ .cp = 0xE0A0,
+ .status = .err,
+ .reason = .malformed_payload,
+ },
+ }, testExecute(alloc, &glossary, &req).?);
+ try testing.expect(!glossary.contains(0xE0A0));
+}
+
+test "execute clear removes all glyphs" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
+ defer reg1.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg1);
+
+ var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
+ defer reg2.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg2);
+
+ var req = try testParse(alloc, "c");
+ defer req.deinit(alloc);
+
+ try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?);
+ try testing.expect(!glossary.contains(0xE0A0));
+ try testing.expect(!glossary.contains(0xE0A1));
+}
+
+test "execute clear removes one glyph" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
+ defer reg1.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg1);
+
+ var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
+ defer reg2.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg2);
+
+ var req = try testParse(alloc, "c;cp=e0a0");
+ defer req.deinit(alloc);
+
+ try testing.expectEqual(Response{ .clear = .{} }, testExecute(alloc, &glossary, &req).?);
+ try testing.expect(!glossary.contains(0xE0A0));
+ try testing.expect(glossary.contains(0xE0A1));
+}
+
+test "execute clear rejects non-PUA" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var req = try testParse(alloc, "c;cp=41");
+ defer req.deinit(alloc);
+
+ try testing.expectEqual(Response{
+ .clear = .{
+ .status = .err,
+ .reason = "out_of_namespace",
+ },
+ }, testExecute(alloc, &glossary, &req).?);
+}
+
+test "execute clear rejects malformed cp without clearing glossary" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var reg1 = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
+ defer reg1.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg1);
+
+ var reg2 = try testParse(alloc, "r;cp=e0a1;AAAAAAAAAAAAAA==");
+ defer reg2.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg2);
+
+ for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| {
+ var req = try testParse(alloc, data);
+ defer req.deinit(alloc);
+
+ try testing.expectEqual(Response{
+ .clear = .{
+ .status = .err,
+ .reason = "malformed_payload",
+ },
+ }, testExecute(alloc, &glossary, &req).?);
+ try testing.expect(glossary.contains(0xE0A0));
+ try testing.expect(glossary.contains(0xE0A1));
+ }
+}
+
+test "execute query reports no coverage" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var req = try testParse(alloc, "q;cp=e0a0");
+ defer req.deinit(alloc);
+
+ try testing.expectEqual(Response{
+ .query = .{
+ .cp = 0xE0A0,
+ .status = .{},
+ },
+ }, testExecute(alloc, &glossary, &req).?);
+}
+
+test "execute query reports glossary coverage" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var reg = try testParse(alloc, "r;cp=e0a0;AAAAAAAAAAAAAA==");
+ defer reg.deinit(alloc);
+ _ = testExecute(alloc, &glossary, &reg);
+
+ var req = try testParse(alloc, "q;cp=e0a0");
+ defer req.deinit(alloc);
+
+ try testing.expectEqual(Response{
+ .query = .{
+ .cp = 0xE0A0,
+ .status = .{ .glossary = true },
+ },
+ }, testExecute(alloc, &glossary, &req).?);
+}
+
+test "execute query without cp returns no response" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var glossary: Glossary = .empty;
+ defer glossary.deinit(alloc);
+
+ var req = try testParse(alloc, "q;foo=bar");
+ defer req.deinit(alloc);
+
+ try testing.expect(testExecute(alloc, &glossary, &req) == null);
+}
diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig
new file mode 100644
index 00000000..71a2dd4b
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/apc/glyph/request.zig
@@ -0,0 +1,855 @@
+const std = @import("std");
+const Allocator = std.mem.Allocator;
+const assert = std.debug.assert;
+const Glyf = @import("../../../font/opentype/glyf.zig").Glyf;
+
+/// Maximum decoded glyph payload size accepted by the protocol.
+/// This is documented in the spec.
+const max_payload_size = 64 * 1024; // 64 KiB
+
+/// Stateful parser for a single glyph APC payload after the `25a1;` prefix.
+pub const CommandParser = struct {
+ alloc: Allocator,
+ data: std.ArrayList(u8) = .empty,
+
+ /// Maximum bytes the data payload can buffer. This is to prevent
+ /// malicious input from causing us to allocate too much memory.
+ max_bytes: usize,
+
+ pub const Error = Allocator.Error || error{InvalidFormat};
+
+ /// Create a glyph APC parser that buffers the raw command bytes.
+ pub fn init(alloc: Allocator, max_bytes: usize) CommandParser {
+ return .{ .alloc = alloc, .max_bytes = max_bytes };
+ }
+
+ /// Release any buffered command bytes owned by the parser.
+ pub fn deinit(self: *CommandParser) void {
+ self.data.deinit(self.alloc);
+ }
+
+ /// Append one more byte of APC payload to the buffered command.
+ pub fn feed(self: *CommandParser, byte: u8) Allocator.Error!void {
+ if (self.data.items.len >= self.max_bytes) return error.OutOfMemory;
+ try self.data.append(self.alloc, byte);
+ }
+
+ /// Append a slice of APC payload bytes to the buffered command.
+ /// Equivalent to calling feed for each byte, but appends in bulk.
+ pub fn feedSlice(self: *CommandParser, bytes: []const u8) Allocator.Error!void {
+ if (self.data.items.len + bytes.len > self.max_bytes) return error.OutOfMemory;
+ try self.data.appendSlice(self.alloc, bytes);
+ }
+
+ /// Finish parsing and return an owned request that can outlive the parser.
+ pub fn complete(self: *CommandParser, alloc: Allocator) Error!Request {
+ // Normalize bare single-byte verbs like `s` into `s;` so the parsed
+ // command always has the standard `verb;...` layout.
+ if (self.data.items.len == 1) try self.data.append(self.alloc, ';');
+
+ const raw = try self.data.toOwnedSlice(alloc);
+
+ // Ownership of the buffered bytes has moved to `raw`, so clear the
+ // array list before we build the final command value.
+ self.data = .empty;
+ errdefer alloc.free(raw);
+ return try Request.parse(alloc, raw);
+ }
+};
+
+/// Parsed glyph APC request with the verb classified eagerly.
+pub const Request = union(enum) {
+ /// Support query (bare `s` verb, no options).
+ support,
+
+ /// Codepoint coverage query.
+ query: Query,
+
+ /// Glyph registration request.
+ register: Register,
+
+ /// Registration clear request.
+ clear: Clear,
+
+ /// Query verb payload with lazily-decoded options.
+ pub const Query = struct {
+ raw: []const u8,
+
+ /// Initialize a query command from owned raw command bytes.
+ pub fn init(raw: []const u8) Query {
+ return .{ .raw = raw };
+ }
+
+ /// Options recognized for the glyph query request.
+ pub const Option = enum {
+ /// Target Unicode codepoint encoded in hexadecimal.
+ cp,
+
+ /// Return the decoded Zig type for a query option.
+ pub fn Type(comptime self: Option) type {
+ return switch (self) {
+ .cp => u21,
+ };
+ }
+
+ /// Return the wire-format option key for this query option.
+ fn key(comptime self: Option) []const u8 {
+ return @tagName(self);
+ }
+
+ /// Read and decode a query option from the raw option string.
+ pub fn read(comptime self: Option, raw: []const u8) ?self.Type() {
+ const value = optionValue(raw, self.key()) orelse return null;
+ return switch (self) {
+ .cp => std.fmt.parseInt(u21, value, 16) catch null,
+ };
+ }
+ };
+
+ /// Lazily decode a query option on demand.
+ pub fn get(self: Query, comptime option: Option) ?option.Type() {
+ return option.read(self.rawOptions());
+ }
+
+ /// Return the raw option portion of a valid query command.
+ fn rawOptions(self: Query) []const u8 {
+ assert(self.raw.len >= 2);
+ assert(self.raw[0] == 'q');
+ assert(self.raw[1] == ';');
+ return self.raw[2..];
+ }
+ };
+
+ /// Register verb payload with lazily-decoded options and optional base64 data.
+ pub const Register = struct {
+ raw: []const u8,
+ payload_idx: usize,
+
+ /// Initialize a register command from owned raw command bytes.
+ pub fn init(raw: []const u8) ?Register {
+ if (raw.len < 2) return null;
+ if (raw[0] != 'r') return null;
+ if (raw[1] != ';') return null;
+ const payload_idx = std.mem.lastIndexOfScalar(u8, raw, ';') orelse return null;
+ if (payload_idx <= 1) return null;
+
+ return .{
+ .raw = raw,
+ .payload_idx = payload_idx,
+ };
+ }
+
+ /// Options recognized for the glyph register verb.
+ pub const Option = enum {
+ /// Target Unicode codepoint encoded in hexadecimal.
+ cp,
+
+ /// Glyph payload format.
+ fmt,
+
+ /// Requested reply verbosity for registration.
+ reply,
+
+ /// Units-per-em for the glyph coordinate system.
+ upm,
+
+ /// Authored advance width in units-per-em units.
+ aw,
+
+ /// Authored line height in units-per-em units.
+ lh,
+
+ /// Unicode cell width for terminal layout.
+ width,
+
+ /// Glyph scale policy.
+ size,
+
+ /// Glyph placement within the render span.
+ @"align",
+
+ /// Fractional insets from the render span edges.
+ pad,
+
+ /// Return the decoded Zig type for a register option.
+ pub fn Type(comptime self: Option) type {
+ return switch (self) {
+ .cp => u21,
+ .fmt => Format,
+ .reply => Reply,
+ .upm => u32,
+ .aw => u32,
+ .lh => u32,
+ .width => Width,
+ .size => Size,
+ .@"align" => Align,
+ .pad => Pad,
+ };
+ }
+
+ /// Return the protocol default value for this option, if any.
+ pub fn default(comptime self: Option) ?self.Type() {
+ return switch (self) {
+ .cp => null,
+ .fmt => .glyf,
+ .reply => .all,
+ .upm => 1000,
+ .aw => null,
+ .lh => null,
+ .width => .narrow,
+ .size => .height,
+ .@"align" => .{},
+ .pad => .{},
+ };
+ }
+
+ /// Return the wire-format option key for this register option.
+ fn key(comptime self: Option) []const u8 {
+ return @tagName(self);
+ }
+
+ /// Read and decode a register option from the raw option string.
+ pub fn read(comptime self: Option, raw: []const u8) ?self.Type() {
+ const value = optionValue(raw, self.key()) orelse return null;
+ return switch (self) {
+ .cp => std.fmt.parseInt(u21, value, 16) catch null,
+ .fmt => Format.init(value),
+ .reply => Reply.init(value) orelse .all,
+ .upm => std.fmt.parseInt(u32, value, 10) catch null,
+ .aw => std.fmt.parseInt(u32, value, 10) catch null,
+ .lh => std.fmt.parseInt(u32, value, 10) catch null,
+ .width => Width.init(value),
+ .size => Size.init(value),
+ .@"align" => Align.init(value),
+ .pad => Pad.init(value),
+ };
+ }
+ };
+
+ /// Lazily decode a register option on demand, applying protocol
+ /// defaults when the option is omitted.
+ pub fn get(self: Register, comptime option: Option) ?option.Type() {
+ const raw = self.rawOptions();
+ if (optionValue(raw, option.key()) == null) {
+ return switch (option) {
+ .aw, .lh => self.get(.upm),
+ else => option.default(),
+ };
+ }
+ return option.read(raw);
+ }
+
+ /// Return the base64 payload carried by a register request.
+ ///
+ /// If no payload is present, this returns an empty slice. The returned
+ /// bytes may still be invalid base64; this function only exposes the raw
+ /// payload segment and does not validate or decode it.
+ pub fn payload(self: Register) []const u8 {
+ assert(self.raw.len >= 2);
+ assert(self.raw[0] == 'r');
+ assert(self.raw[1] == ';');
+ return if (self.payload_idx == self.raw.len)
+ ""
+ else
+ self.raw[self.payload_idx + 1 ..];
+ }
+
+ /// Errors that can occur while decoding a register glyph payload.
+ pub const DecodeError = Allocator.Error || error{
+ /// The decoded payload exceeds the protocol limit.
+ PayloadTooLarge,
+
+ /// The payload could not be decoded or parsed as the declared format.
+ MalformedPayload,
+
+ /// The glyf payload is composite, which the protocol forbids.
+ CompositeUnsupported,
+
+ /// The glyf payload contains hinting instructions, which the
+ /// protocol forbids.
+ HintingUnsupported,
+ };
+
+ /// Decode this request's base64 glyf payload into an owned outline.
+ pub fn decodeGlyfPayload(self: Register, alloc: Allocator) DecodeError!Glyf.Outline {
+ // Prep base64 decoding, initial validation.
+ const Decoder = std.base64.standard.Decoder;
+ const payload_bytes = self.payload();
+ const size = Decoder.calcSizeForSlice(payload_bytes) catch
+ return error.MalformedPayload;
+ if (size > max_payload_size) return error.PayloadTooLarge;
+
+ // Max payload size is reasonable for stack and its likely
+ // we'll have stack space. We don't use much stack space in
+ // the future function calls either, so try a stack allocator
+ // here and fallback to heap as necessary.
+ var data_stack = std.heap.stackFallback(
+ max_payload_size,
+ alloc,
+ );
+ const data_alloc = data_stack.get();
+ const data = try data_alloc.alloc(u8, size);
+ defer data_alloc.free(data);
+
+ // Base64 decode
+ Decoder.decode(data, payload_bytes) catch
+ return error.MalformedPayload;
+
+ // Glyf.Entry borrows from `data`, but only for the duration of the
+ // decode call below. Glyf.Entry.decode returns an owned Outline, so
+ // it is safe to free `data` before returning that outline.
+ const glyf_entry = Glyf.Entry.init(data) catch return error.MalformedPayload;
+ return glyf_entry.decode(alloc) catch |err| switch (err) {
+ error.OutOfMemory => error.OutOfMemory,
+ // Unsupported fields
+ error.CompositeNotSupported => error.CompositeUnsupported,
+ error.InstructionsNotSupported => error.HintingUnsupported,
+ // Various semantic issues
+ error.EndOfStream,
+ error.EndPointsOutOfOrder,
+ error.TooManyPoints,
+ error.CoordinateOverflow,
+ => error.MalformedPayload,
+ };
+ }
+
+ /// Return the raw option portion of a valid register command.
+ fn rawOptions(self: Register) []const u8 {
+ assert(self.raw.len >= 2);
+ assert(self.raw[0] == 'r');
+ assert(self.raw[1] == ';');
+ assert(self.payload_idx >= 2);
+ assert(self.payload_idx <= self.raw.len);
+ return self.raw[2..self.payload_idx];
+ }
+ };
+
+ /// Clear verb payload with lazily-decoded options.
+ pub const Clear = struct {
+ raw: []const u8,
+
+ /// Initialize a clear command from owned raw command bytes.
+ pub fn init(raw: []const u8) Clear {
+ return .{ .raw = raw };
+ }
+
+ /// Options recognized for the glyph clear request.
+ pub const Option = enum {
+ /// Target Unicode codepoint encoded in hexadecimal.
+ cp,
+
+ /// Return the decoded Zig type for a clear option.
+ pub fn Type(comptime self: Option) type {
+ return switch (self) {
+ .cp => u21,
+ };
+ }
+
+ /// Return the wire-format option key for this clear option.
+ fn key(comptime self: Option) []const u8 {
+ return @tagName(self);
+ }
+
+ /// Read and decode a clear option from the raw option string.
+ pub fn read(comptime self: Option, raw: []const u8) ?self.Type() {
+ const value = optionValue(raw, self.key()) orelse return null;
+ return switch (self) {
+ .cp => std.fmt.parseInt(u21, value, 16) catch null,
+ };
+ }
+
+ /// Return whether the option is present in the raw option string,
+ /// independent of whether its value can be decoded.
+ pub fn present(comptime self: Option, raw: []const u8) bool {
+ return optionValue(raw, self.key()) != null;
+ }
+ };
+
+ /// Lazily decode a clear option on demand.
+ pub fn get(self: Clear, comptime option: Option) ?option.Type() {
+ return option.read(self.rawOptions());
+ }
+
+ /// Return whether a clear option was provided, even if malformed.
+ pub fn has(self: Clear, comptime option: Option) bool {
+ return option.present(self.rawOptions());
+ }
+
+ /// Return the raw option portion of a valid clear command.
+ fn rawOptions(self: Clear) []const u8 {
+ assert(self.raw.len >= 2);
+ assert(self.raw[0] == 'c');
+ assert(self.raw[1] == ';');
+ return self.raw[2..];
+ }
+ };
+
+ /// Parse an owned glyph APC payload into its eagerly-classified request
+ /// form.
+ ///
+ /// The raw format here is strict on its requirements to avoid
+ /// edge cases: it must contain the request AND the request must
+ /// end in a semicolon (even if there are no options). The spec itself
+ /// does not require this but we artificially insert it in our parser
+ /// to simplify parsing later.
+ pub fn parse(alloc: Allocator, raw: []const u8) error{InvalidFormat}!Request {
+ if (raw.len < 2) return error.InvalidFormat;
+ if (raw[1] != ';') return error.InvalidFormat;
+
+ return switch (raw[0]) {
+ 's' => {
+ alloc.free(raw);
+ return .support;
+ },
+ 'q' => .{ .query = .init(raw) },
+ 'r' => .{ .register = Register.init(raw) orelse return error.InvalidFormat },
+ 'c' => .{ .clear = .init(raw) },
+ else => error.InvalidFormat,
+ };
+ }
+
+ /// Free the raw bytes retained by any request variant.
+ pub fn deinit(self: *Request, alloc: Allocator) void {
+ switch (self.*) {
+ .support => {},
+ inline else => |*cmd| if (cmd.raw.len > 0) alloc.free(cmd.raw),
+ }
+ }
+};
+
+/// Glyph payload formats named by the protocol.
+pub const Format = enum {
+ /// TrueType simple glyph outline data.
+ glyf,
+
+ /// OpenType COLR version 0 layered color glyph data.
+ colrv0,
+
+ /// OpenType COLR version 1 paint graph glyph data.
+ colrv1,
+
+ /// Parse a glyph payload format name.
+ pub fn init(value: []const u8) ?Format {
+ return std.meta.stringToEnum(Format, value);
+ }
+};
+
+/// Register command reply verbosity.
+pub const Reply = enum(u2) {
+ /// Suppress both success and failure replies.
+ none = 0,
+
+ /// Emit replies for both success and failure cases.
+ all = 1,
+
+ /// Emit replies only for failure cases.
+ failures = 2,
+
+ /// Parse the register command reply mode from its single-digit encoding.
+ pub fn init(value: []const u8) ?Reply {
+ if (value.len != 1) return null;
+ return switch (value[0]) {
+ '0' => .none,
+ '1' => .all,
+ '2' => .failures,
+ else => null,
+ };
+ }
+};
+
+/// Register command width override for terminal layout.
+pub const Width = enum(u2) {
+ /// One terminal cell.
+ narrow = 1,
+
+ /// Two terminal cells.
+ wide = 2,
+
+ /// Parse the register command width from its single-digit encoding.
+ pub fn init(value: []const u8) ?Width {
+ if (value.len != 1) return null;
+ return switch (value[0]) {
+ '1' => .narrow,
+ '2' => .wide,
+ else => null,
+ };
+ }
+};
+
+/// Register command glyph scale policy.
+pub const Size = enum {
+ height,
+ advance,
+ contain,
+ cover,
+ stretch,
+
+ /// Parse a glyph scale policy name.
+ pub fn init(value: []const u8) ?Size {
+ return std.meta.stringToEnum(Size, value);
+ }
+};
+
+/// Register command glyph placement within the render span.
+pub const Align = struct {
+ horizontal: Horizontal = .center,
+ vertical: Vertical = .center,
+
+ pub const Horizontal = enum {
+ start,
+ center,
+ end,
+
+ fn init(value: []const u8) ?Horizontal {
+ return std.meta.stringToEnum(Horizontal, value);
+ }
+ };
+
+ pub const Vertical = enum {
+ start,
+ center,
+ end,
+ baseline,
+
+ fn init(value: []const u8) ?Vertical {
+ return std.meta.stringToEnum(Vertical, value);
+ }
+ };
+
+ /// Parse an align value in `<horizontal>,<vertical>` form.
+ pub fn init(value: []const u8) ?Align {
+ var it = std.mem.splitScalar(u8, value, ',');
+ const horizontal = Horizontal.init(it.next() orelse return null) orelse return null;
+ const vertical = Vertical.init(it.next() orelse return null) orelse return null;
+ if (it.next() != null) return null;
+
+ return .{
+ .horizontal = horizontal,
+ .vertical = vertical,
+ };
+ }
+};
+
+/// Register command fractional insets from the render span edges.
+pub const Pad = struct {
+ top: f64 = 0,
+ right: f64 = 0,
+ bottom: f64 = 0,
+ left: f64 = 0,
+
+ /// Parse a pad value in `<top>,<right>,<bottom>,<left>` form.
+ pub fn init(value: []const u8) ?Pad {
+ var it = std.mem.splitScalar(u8, value, ',');
+ const top = parseFraction(it.next() orelse return null) orelse return null;
+ const right = parseFraction(it.next() orelse return null) orelse return null;
+ const bottom = parseFraction(it.next() orelse return null) orelse return null;
+ const left = parseFraction(it.next() orelse return null) orelse return null;
+ if (it.next() != null) return null;
+
+ // Glyph Protocol §8.5.2: "If `l + r ≥ 1` or `t + b ≥ 1`
+ // the terminal MUST treat the request as if `pad=0,0,0,0`."
+ if (left + right >= 1 or top + bottom >= 1) return .{};
+
+ return .{
+ .top = top,
+ .right = right,
+ .bottom = bottom,
+ .left = left,
+ };
+ }
+
+ /// Parse one pad component from the spec's `0.0`–`1.0` fractional range.
+ /// Top/bottom fractions are relative to cell height; left/right fractions
+ /// are relative to render span width.
+ fn parseFraction(value: []const u8) ?f64 {
+ const result = std.fmt.parseFloat(f64, value) catch return null;
+ if (!(result >= 0 and result <= 1)) return null;
+ return result;
+ }
+};
+
+/// Find the last occurrence of `key=value` for a lazily-parsed option list.
+fn optionValue(raw: []const u8, comptime key: []const u8) ?[]const u8 {
+ var remaining = raw;
+ var result: ?[]const u8 = null;
+ while (remaining.len > 0) {
+ // Options are semicolon-delimited, so each loop peels off one segment
+ // and checks whether it matches the requested key.
+ const len = std.mem.indexOfScalar(u8, remaining, ';') orelse remaining.len;
+ const full = remaining[0..len];
+
+ if (std.mem.indexOfScalar(u8, full, '=')) |eql_idx| {
+ if (std.mem.eql(u8, full[0..eql_idx], key)) {
+ result = full[eql_idx + 1 ..];
+ }
+ }
+
+ if (len == remaining.len) break;
+ remaining = remaining[len + 1 ..];
+ }
+
+ return result;
+}
+
+fn testParse(alloc: Allocator, data: []const u8) CommandParser.Error!Request {
+ var parser = CommandParser.init(alloc, 1024 * 1024);
+ defer parser.deinit();
+ for (data) |byte| try parser.feed(byte);
+ return try parser.complete(alloc);
+}
+
+test "support command" {
+ const testing = std.testing;
+
+ var cmd = try testParse(testing.allocator, "s");
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .support);
+}
+
+test "query command" {
+ const testing = std.testing;
+
+ var cmd = try testParse(testing.allocator, "q;cp=E0A0");
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .query);
+ try testing.expectEqual(@as(u21, 0xE0A0), cmd.query.get(.cp).?);
+}
+
+test "register command with payload" {
+ const testing = std.testing;
+
+ var cmd = try testParse(
+ testing.allocator,
+ "r;cp=e0a0;fmt=glyf;upm=1000;reply=2;QQ==",
+ );
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .register);
+ try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?);
+ try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?);
+ try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?);
+ try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?);
+ try testing.expectEqualStrings("QQ==", cmd.register.payload());
+}
+
+test "register command with sizing and placement options" {
+ const testing = std.testing;
+
+ var cmd = try testParse(
+ testing.allocator,
+ "r;cp=e0a0;upm=2048;aw=1024;lh=1536;width=2;size=contain;align=end,baseline;pad=0.1,0.2,0.3,0.4;QQ==",
+ );
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .register);
+ try testing.expectEqual(@as(u32, 2048), cmd.register.get(.upm).?);
+ try testing.expectEqual(@as(u32, 1024), cmd.register.get(.aw).?);
+ try testing.expectEqual(@as(u32, 1536), cmd.register.get(.lh).?);
+ try testing.expectEqual(Width.wide, cmd.register.get(.width).?);
+ try testing.expectEqual(Size.contain, cmd.register.get(.size).?);
+ try testing.expectEqual(Align{
+ .horizontal = .end,
+ .vertical = .baseline,
+ }, cmd.register.get(.@"align").?);
+ try testing.expectEqual(Pad{
+ .top = 0.1,
+ .right = 0.2,
+ .bottom = 0.3,
+ .left = 0.4,
+ }, cmd.register.get(.pad).?);
+ try testing.expectEqualStrings("QQ==", cmd.register.payload());
+}
+
+test "register option defaults" {
+ const testing = std.testing;
+ const Option = Request.Register.Option;
+
+ try testing.expect(Option.cp.default() == null);
+ try testing.expectEqual(Format.glyf, Option.fmt.default().?);
+ try testing.expectEqual(@as(u32, 1000), Option.upm.default().?);
+ try testing.expect(Option.aw.default() == null);
+ try testing.expect(Option.lh.default() == null);
+ try testing.expectEqual(Width.narrow, Option.width.default().?);
+ try testing.expectEqual(Size.height, Option.size.default().?);
+ try testing.expectEqual(Align{}, Option.@"align".default().?);
+ try testing.expectEqual(Pad{}, Option.pad.default().?);
+ try testing.expectEqual(Reply.all, Option.reply.default().?);
+}
+
+test "register command defaults" {
+ const testing = std.testing;
+
+ var cmd = try testParse(testing.allocator, "r;cp=e0a0;QQ==");
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .register);
+ try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?);
+ try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?);
+ try testing.expectEqual(@as(u32, 1000), cmd.register.get(.upm).?);
+ try testing.expectEqual(@as(u32, 1000), cmd.register.get(.aw).?);
+ try testing.expectEqual(@as(u32, 1000), cmd.register.get(.lh).?);
+ try testing.expectEqual(Width.narrow, cmd.register.get(.width).?);
+ try testing.expectEqual(Size.height, cmd.register.get(.size).?);
+ try testing.expectEqual(Align{}, cmd.register.get(.@"align").?);
+ try testing.expectEqual(Pad{}, cmd.register.get(.pad).?);
+ try testing.expectEqual(Reply.all, cmd.register.get(.reply).?);
+}
+
+test "register command aw and lh default to upm" {
+ const testing = std.testing;
+
+ var cmd = try testParse(testing.allocator, "r;cp=e0a0;upm=2048;QQ==");
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .register);
+ try testing.expectEqual(@as(u32, 2048), cmd.register.get(.aw).?);
+ try testing.expectEqual(@as(u32, 2048), cmd.register.get(.lh).?);
+}
+
+test "register command invalid sizing and placement options" {
+ const testing = std.testing;
+
+ var cmd = try testParse(
+ testing.allocator,
+ "r;cp=e0a0;width=3;size=invalid;align=center,middle;pad=0,1.2,0,0;QQ==",
+ );
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .register);
+ try testing.expect(cmd.register.get(.width) == null);
+ try testing.expect(cmd.register.get(.size) == null);
+ try testing.expect(cmd.register.get(.@"align") == null);
+ try testing.expect(cmd.register.get(.pad) == null);
+}
+
+test "register command degenerate padding defaults to no padding" {
+ const testing = std.testing;
+
+ var cmd = try testParse(
+ testing.allocator,
+ "r;cp=e0a0;pad=0.4,0.2,0.6,0.1;QQ==",
+ );
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .register);
+ try testing.expectEqual(Pad{}, cmd.register.get(.pad).?);
+}
+
+test "register command invalid reply falls back to reply=1" {
+ const testing = std.testing;
+
+ var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=9;QQ==");
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .register);
+ try testing.expectEqual(Reply.all, cmd.register.get(.reply).?);
+}
+
+test "register command duplicate options use the last value" {
+ const testing = std.testing;
+
+ var cmd = try testParse(testing.allocator, "r;cp=e0a0;reply=1;reply=2;QQ==");
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .register);
+ try testing.expectEqual(Reply.failures, cmd.register.get(.reply).?);
+}
+
+test "register command with invalid payload" {
+ const testing = std.testing;
+
+ var cmd = try testParse(
+ testing.allocator,
+ "r;cp=e0a0;fmt=glyf;%%%not-base64%%%",
+ );
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .register);
+ try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?);
+ try testing.expectEqual(Format.glyf, cmd.register.get(.fmt).?);
+ try testing.expectEqualStrings("%%%not-base64%%%", cmd.register.payload());
+}
+
+test "register command rejects missing payload separator" {
+ const testing = std.testing;
+
+ for ([_][]const u8{ "r", "r;cp=e0a0", "r;foo" }) |data| {
+ try testing.expectError(
+ error.InvalidFormat,
+ testParse(testing.allocator, data),
+ );
+ }
+}
+
+test "register decodes glyf payload" {
+ const testing = std.testing;
+
+ var cmd = try testParse(testing.allocator, "r;cp=e0a0;AAAAAAAAAAAAAA==");
+ defer cmd.deinit(testing.allocator);
+
+ var outline = try cmd.register.decodeGlyfPayload(testing.allocator);
+ defer outline.deinit(testing.allocator);
+
+ try testing.expectEqual(@as(usize, 0), outline.points.len);
+ try testing.expectEqual(@as(usize, 0), outline.contours.len);
+}
+
+test "register rejects malformed glyf payload" {
+ const testing = std.testing;
+
+ var cmd = try testParse(testing.allocator, "r;cp=e0a0;%%%not-base64%%%");
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expectError(error.MalformedPayload, cmd.register.decodeGlyfPayload(testing.allocator));
+}
+
+test "register response without payload" {
+ const testing = std.testing;
+
+ var cmd = try testParse(
+ testing.allocator,
+ "r;cp=E0A0;status=4;reason=out_of_namespace",
+ );
+ defer cmd.deinit(testing.allocator);
+
+ // Register parsing is request-only, so the final segment is always treated
+ // as payload rather than as a response field.
+ try testing.expect(cmd == .register);
+ try testing.expectEqual(@as(u21, 0xE0A0), cmd.register.get(.cp).?);
+ try testing.expectEqualStrings("reason=out_of_namespace", cmd.register.payload());
+}
+
+test "clear command" {
+ const testing = std.testing;
+
+ var cmd = try testParse(testing.allocator, "c;cp=e0a0");
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .clear);
+ try testing.expect(cmd.clear.has(.cp));
+ try testing.expectEqual(@as(u21, 0xE0A0), cmd.clear.get(.cp).?);
+}
+
+test "clear command tracks malformed cp presence" {
+ const testing = std.testing;
+
+ for ([_][]const u8{ "c;cp=zz", "c;cp=", "c;cp=200000" }) |data| {
+ var cmd = try testParse(testing.allocator, data);
+ defer cmd.deinit(testing.allocator);
+
+ try testing.expect(cmd == .clear);
+ try testing.expect(cmd.clear.has(.cp));
+ try testing.expect(cmd.clear.get(.cp) == null);
+ }
+}
+
+test "invalid command" {
+ const testing = std.testing;
+
+ try testing.expectError(
+ error.InvalidFormat,
+ testParse(testing.allocator, "x"),
+ );
+}
diff --git a/vendor/libghostty-vt/src/terminal/apc/glyph/response.zig b/vendor/libghostty-vt/src/terminal/apc/glyph/response.zig
new file mode 100644
index 00000000..9e40f0ea
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/apc/glyph/response.zig
@@ -0,0 +1,346 @@
+const std = @import("std");
+
+/// Query response coverage state for a codepoint.
+pub const Coverage = packed struct(u2) {
+ /// A system font covers the codepoint.
+ system: bool = false,
+
+ /// A session glyph registration covers the codepoint.
+ glossary: bool = false,
+
+ /// No system font or registered glyph covers the codepoint.
+ pub const free: Coverage = .{};
+
+ /// Parse the query response coverage list from its comma-separated form.
+ /// Unknown coverage names are ignored for forward compatibility.
+ pub fn init(value: []const u8) ?Coverage {
+ var result: Coverage = .free;
+ var it = std.mem.splitScalar(u8, value, ',');
+ while (it.next()) |name| {
+ if (std.mem.eql(u8, name, "system")) {
+ result.system = true;
+ } else if (std.mem.eql(u8, name, "glossary")) {
+ result.glossary = true;
+ }
+ }
+
+ return result;
+ }
+};
+
+/// Response to a glyph APC request, formatted for the wire protocol.
+pub const Response = union(enum) {
+ /// Recommended fixed buffer size for formatting a Glyph Protocol response.
+ ///
+ /// Glyph Protocol responses contain only framing plus bounded scalar fields:
+ /// a u21 codepoint as hex, a decimal u8 status, a small fixed set of
+ /// supported format names, coverage names, and the reason names produced by
+ /// the executor. 1024 bytes is therefore far above the longest response we
+ /// can emit today, while still being small enough for stack allocation in
+ /// stream handlers. If callers construct responses with arbitrary `.other`
+ /// or clear reason strings, they must ensure those strings fit or handle the
+ /// writer error from `formatWire`.
+ pub const max_wire_bytes = 1024;
+
+ /// Support query response listing supported payload formats.
+ support: Support,
+
+ /// Codepoint coverage query response.
+ query: Query,
+
+ /// Glyph registration response (success or error).
+ register: Register,
+
+ /// Registration clear response.
+ clear: Clear,
+
+ /// Support query response fields.
+ pub const Support = struct {
+ /// Supported payload formats.
+ fmt: Formats,
+
+ pub const Formats = packed struct(u8) {
+ /// TrueType simple glyph outlines (required in v1).
+ glyf: bool = false,
+
+ /// COLR v0 layered flat-colour glyphs.
+ colrv0: bool = false,
+
+ /// COLR v1 paint-graph glyphs.
+ colrv1: bool = false,
+
+ _padding: u5 = 0,
+ };
+ };
+
+ /// Codepoint query response fields.
+ pub const Query = struct {
+ /// The queried codepoint.
+ cp: u21,
+
+ /// Coverage status for the codepoint.
+ status: Coverage,
+ };
+
+ /// Register response fields.
+ pub const Register = struct {
+ /// The target codepoint of the registration.
+ cp: u21,
+
+ /// Result status of the registration encoded as a decimal u8.
+ status: Status = .ok,
+
+ /// Optional symbolic error reason.
+ reason: ?Reason = null,
+
+ /// Register error reason codes defined by Glyph Protocol §6.2.
+ pub const Reason = union(enum) {
+ /// `cp` is not in any PUA range.
+ out_of_namespace,
+
+ /// Payload contains composite glyphs.
+ composite_unsupported,
+
+ /// Payload contains hinting instructions.
+ hinting_unsupported,
+
+ /// Payload failed to parse as the declared `fmt`.
+ malformed_payload,
+
+ /// Payload exceeds 64 KiB after base64 decoding.
+ payload_too_large,
+
+ /// A reason code not known by this version of Ghostty.
+ other: []const u8,
+
+ /// Return the wire-format reason name.
+ pub fn name(self: Reason) []const u8 {
+ return switch (self) {
+ .out_of_namespace => "out_of_namespace",
+ .composite_unsupported => "composite_unsupported",
+ .hinting_unsupported => "hinting_unsupported",
+ .malformed_payload => "malformed_payload",
+ .payload_too_large => "payload_too_large",
+ .other => |value| value,
+ };
+ }
+ };
+ };
+
+ /// Clear response fields.
+ pub const Clear = struct {
+ /// Result status of the clear operation encoded as a decimal u8.
+ status: Status = .ok,
+
+ /// Optional symbolic error reason.
+ reason: ?[]const u8 = null,
+ };
+
+ /// Status code for register and clear responses.
+ pub const Status = enum(u8) {
+ /// The operation completed successfully.
+ ok = 0,
+
+ /// A generic or unspecified error occurred.
+ err = 1,
+
+ _,
+ };
+
+ /// Write the response in the glyph APC wire format to `writer`.
+ ///
+ /// The framing is: `ESC _ 25a1 ; <verb> ; <key=value>* ESC \`
+ pub fn formatWire(
+ self: Response,
+ writer: *std.Io.Writer,
+ ) std.Io.Writer.Error!void {
+ try writer.writeAll("\x1b_25a1;");
+ switch (self) {
+ .support => |r| {
+ // From the spec:
+ // Order is not significant; clients MUST treat the value as a
+ // set. An empty fmt= value means the terminal recognises
+ // Glyph Protocol but currently advertises no payload formats
+ // — every r will be rejected. Clients MUST ignore names they
+ // do not recognise rather than failing the reply, so future
+ // format names are forward- compatible.
+ try writer.writeAll("s;fmt=");
+ var first = true;
+ if (r.fmt.glyf) {
+ first = false;
+ try writer.writeAll("glyf");
+ }
+ if (r.fmt.colrv0) {
+ if (!first) try writer.writeByte(',');
+ first = false;
+ try writer.writeAll("colrv0");
+ }
+ if (r.fmt.colrv1) {
+ if (!first) try writer.writeByte(',');
+ first = false;
+ try writer.writeAll("colrv1");
+ }
+ },
+ .query => |r| {
+ // status is a comma-separated list of coverage names — the
+ // set of sources that can render cp in this session. Order is
+ // not significant; clients MUST treat the value as a set.
+ try writer.print("q;cp={x};status=", .{r.cp});
+ var first = true;
+ if (r.status.system) {
+ first = false;
+ try writer.writeAll("system");
+ }
+ if (r.status.glossary) {
+ if (!first) try writer.writeByte(',');
+ try writer.writeAll("glossary");
+ }
+ },
+ .register => |r| {
+ try writer.print("r;cp={x};status={d}", .{ r.cp, @intFromEnum(r.status) });
+ if (r.reason) |reason| {
+ try writer.writeAll(";reason=");
+ try writer.writeAll(reason.name());
+ }
+ },
+ .clear => |r| {
+ try writer.print("c;status={d}", .{@intFromEnum(r.status)});
+ if (r.reason) |reason| {
+ try writer.writeAll(";reason=");
+ try writer.writeAll(reason);
+ }
+ },
+ }
+ try writer.writeAll("\x1b\\");
+ }
+};
+
+test "support formats default to no advertised formats" {
+ const testing = std.testing;
+ const Formats = Response.Support.Formats;
+
+ try testing.expectEqual(@as(u8, 0), @as(u8, @bitCast(Formats{})));
+}
+
+test "response support formatWire" {
+ const testing = std.testing;
+
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ const resp: Response = .{ .support = .{ .fmt = .{ .glyf = true, .colrv0 = true } } };
+ try resp.formatWire(&writer);
+ try testing.expectEqualStrings("\x1b_25a1;s;fmt=glyf,colrv0\x1b\\", writer.buffered());
+}
+
+test "response support formatWire with no formats" {
+ const testing = std.testing;
+
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ const resp: Response = .{ .support = .{ .fmt = .{} } };
+ try resp.formatWire(&writer);
+ try testing.expectEqualStrings("\x1b_25a1;s;fmt=\x1b\\", writer.buffered());
+}
+
+test "response query formatWire" {
+ const testing = std.testing;
+
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{ .system = true, .glossary = true } } };
+ try resp.formatWire(&writer);
+ try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=system,glossary\x1b\\", writer.buffered());
+}
+
+test "response query formatWire with no coverage" {
+ const testing = std.testing;
+
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ const resp: Response = .{ .query = .{ .cp = 0xE0A0, .status = .{} } };
+ try resp.formatWire(&writer);
+ try testing.expectEqualStrings("\x1b_25a1;q;cp=e0a0;status=\x1b\\", writer.buffered());
+}
+
+test "coverage parses comma-separated names" {
+ const testing = std.testing;
+
+ try testing.expectEqual(Coverage{}, Coverage.init("").?);
+ try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system").?);
+ try testing.expectEqual(Coverage{ .glossary = true }, Coverage.init("glossary").?);
+ try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("system,glossary").?);
+ try testing.expectEqual(Coverage{ .system = true, .glossary = true }, Coverage.init("glossary,system").?);
+ try testing.expectEqual(Coverage{ .system = true }, Coverage.init("system,future").?);
+ try testing.expectEqual(Coverage{}, Coverage.init("future").?);
+}
+
+test "response register success formatWire" {
+ const testing = std.testing;
+
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ const resp: Response = .{ .register = .{ .cp = 0xE0A0 } };
+ try resp.formatWire(&writer);
+ try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=0\x1b\\", writer.buffered());
+}
+
+test "response register error formatWire" {
+ const testing = std.testing;
+
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = .err, .reason = .out_of_namespace } };
+ try resp.formatWire(&writer);
+ try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=1;reason=out_of_namespace\x1b\\", writer.buffered());
+}
+
+test "response register arbitrary status formatWire" {
+ const testing = std.testing;
+
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ const resp: Response = .{ .register = .{ .cp = 0xE0A0, .status = @enumFromInt(37), .reason = .payload_too_large } };
+ try resp.formatWire(&writer);
+ try testing.expectEqualStrings("\x1b_25a1;r;cp=e0a0;status=37;reason=payload_too_large\x1b\\", writer.buffered());
+}
+
+test "register reason names" {
+ const testing = std.testing;
+ const Reason = Response.Register.Reason;
+
+ try testing.expectEqualStrings("out_of_namespace", (Reason{ .out_of_namespace = {} }).name());
+ try testing.expectEqualStrings("composite_unsupported", (Reason{ .composite_unsupported = {} }).name());
+ try testing.expectEqualStrings("hinting_unsupported", (Reason{ .hinting_unsupported = {} }).name());
+ try testing.expectEqualStrings("malformed_payload", (Reason{ .malformed_payload = {} }).name());
+ try testing.expectEqualStrings("payload_too_large", (Reason{ .payload_too_large = {} }).name());
+ try testing.expectEqualStrings("future_reason", (Reason{ .other = "future_reason" }).name());
+}
+
+test "response clear formatWire" {
+ const testing = std.testing;
+
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ const resp: Response = .{ .clear = .{} };
+ try resp.formatWire(&writer);
+ try testing.expectEqualStrings("\x1b_25a1;c;status=0\x1b\\", writer.buffered());
+}
+
+test "response clear error formatWire" {
+ const testing = std.testing;
+
+ var buf: [256]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+
+ const resp: Response = .{ .clear = .{ .status = .err, .reason = "out_of_namespace" } };
+ try resp.formatWire(&writer);
+ try testing.expectEqualStrings("\x1b_25a1;c;status=1;reason=out_of_namespace\x1b\\", writer.buffered());
+}
diff --git a/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig b/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig
index deeecf55..787f0ace 100644
--- a/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig
+++ b/vendor/libghostty-vt/src/terminal/bitmap_allocator.zig
@@ -219,7 +219,13 @@ pub fn BitmapAllocator(comptime chunk_size: comptime_int) type {
const bitmap_start = 0;
const bitmap_end = @sizeOf(u64) * bitmap_count;
const chunks_start = alignForward(usize, bitmap_end, @alignOf(u8));
- const chunks_end = chunks_start + (aligned_cap * chunk_size);
+
+ // The chunks region must be exactly the bytes addressable by
+ // the bitmaps: one chunk per bit of every bitmap. Anything more
+ // is unreachable waste, while anything less would let alloc
+ // hand out memory beyond the region, since init marks every
+ // bitmap bit as free.
+ const chunks_end = chunks_start + (aligned_chunk_count * chunk_size);
const total_size = chunks_end;
return Layout{
@@ -452,6 +458,79 @@ test "BitmapAllocator layout" {
try testing.expectEqual(@as(usize, 1), layout.bitmap_count);
}
+test "BitmapAllocator layout chunks region matches bitmap addressable bytes" {
+ const testing = std.testing;
+
+ // The chunks region must be exactly the bytes addressable by the
+ // bitmaps: one chunk per bit of every bitmap. Prior to this being
+ // fixed, the region was over-reserved by a factor of chunk_size
+ // (~186 KiB of dead space per standard page), while capacities
+ // smaller than one full bitmap were under-reserved, allowing
+ // out-of-bounds allocations.
+ inline for (.{ 1, 2, 4, 16, 32 }) |chunk| {
+ const Alloc = BitmapAllocator(chunk);
+ for ([_]usize{
+ 1,
+ chunk,
+ chunk + 1,
+ 48,
+ 64,
+ 512,
+ 1024,
+ 2048,
+ 8192,
+ 8193,
+ }) |cap| {
+ const layout = Alloc.layout(cap);
+ const chunks_size = layout.total_size - layout.chunks_start;
+
+ // Reserved == addressable by the bitmaps. This must match
+ // capacityBytes() which is computed from the bitmap count.
+ try testing.expectEqual(
+ layout.bitmap_count * Alloc.bitmap_bit_size * chunk,
+ chunks_size,
+ );
+
+ // We always reserve at least the requested capacity.
+ try testing.expect(chunks_size >= cap);
+ }
+ }
+}
+
+test "BitmapAllocator layout small capacity cannot alloc out of bounds" {
+ // Regression test: for capacities smaller than one full bitmap of
+ // chunks, init marks all bitmap bits as free, so alloc will hand out
+ // chunks up to the full bitmap. The layout must reserve that entire
+ // addressable region or those allocations would be out of bounds of
+ // the backing buffer.
+ const Alloc = BitmapAllocator(16);
+ const cap = 48; // 3 chunks, bitmap addresses 64
+
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ const layout = Alloc.layout(cap);
+ const buf = try alloc.alignedAlloc(u8, Alloc.base_align, layout.total_size);
+ defer alloc.free(buf);
+
+ var bm = Alloc.init(.init(buf), layout);
+
+ // Allocate every chunk the bitmap can hand out and verify each is
+ // fully within the backing buffer. Writing to each allocation lets
+ // the testing allocator catch any out-of-bounds corruption.
+ const buf_start = @intFromPtr(buf.ptr);
+ const buf_end = buf_start + buf.len;
+ var count: usize = 0;
+ while (bm.alloc(u8, buf, 16)) |slice| {
+ try testing.expect(@intFromPtr(slice.ptr) >= buf_start);
+ try testing.expect(@intFromPtr(slice.ptr) + slice.len <= buf_end);
+ @memset(slice, 0xAA);
+ count += 1;
+ } else |err| {
+ try testing.expectEqual(error.OutOfMemory, err);
+ }
+ try testing.expectEqual(Alloc.bitmap_bit_size, count);
+}
+
test "BitmapAllocator alloc sequentially" {
const Alloc = BitmapAllocator(4);
const cap = 64;
diff --git a/vendor/libghostty-vt/src/terminal/c/color.zig b/vendor/libghostty-vt/src/terminal/c/color.zig
index 3d244a19..ad92883c 100644
--- a/vendor/libghostty-vt/src/terminal/c/color.zig
+++ b/vendor/libghostty-vt/src/terminal/c/color.zig
@@ -1,8 +1,46 @@
+const std = @import("std");
const lib = @import("../lib.zig");
const color = @import("../color.zig");
+const x11_color = @import("../x11_color.zig");
+const Result = @import("result.zig").Result;
+
+/// C: GhosttyColorPaletteMask
+pub const PaletteMask = extern struct {
+ bits: [4]u64,
+
+ /// Convert to the Zig PaletteMask (std.StaticBitSet(256)).
+ pub fn toZig(self: *const PaletteMask) color.PaletteMask {
+ var result = color.PaletteMask.initEmpty();
+ for (0..256) |i| {
+ if (((self.bits[i >> 6] >> @as(u6, @intCast(i & 63))) & 1) != 0) {
+ result.set(i);
+ }
+ }
+ return result;
+ }
+};
+
+/// C: GhosttyColorX11Entry
+pub const X11Entry = extern struct {
+ /// Null-terminated color name; null marks the end of the table.
+ name: ?[*:0]const u8,
+ color: color.RGB.C,
+};
+
+/// Comptime-built static table, terminated by a null-name entry.
+const x11_entries: [x11_color.entries.len + 1]X11Entry = entries: {
+ @setEvalBranchQuota(10_000);
+ var result: [x11_color.entries.len + 1]X11Entry = undefined;
+ for (x11_color.entries, 0..) |entry, i| result[i] = .{
+ .name = entry.name.ptr,
+ .color = entry.color.cval(),
+ };
+ result[x11_color.entries.len] = .{ .name = null, .color = .{ .r = 0, .g = 0, .b = 0 } };
+ break :entries result;
+};
pub fn rgb_get(
- c: color.RGB.C,
+ c: *const color.RGB.C,
r: *u8,
g: *u8,
b: *u8,
@@ -11,3 +49,538 @@ pub fn rgb_get(
g.* = c.g;
b.* = c.b;
}
+
+pub fn parse_x11(
+ name_: ?[*]const u8,
+ len: usize,
+ out: *color.RGB.C,
+) callconv(lib.calling_conv) Result {
+ const name = (name_ orelse return .invalid_value)[0..len];
+ const trimmed = std.mem.trim(u8, name, " \t");
+ const rgb = x11_color.map.get(trimmed) orelse return .invalid_value;
+ out.* = rgb.cval();
+ return .success;
+}
+
+pub fn parse(
+ value_: ?[*]const u8,
+ len: usize,
+ out: *color.RGB.C,
+) callconv(lib.calling_conv) Result {
+ const value = (value_ orelse return .invalid_value)[0..len];
+ const rgb = color.RGB.parse(value) catch return .invalid_value;
+ out.* = rgb.cval();
+ return .success;
+}
+
+pub fn palette_default(out: *color.PaletteC) callconv(lib.calling_conv) void {
+ out.* = color.paletteCval(&color.default);
+}
+
+/// Generate a 256-color palette. The output may alias the base input.
+pub fn palette_generate(
+ base_: ?*const color.PaletteC,
+ skip_: ?*const PaletteMask,
+ bg: *const color.RGB.C,
+ fg: *const color.RGB.C,
+ harmonious: bool,
+ out: *color.PaletteC,
+) callconv(lib.calling_conv) void {
+ const base: color.Palette = if (base_) |base|
+ color.paletteZval(base)
+ else
+ color.default;
+ const skip: color.PaletteMask = if (skip_) |skip|
+ skip.toZig()
+ else
+ .initEmpty();
+ const result = color.generate256Color(
+ base,
+ skip,
+ .fromC(bg.*),
+ .fromC(fg.*),
+ harmonious,
+ );
+ out.* = color.paletteCval(&result);
+}
+
+pub fn x11_names() callconv(lib.calling_conv) [*]const X11Entry {
+ return x11_entries[0..].ptr;
+}
+
+pub fn x11_name_count() callconv(lib.calling_conv) usize {
+ return x11_color.entries.len;
+}
+
+pub fn parse_palette_entry(
+ value_: ?[*]const u8,
+ len: usize,
+ out_index: *u8,
+ out_rgb: *color.RGB.C,
+) callconv(lib.calling_conv) Result {
+ const value = (value_ orelse return .invalid_value)[0..len];
+ const entry = color.parsePaletteEntry(value) catch return .invalid_value;
+ out_index.* = entry.index;
+ out_rgb.* = entry.color.cval();
+ return .success;
+}
+
+pub fn luminance(c: *const color.RGB.C) callconv(lib.calling_conv) f64 {
+ return color.RGB.fromC(c.*).luminance();
+}
+
+pub fn perceived_luminance(c: *const color.RGB.C) callconv(lib.calling_conv) f64 {
+ return color.RGB.fromC(c.*).perceivedLuminance();
+}
+
+pub fn contrast(a: *const color.RGB.C, b: *const color.RGB.C) callconv(lib.calling_conv) f64 {
+ return color.RGB.fromC(a.*).contrast(.fromC(b.*));
+}
+
+fn expectRgb(expected: color.RGB, actual: color.RGB.C) !void {
+ try std.testing.expectEqual(expected, color.RGB.fromC(actual));
+}
+
+fn expectPaletteC(expected: *const color.PaletteC, actual: *const color.PaletteC) !void {
+ for (expected.*, actual.*) |expected_rgb, actual_rgb| {
+ try expectRgb(color.RGB.fromC(expected_rgb), actual_rgb);
+ }
+}
+
+fn generatePalette(
+ base_: ?*const color.PaletteC,
+ skip_: ?*const PaletteMask,
+ bg: color.RGB,
+ fg: color.RGB,
+ harmonious: bool,
+) color.PaletteC {
+ var out: color.PaletteC = undefined;
+ const bg_c = bg.cval();
+ const fg_c = fg.cval();
+ palette_generate(base_, skip_, &bg_c, &fg_c, harmonious, &out);
+ return out;
+}
+
+fn setMaskBit(mask: *PaletteMask, idx: usize) void {
+ mask.bits[idx >> 6] |= @as(u64, 1) << @as(u6, @intCast(idx & 63));
+}
+
+test "color: parse_x11 valid" {
+ const testing = std.testing;
+
+ const cases = [_]struct {
+ input: []const u8,
+ expected: color.RGB,
+ }{
+ .{ .input = "white", .expected = .{ .r = 255, .g = 255, .b = 255 } },
+ .{ .input = "medium spring green", .expected = .{ .r = 0, .g = 250, .b = 154 } },
+ .{ .input = "ForestGreen", .expected = .{ .r = 34, .g = 139, .b = 34 } },
+ .{ .input = "FoReStGReen", .expected = .{ .r = 34, .g = 139, .b = 34 } },
+ .{ .input = " Forest Green ", .expected = .{ .r = 34, .g = 139, .b = 34 } },
+ .{ .input = "\tblack\t", .expected = .{ .r = 0, .g = 0, .b = 0 } },
+ };
+
+ for (cases) |case| {
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.success, parse_x11(case.input.ptr, case.input.len, &out));
+ try expectRgb(case.expected, out);
+ }
+}
+
+test "color: parse_x11 invalid" {
+ const testing = std.testing;
+
+ const cases = [_][]const u8{
+ "nosuchcolor",
+ "",
+ " ",
+ "#ffffff",
+ };
+
+ for (cases) |case| {
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse_x11(case.ptr, case.len, &out));
+ }
+
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse_x11(null, 0, &out));
+}
+
+test "color: parse valid" {
+ const testing = std.testing;
+
+ const cases = [_]struct {
+ input: []const u8,
+ expected: color.RGB,
+ }{
+ .{ .input = "black", .expected = .{ .r = 0, .g = 0, .b = 0 } },
+ .{ .input = "#AABBCC", .expected = .{ .r = 170, .g = 187, .b = 204 } },
+ .{ .input = "0A0B0C", .expected = .{ .r = 10, .g = 11, .b = 12 } },
+ .{ .input = "FFF", .expected = .{ .r = 255, .g = 255, .b = 255 } },
+ .{ .input = "#345", .expected = .{ .r = 51, .g = 68, .b = 85 } },
+ .{ .input = "rgb:1/2/3", .expected = .{ .r = 17, .g = 34, .b = 51 } },
+ .{ .input = " black ", .expected = .{ .r = 0, .g = 0, .b = 0 } },
+ .{ .input = " #AABBCC ", .expected = .{ .r = 170, .g = 187, .b = 204 } },
+ };
+
+ for (cases) |case| {
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.success, parse(case.input.ptr, case.input.len, &out));
+ try expectRgb(case.expected, out);
+ }
+}
+
+test "color: parse invalid" {
+ const testing = std.testing;
+
+ const cases = [_][]const u8{
+ "",
+ "notacolor",
+ "#12345",
+ };
+
+ for (cases) |case| {
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse(case.ptr, case.len, &out));
+ }
+
+ var out: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse(null, 0, &out));
+}
+
+test "color: parse_palette_entry valid" {
+ const testing = std.testing;
+
+ const cases = [_]struct {
+ input: []const u8,
+ index: u8,
+ expected: color.RGB,
+ }{
+ .{ .input = "0=#AABBCC", .index = 0, .expected = .{ .r = 170, .g = 187, .b = 204 } },
+ .{ .input = "0xF=#ABCDEF", .index = 15, .expected = .{ .r = 171, .g = 205, .b = 239 } },
+ .{ .input = "0b1=#014589", .index = 1, .expected = .{ .r = 1, .g = 69, .b = 137 } },
+ .{ .input = "0o7=#234567", .index = 7, .expected = .{ .r = 35, .g = 69, .b = 103 } },
+ .{ .input = " 1= #DDEEFF ", .index = 1, .expected = .{ .r = 221, .g = 238, .b = 255 } },
+ .{ .input = "1=black", .index = 1, .expected = .{ .r = 0, .g = 0, .b = 0 } },
+ };
+
+ for (cases) |case| {
+ var index: u8 = undefined;
+ var rgb: color.RGB.C = undefined;
+ try testing.expectEqual(.success, parse_palette_entry(
+ case.input.ptr,
+ case.input.len,
+ &index,
+ &rgb,
+ ));
+ try testing.expectEqual(case.index, index);
+ try expectRgb(case.expected, rgb);
+ }
+}
+
+test "color: parse_palette_entry invalid" {
+ const testing = std.testing;
+
+ const cases = [_][]const u8{
+ "256=#AABBCC",
+ "a",
+ "",
+ "1=notacolor",
+ };
+
+ for (cases) |case| {
+ var index: u8 = undefined;
+ var rgb: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse_palette_entry(
+ case.ptr,
+ case.len,
+ &index,
+ &rgb,
+ ));
+ }
+
+ var index: u8 = undefined;
+ var rgb: color.RGB.C = undefined;
+ try testing.expectEqual(.invalid_value, parse_palette_entry(null, 0, &index, &rgb));
+}
+
+test "color: palette_default" {
+ var out: color.PaletteC = undefined;
+ palette_default(&out);
+
+ const expected = color.paletteCval(&color.default);
+ try expectPaletteC(&expected, &out);
+
+ try expectRgb(.{ .r = 0x1D, .g = 0x1F, .b = 0x21 }, out[0]);
+ try expectRgb(.{ .r = 0, .g = 0, .b = 0 }, out[16]);
+ try expectRgb(.{ .r = 255, .g = 255, .b = 255 }, out[231]);
+ try expectRgb(.{ .r = 8, .g = 8, .b = 8 }, out[232]);
+ try expectRgb(.{ .r = 238, .g = 238, .b = 238 }, out[255]);
+}
+
+test "color: palette_generate base16 preserved" {
+ const testing = std.testing;
+
+ const base = color.paletteCval(&color.default);
+ const palette = generatePalette(
+ &base,
+ null,
+ .{ .r = 0, .g = 0, .b = 0 },
+ .{ .r = 255, .g = 255, .b = 255 },
+ false,
+ );
+
+ for (0..16) |i| {
+ try testing.expectEqual(color.RGB.fromC(base[i]), color.RGB.fromC(palette[i]));
+ }
+}
+
+test "color: palette_generate cube corners" {
+ const bg = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const fg = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const palette = generatePalette(null, null, bg, fg, false);
+
+ try expectRgb(bg, palette[16]);
+ try expectRgb(fg, palette[231]);
+}
+
+test "color: palette_generate light theme harmonious=false" {
+ const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
+
+ const normal = generatePalette(null, null, white, black, false);
+ try expectRgb(black, normal[16]);
+ try expectRgb(white, normal[231]);
+
+ const harmonious = generatePalette(null, null, white, black, true);
+ try expectRgb(white, harmonious[16]);
+ try expectRgb(black, harmonious[231]);
+}
+
+test "color: palette_generate grayscale monotonic" {
+ const testing = std.testing;
+
+ const palette = generatePalette(
+ null,
+ null,
+ .{ .r = 0, .g = 0, .b = 0 },
+ .{ .r = 255, .g = 255, .b = 255 },
+ false,
+ );
+
+ var prev_lum: f64 = 0.0;
+ for (232..256) |i| {
+ const lum = color.RGB.fromC(palette[i]).luminance();
+ try testing.expect(lum >= prev_lum);
+ prev_lum = lum;
+ }
+}
+
+test "color: palette_generate skip mask" {
+ const testing = std.testing;
+
+ const base = color.paletteCval(&color.default);
+ var skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } };
+ setMaskBit(&skip, 20);
+ setMaskBit(&skip, 100);
+ setMaskBit(&skip, 240);
+
+ const palette = generatePalette(
+ &base,
+ &skip,
+ .{ .r = 0, .g = 0, .b = 0 },
+ .{ .r = 255, .g = 255, .b = 255 },
+ false,
+ );
+
+ try testing.expectEqual(color.RGB.fromC(base[20]), color.RGB.fromC(palette[20]));
+ try testing.expectEqual(color.RGB.fromC(base[100]), color.RGB.fromC(palette[100]));
+ try testing.expectEqual(color.RGB.fromC(base[240]), color.RGB.fromC(palette[240]));
+ try testing.expect(!color.RGB.fromC(palette[21]).eql(color.RGB.fromC(base[21])));
+}
+
+test "color: palette_generate dark harmonious no-op" {
+ const testing = std.testing;
+
+ const bg = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const fg = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const normal = generatePalette(null, null, bg, fg, false);
+ const harmonious = generatePalette(null, null, bg, fg, true);
+
+ for (16..256) |i| {
+ try testing.expectEqual(color.RGB.fromC(normal[i]), color.RGB.fromC(harmonious[i]));
+ }
+}
+
+test "color: palette_generate light harmonious ramp" {
+ const testing = std.testing;
+
+ const bg = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const fg = color.RGB{ .r = 0, .g = 0, .b = 0 };
+
+ {
+ const palette = generatePalette(null, null, bg, fg, false);
+ var prev_lum: f64 = 0.0;
+ for (232..256) |i| {
+ const lum = color.RGB.fromC(palette[i]).luminance();
+ try testing.expect(lum >= prev_lum);
+ prev_lum = lum;
+ }
+ }
+
+ {
+ const palette = generatePalette(null, null, bg, fg, true);
+ var prev_lum: f64 = 1.0;
+ for (232..256) |i| {
+ const lum = color.RGB.fromC(palette[i]).luminance();
+ try testing.expect(lum <= prev_lum);
+ prev_lum = lum;
+ }
+ }
+}
+
+test "color: palette_generate NULL base uses default" {
+ const base = color.paletteCval(&color.default);
+ const bg = color.RGB{ .r = 5, .g = 10, .b = 15 };
+ const fg = color.RGB{ .r = 245, .g = 250, .b = 255 };
+
+ const null_base = generatePalette(null, null, bg, fg, false);
+ const explicit = generatePalette(&base, null, bg, fg, false);
+ try expectPaletteC(&explicit, &null_base);
+}
+
+test "color: palette_generate NULL skip" {
+ const base = color.paletteCval(&color.default);
+ const bg = color.RGB{ .r = 5, .g = 10, .b = 15 };
+ const fg = color.RGB{ .r = 245, .g = 250, .b = 255 };
+ const skip: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } };
+
+ const null_skip = generatePalette(&base, null, bg, fg, false);
+ const explicit = generatePalette(&base, &skip, bg, fg, false);
+ try expectPaletteC(&explicit, &null_skip);
+}
+
+test "color: palette_generate matches generate256Color" {
+ const base_c = color.paletteCval(&color.default);
+ const bg = color.RGB{ .r = 16, .g = 32, .b = 48 };
+ const fg = color.RGB{ .r = 240, .g = 224, .b = 208 };
+ var skip_c: PaletteMask = .{ .bits = .{ 0, 0, 0, 0 } };
+ setMaskBit(&skip_c, 20);
+ setMaskBit(&skip_c, 100);
+ setMaskBit(&skip_c, 240);
+
+ const actual = generatePalette(&base_c, &skip_c, bg, fg, true);
+ const expected_z = color.generate256Color(
+ color.paletteZval(&base_c),
+ skip_c.toZig(),
+ bg,
+ fg,
+ true,
+ );
+ const expected = color.paletteCval(&expected_z);
+ try expectPaletteC(&expected, &actual);
+}
+
+test "color: palette_generate in-place" {
+ var base = color.paletteCval(&color.default);
+ const bg = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const fg = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const expected = generatePalette(&base, null, bg, fg, false);
+
+ const bg_c = bg.cval();
+ const fg_c = fg.cval();
+ palette_generate(&base, null, &bg_c, &fg_c, false, &base);
+ try expectPaletteC(&expected, &base);
+}
+
+test "color: luminance" {
+ const testing = std.testing;
+
+ const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const black_c = black.cval();
+ const white_c = white.cval();
+ try testing.expectApproxEqAbs(@as(f64, 0.0), luminance(&black_c), 0.000001);
+ try testing.expectApproxEqAbs(@as(f64, 1.0), luminance(&white_c), 0.000001);
+
+ const samples = [_]color.RGB{
+ .{ .r = 40, .g = 44, .b = 52 },
+ .{ .r = 171, .g = 205, .b = 239 },
+ .{ .r = 12, .g = 34, .b = 56 },
+ };
+ for (samples) |sample| {
+ const sample_c = sample.cval();
+ try testing.expectApproxEqAbs(sample.luminance(), luminance(&sample_c), 0.000001);
+ }
+}
+
+test "color: perceived_luminance" {
+ const testing = std.testing;
+
+ const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const dark = color.RGB{ .r = 0x28, .g = 0x2C, .b = 0x34 };
+ const black_c = black.cval();
+ const white_c = white.cval();
+ const dark_c = dark.cval();
+ try testing.expectApproxEqAbs(@as(f64, 0.0), perceived_luminance(&black_c), 0.000001);
+ try testing.expectApproxEqAbs(@as(f64, 1.0), perceived_luminance(&white_c), 0.000001);
+ try testing.expect(perceived_luminance(&dark_c) < 0.5);
+ try testing.expect(perceived_luminance(&white_c) > 0.5);
+
+ const samples = [_]color.RGB{
+ dark,
+ .{ .r = 171, .g = 205, .b = 239 },
+ .{ .r = 12, .g = 34, .b = 56 },
+ };
+ for (samples) |sample| {
+ const sample_c = sample.cval();
+ try testing.expectApproxEqAbs(
+ sample.perceivedLuminance(),
+ perceived_luminance(&sample_c),
+ 0.000001,
+ );
+ }
+}
+
+test "color: contrast" {
+ const testing = std.testing;
+
+ const black = color.RGB{ .r = 0, .g = 0, .b = 0 };
+ const white = color.RGB{ .r = 255, .g = 255, .b = 255 };
+ const gray = color.RGB{ .r = 128, .g = 128, .b = 128 };
+ const black_c = black.cval();
+ const white_c = white.cval();
+ const gray_c = gray.cval();
+
+ try testing.expectApproxEqAbs(@as(f64, 21.0), contrast(&white_c, &black_c), 0.000001);
+ try testing.expectApproxEqAbs(
+ contrast(&black_c, &white_c),
+ contrast(&white_c, &black_c),
+ 0.000001,
+ );
+ try testing.expectApproxEqAbs(@as(f64, 1.0), contrast(&gray_c, &gray_c), 0.000001);
+}
+
+test "color: x11_names" {
+ const testing = std.testing;
+
+ const names = x11_names();
+ const count = x11_name_count();
+ try testing.expectEqual(x11_color.entries.len, count);
+ try testing.expect(count > 700);
+
+ var walked: usize = 0;
+ while (names[walked].name) |name| : (walked += 1) {
+ const name_slice = std.mem.span(name);
+ const expected = x11_color.map.get(name_slice).?;
+ try testing.expectEqual(expected, color.RGB.fromC(names[walked].color));
+ }
+
+ try testing.expectEqual(count, walked);
+ try testing.expectEqual(@as(?[*:0]const u8, null), names[count].name);
+}
+
+test "color: x11_names static" {
+ try std.testing.expectEqual(@intFromPtr(x11_names()), @intFromPtr(x11_names()));
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/color_scheme.zig b/vendor/libghostty-vt/src/terminal/c/color_scheme.zig
new file mode 100644
index 00000000..ee022b35
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/c/color_scheme.zig
@@ -0,0 +1,63 @@
+const std = @import("std");
+const lib = @import("../lib.zig");
+const device_status = @import("../device_status.zig");
+const Result = @import("result.zig").Result;
+
+pub fn report_encode(
+ scheme: device_status.ColorScheme,
+ out_: ?[*]u8,
+ out_len: usize,
+ out_written: *usize,
+) callconv(lib.calling_conv) Result {
+ var writer: std.Io.Writer = .fixed(if (out_) |out| out[0..out_len] else &.{});
+ device_status.encodeColorSchemeReport(&writer, scheme) catch |err| switch (err) {
+ error.WriteFailed => {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ device_status.encodeColorSchemeReport(&discarding.writer, scheme) catch unreachable;
+ out_written.* = @intCast(discarding.count);
+ return .out_of_space;
+ },
+ };
+
+ out_written.* = writer.end;
+ return .success;
+}
+
+test "encode color scheme report dark" {
+ var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined;
+ var written: usize = 0;
+ const result = report_encode(.dark, &buf, buf.len, &written);
+ try std.testing.expectEqual(.success, result);
+ try std.testing.expectEqualStrings("\x1B[?997;1n", buf[0..written]);
+}
+
+test "encode color scheme report light" {
+ var buf: [device_status.max_color_scheme_report_encode_size]u8 = undefined;
+ var written: usize = 0;
+ const result = report_encode(.light, &buf, buf.len, &written);
+ try std.testing.expectEqual(.success, result);
+ try std.testing.expectEqualStrings("\x1B[?997;2n", buf[0..written]);
+}
+
+test "encode color scheme report with null buffer" {
+ var written: usize = 0;
+ const result = report_encode(.dark, null, 0, &written);
+ try std.testing.expectEqual(.out_of_space, result);
+ try std.testing.expectEqual(@as(usize, 9), written);
+}
+
+test "encode color scheme report with insufficient buffer" {
+ var buf: [3]u8 = undefined;
+ var written: usize = 0;
+ const result = report_encode(.light, &buf, buf.len, &written);
+ try std.testing.expectEqual(.out_of_space, result);
+ try std.testing.expectEqual(@as(usize, 9), written);
+}
+
+test "encode color scheme report with exact buffer" {
+ var buf: [9]u8 = undefined;
+ var written: usize = 0;
+ const result = report_encode(.dark, &buf, buf.len, &written);
+ try std.testing.expectEqual(.success, result);
+ try std.testing.expectEqual(@as(usize, 9), written);
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig b/vendor/libghostty-vt/src/terminal/c/grid_ref.zig
index db7ff2e8..946ca8a1 100644
--- a/vendor/libghostty-vt/src/terminal/c/grid_ref.zig
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
index a00632ed..13a675cc 100644
--- a/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
+++ b/vendor/libghostty-vt/src/terminal/c/kitty_graphics.zig
@@ -52,11 +52,13 @@ else
pub const Data = enum(c_int) {
invalid = 0,
placement_iterator = 1,
+ generation = 2,
pub fn OutType(comptime self: Data) type {
return switch (self) {
.invalid => void,
.placement_iterator => PlacementIterator,
+ .generation => u64,
};
}
};
@@ -129,6 +131,7 @@ fn getTyped(
.layer_filter = it.layer_filter,
};
},
+ .generation => out.* = storage.generation,
}
return .success;
}
@@ -178,7 +181,7 @@ pub const ImageData = enum(c_int) {
compression = 6,
data_ptr = 7,
data_len = 8,
- transmit_time_ns = 9,
+ generation = 9,
pub fn OutType(comptime self: ImageData) type {
return switch (self) {
@@ -188,7 +191,7 @@ pub const ImageData = enum(c_int) {
.compression => ImageCompression,
.data_ptr => [*]const u8,
.data_len => usize,
- .transmit_time_ns => u64,
+ .generation => u64,
};
}
};
@@ -260,22 +263,12 @@ fn imageGetTyped(
.compression => out.* = image.compression,
.data_ptr => out.* = image.data.ptr,
.data_len => out.* = image.data.len,
- .transmit_time_ns => out.* = instantNanos(image.transmit_time),
+ .generation => out.* = image.generation,
}
return .success;
}
-/// Flattens an Instant's platform timestamp to nanoseconds for the C API.
-/// The epoch is unspecified; only equality and ordering are meaningful.
-fn instantNanos(instant: std.time.Instant) u64 {
- if (@TypeOf(instant.timestamp) == u64) return instant.timestamp;
- const ts = instant.timestamp;
- const sec: u64 = @intCast(@max(ts.sec, 0));
- const nsec: u64 = @intCast(@max(ts.nsec, 0));
- return sec *| std.time.ns_per_s +| nsec;
-}
-
pub fn placement_iterator_new(
alloc_: ?*const CAllocator,
out: *PlacementIterator,
@@ -990,48 +983,6 @@ test "image_get_handle and image_get with transmitted image" {
try testing.expect(data_len > 0);
}
-test "image_get transmit_time_ns changes on retransmission" {
- if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
-
- var t: terminal_c.Terminal = null;
- try testing.expectEqual(Result.success, terminal_c.new(
- &lib.alloc.test_allocator,
- &t,
- .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
- ));
- defer terminal_c.free(t);
-
- const cmd = "\x1b_Ga=T,t=d,f=24,i=1,p=1,s=1,v=2;////////\x1b\\";
- terminal_c.vt_write(t, cmd.ptr, cmd.len);
-
- var graphics: KittyGraphics = undefined;
- try testing.expectEqual(Result.success, terminal_c.get(
- t,
- .kitty_graphics,
- @ptrCast(&graphics),
- ));
-
- var first: u64 = undefined;
- try testing.expectEqual(Result.success, image_get(
- image_get_handle(graphics, 1),
- .transmit_time_ns,
- @ptrCast(&first),
- ));
- try testing.expect(first > 0);
-
- std.Thread.sleep(1 * std.time.ns_per_ms);
- const retransmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\";
- terminal_c.vt_write(t, retransmit.ptr, retransmit.len);
-
- var second: u64 = undefined;
- try testing.expectEqual(Result.success, image_get(
- image_get_handle(graphics, 1),
- .transmit_time_ns,
- @ptrCast(&second),
- ));
- try testing.expect(second > first);
-}
-
test "placement_rect with transmit and display" {
if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
@@ -1763,3 +1714,259 @@ test "placement_get_multi null keys returns invalid_value" {
var values = [_]?*anyopaque{@ptrCast(&id)};
try testing.expectEqual(Result.invalid_value, placement_get_multi(null, 1, null, &values, null));
}
+
+test "storage generation via get" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+
+ // Fresh storage: generation zero.
+ var gen0: u64 = 99;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen0)));
+ try testing.expectEqual(0, gen0);
+
+ // Transmit bumps the generation.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ var gen1: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen1)));
+ try testing.expect(gen1 > gen0);
+
+ // Unrelated terminal writes (plain text) do not bump it.
+ const text = "hello world";
+ terminal_c.vt_write(t, text.ptr, text.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ var gen2: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen2)));
+ try testing.expectEqual(gen1, gen2);
+
+ // Placement bumps it.
+ const display = "\x1b_Ga=p,i=1,p=1,c=1,r=1;\x1b\\";
+ terminal_c.vt_write(t, display.ptr, display.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ var gen3: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen3)));
+ try testing.expect(gen3 > gen2);
+
+ // Delete bumps it.
+ const del = "\x1b_Ga=d,d=A\x1b\\";
+ terminal_c.vt_write(t, del.ptr, del.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ var gen4: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen4)));
+ try testing.expect(gen4 > gen3);
+}
+
+test "image generation detects same-sized retransmission" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // Transmit a 1x2 RGB image with id=1.
+ const transmit1 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, transmit1.ptr, transmit1.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+
+ var gen1: u64 = 0;
+ var w1: u32 = 0;
+ var h1: u32 = 0;
+ var len1: usize = 0;
+ {
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+ try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen1)));
+ try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w1)));
+ try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h1)));
+ try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len1)));
+ try testing.expect(gen1 > 0);
+ }
+
+ // Retransmit the same ID with identical dimensions but different
+ // pixel bytes. All size heuristics match; only the generation
+ // reveals the change.
+ const transmit2 = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA\x1b\\";
+ terminal_c.vt_write(t, transmit2.ptr, transmit2.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+
+ {
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+ var gen2: u64 = 0;
+ var w2: u32 = 0;
+ var h2: u32 = 0;
+ var len2: usize = 0;
+ try testing.expectEqual(Result.success, image_get(img, .generation, @ptrCast(&gen2)));
+ try testing.expectEqual(Result.success, image_get(img, .width, @ptrCast(&w2)));
+ try testing.expectEqual(Result.success, image_get(img, .height, @ptrCast(&h2)));
+ try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&len2)));
+
+ // Size heuristics are identical...
+ try testing.expectEqual(w1, w2);
+ try testing.expectEqual(h1, h2);
+ try testing.expectEqual(len1, len2);
+
+ // ...but the generation changed.
+ try testing.expect(gen2 > gen1);
+ }
+}
+
+test "image generation via image_get_multi" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ var id: u32 = 0;
+ var generation: u64 = 0;
+ var written: usize = 0;
+ const keys = [_]ImageData{ .id, .generation };
+ var values = [_]?*anyopaque{ @ptrCast(&id), @ptrCast(&generation) };
+ try testing.expectEqual(Result.success, image_get_multi(img, keys.len, &keys, &values, &written));
+ try testing.expectEqual(keys.len, written);
+ try testing.expectEqual(1, id);
+ try testing.expect(generation > 0);
+
+ // The image stamp came from the same sequence as (and here, the
+ // same event as) the storage stamp.
+ var storage_gen: u64 = 0;
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&storage_gen)));
+ try testing.expectEqual(storage_gen, generation);
+}
+
+test "image compression and format always report decoded data" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ // Transmit a zlib-compressed 1x1 RGB image (o=z). The payload is
+ // base64(zlib([0xFF, 0x00, 0x00])).
+ const compressed = comptime blk: {
+ // zlib stream for the 3 bytes FF 00 00 (stored block).
+ // 78 01: zlib header; 01: final stored block; 03 00 len;
+ // FC FF nlen; FF 00 00 data; adler32 = 0x03000100 (big endian).
+ const raw = [_]u8{ 0x78, 0x01, 0x01, 0x03, 0x00, 0xFC, 0xFF, 0xFF, 0x00, 0x00, 0x03, 0x00, 0x01, 0x00 };
+ var buf: [std.base64.standard.Encoder.calcSize(raw.len)]u8 = undefined;
+ const enc = std.base64.standard.Encoder.encode(&buf, &raw);
+ break :blk enc[0..enc.len].*;
+ };
+ const transmit = "\x1b_Ga=t,t=d,f=24,o=z,i=1,s=1,v=1;" ++ compressed ++ "\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+
+ var graphics: KittyGraphics = undefined;
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ const img = image_get_handle(graphics, 1);
+ try testing.expect(img != null);
+
+ // Compression must report NONE: the data was inflated at
+ // transmission time.
+ var comp: ImageCompression = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .compression, @ptrCast(&comp)));
+ try testing.expectEqual(.none, comp);
+
+ // Data is the decoded pixels: width * height * bpp.
+ var data_len: usize = 0;
+ try testing.expectEqual(Result.success, image_get(img, .data_len, @ptrCast(&data_len)));
+ try testing.expectEqual(3, data_len);
+
+ var data_ptr: [*]const u8 = undefined;
+ try testing.expectEqual(Result.success, image_get(img, .data_ptr, @ptrCast(&data_ptr)));
+ try testing.expectEqualSlices(u8, &.{ 0xFF, 0x00, 0x00 }, data_ptr[0..data_len]);
+}
+
+test "generation never recurs across resets and screen switches" {
+ if (comptime !build_options.kitty_graphics) return error.SkipZigTest;
+
+ var t: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer terminal_c.free(t);
+
+ var graphics: KittyGraphics = undefined;
+ var gen: u64 = 0;
+
+ // Transmit on the main screen.
+ const transmit = "\x1b_Ga=t,t=d,f=24,i=1,s=1,v=2;////////\x1b\\";
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ const gen_main = gen;
+ try testing.expect(gen_main > 0);
+
+ // Switch to the alternate screen: its storage is untouched, so its
+ // generation is zero (which always means "empty").
+ const alt_on = "\x1b[?1049h";
+ terminal_c.vt_write(t, alt_on.ptr, alt_on.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ try testing.expectEqual(0, gen);
+
+ // A transmit on the alt screen draws from the same global sequence,
+ // so its stamp is strictly greater than anything seen on the main
+ // screen: an embedder keying a cache on the generation value alone
+ // can never confuse the two storages.
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ const gen_alt = gen;
+ try testing.expect(gen_alt > gen_main);
+
+ // Back to the main screen: its generation is unchanged.
+ const alt_off = "\x1b[?1049l";
+ terminal_c.vt_write(t, alt_off.ptr, alt_off.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ try testing.expectEqual(gen_main, gen);
+
+ // Reset zeroes the storage (empty), and the next mutation continues
+ // from the global sequence: past values are never reused for
+ // different content.
+ terminal_c.reset(t);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ try testing.expectEqual(0, gen);
+
+ terminal_c.vt_write(t, transmit.ptr, transmit.len);
+ try testing.expectEqual(Result.success, terminal_c.get(t, .kitty_graphics, @ptrCast(&graphics)));
+ try testing.expectEqual(Result.success, get(graphics, .generation, @ptrCast(&gen)));
+ try testing.expect(gen > gen_alt);
+}
diff --git a/vendor/libghostty-vt/src/terminal/c/main.zig b/vendor/libghostty-vt/src/terminal/c/main.zig
index 648bdbe5..37dc5768 100644
--- a/vendor/libghostty-vt/src/terminal/c/main.zig
+++ b/vendor/libghostty-vt/src/terminal/c/main.zig
@@ -5,6 +5,7 @@ const buildpkg = @import("build_info.zig");
pub const allocator = @import("allocator.zig");
pub const cell = @import("cell.zig");
pub const color = @import("color.zig");
+pub const color_scheme = @import("color_scheme.zig");
pub const focus = @import("focus.zig");
pub const formatter = @import("formatter.zig");
pub const grid_ref = @import("grid_ref.zig");
@@ -43,6 +44,7 @@ pub const size_report = @import("size_report.zig");
pub const style = @import("style.zig");
pub const sys = @import("sys.zig");
pub const terminal = @import("terminal.zig");
+pub const unicode = @import("unicode.zig");
// The full C API, unexported.
pub const build_info = buildpkg.get;
@@ -56,6 +58,18 @@ pub const osc_command_type = osc.commandType;
pub const osc_command_data = osc.commandData;
pub const color_rgb_get = color.rgb_get;
+pub const color_contrast = color.contrast;
+pub const color_luminance = color.luminance;
+pub const color_parse = color.parse;
+pub const color_parse_palette_entry = color.parse_palette_entry;
+pub const color_parse_x11 = color.parse_x11;
+pub const color_palette_default = color.palette_default;
+pub const color_palette_generate = color.palette_generate;
+pub const color_perceived_luminance = color.perceived_luminance;
+pub const color_x11_name_count = color.x11_name_count;
+pub const color_x11_names = color.x11_names;
+
+pub const color_scheme_report_encode = color_scheme.report_encode;
pub const focus_encode = focus.encode;
@@ -69,6 +83,8 @@ pub const formatter_free = formatter.free;
pub const render_state_new = render.new;
pub const render_state_free = render.free;
pub const render_state_update = render.update;
+pub const render_state_begin_update = render.begin_update;
+pub const render_state_end_update = render.end_update;
pub const render_state_get = render.get;
pub const render_state_get_multi = render.get_multi;
pub const render_state_set = render.set;
@@ -167,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;
@@ -198,6 +216,9 @@ pub const terminal_point_from_grid_ref = terminal.point_from_grid_ref;
pub const type_json = types.get_json;
+pub const unicode_codepoint_width = unicode.codepoint_width;
+pub const unicode_grapheme_width = unicode.grapheme_width;
+
pub const grid_ref_cell = grid_ref.grid_ref_cell;
pub const grid_ref_row = grid_ref.grid_ref_row;
pub const grid_ref_graphemes = grid_ref.grid_ref_graphemes;
@@ -214,6 +235,7 @@ test {
_ = buildpkg;
_ = cell;
_ = color;
+ _ = color_scheme;
_ = grid_ref;
_ = grid_ref_tracked;
_ = kitty_graphics;
@@ -236,6 +258,7 @@ test {
_ = sys;
_ = terminal;
_ = types;
+ _ = unicode;
// We want to make sure we run the tests for the C allocator interface.
_ = @import("../../lib/allocator.zig");
diff --git a/vendor/libghostty-vt/src/terminal/c/render.zig b/vendor/libghostty-vt/src/terminal/c/render.zig
index 0f3b2e78..6cf78ec5 100644
--- a/vendor/libghostty-vt/src/terminal/c/render.zig
+++ b/vendor/libghostty-vt/src/terminal/c/render.zig
@@ -188,6 +188,25 @@ pub fn update(
return .success;
}
+pub fn begin_update(
+ state_: RenderState,
+ terminal_: terminal_c.Terminal,
+) callconv(lib.calling_conv) Result {
+ const state = state_ orelse return .invalid_value;
+ const t: *ZigTerminal = (terminal_ orelse return .invalid_value).terminal;
+
+ state.state.beginUpdate(state.alloc, t) catch return .out_of_memory;
+ return .success;
+}
+
+pub fn end_update(
+ state_: RenderState,
+) callconv(lib.calling_conv) Result {
+ const state = state_ orelse return .invalid_value;
+ state.state.endUpdate();
+ return .success;
+}
+
pub fn get(
state_: RenderState,
data: Data,
@@ -591,7 +610,7 @@ fn rowCellsGetGraphemesUtf8(
if (!cell.hasText()) return .success;
- var needed = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch
+ var needed: usize = std.unicode.utf8CodepointSequenceLength(cell.codepoint()) catch
return .invalid_value;
for (extra) |cp| {
needed += std.unicode.utf8CodepointSequenceLength(cp) catch
@@ -787,6 +806,61 @@ test "render: update invalid value" {
try testing.expectEqual(Result.invalid_value, update(state, null));
}
+test "render: begin/end update invalid value" {
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ try testing.expectEqual(Result.invalid_value, begin_update(null, null));
+ try testing.expectEqual(Result.invalid_value, begin_update(state, null));
+ try testing.expectEqual(Result.invalid_value, end_update(null));
+
+ // End without a begin is safe.
+ try testing.expectEqual(Result.success, end_update(state));
+}
+
+test "render: begin/end update" {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{
+ .cols = 10,
+ .rows = 3,
+ .max_scrollback = 10_000,
+ },
+ ));
+ defer terminal_c.free(terminal);
+
+ // Write some styled text so that the update has deferred work.
+ const t = terminal.?.terminal;
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("\x1b[1mAB"); // Bold
+
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ // Begin should record pending work, end should complete it.
+ try testing.expectEqual(Result.success, begin_update(state, terminal));
+ try testing.expect(state.?.state.pending_styles.items.len > 0);
+ try testing.expectEqual(Result.success, end_update(state));
+ try testing.expectEqual(0, state.?.state.pending_styles.items.len);
+
+ // The cell styles should be complete.
+ const row_data = state.?.state.row_data.slice();
+ const cells = row_data.items(.cells);
+ try testing.expect(cells[0].get(0).style.flags.bold);
+ try testing.expect(cells[0].get(1).style.flags.bold);
+}
+
test "render: get invalid value" {
var cols: size.CellCountInt = 0;
try testing.expectEqual(Result.invalid_value, get(null, .cols, @ptrCast(&cols)));
@@ -1293,6 +1367,82 @@ test "render: row cells get has_styling" {
}
test "render: row cells get graphemes utf8" {
+ const cases = [_]struct {
+ terminal_input: []const u8,
+ expected: []const u8,
+ }{
+ .{
+ .terminal_input = "e\u{301}",
+ .expected = "e\u{301}",
+ },
+ .{
+ .terminal_input = "\x1b[?2027h\u{1F1FA}\u{1F1F8}",
+ .expected = "\u{1F1FA}\u{1F1F8}",
+ },
+ .{
+ .terminal_input = "\x1b[?2027h\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
+ .expected = "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
+ },
+ };
+
+ for (cases) |case| {
+ var terminal: terminal_c.Terminal = null;
+ try testing.expectEqual(Result.success, terminal_c.new(
+ &lib.alloc.test_allocator,
+ &terminal,
+ .{ .cols = 10, .rows = 3, .max_scrollback = 10_000 },
+ ));
+ defer terminal_c.free(terminal);
+
+ terminal_c.vt_write(terminal, case.terminal_input.ptr, case.terminal_input.len);
+
+ var state: RenderState = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &state,
+ ));
+ defer free(state);
+
+ try testing.expectEqual(Result.success, update(state, terminal));
+
+ var it: RowIterator = null;
+ try testing.expectEqual(Result.success, row_iterator_new(
+ &lib.alloc.test_allocator,
+ &it,
+ ));
+ defer row_iterator_free(it);
+
+ var cells: RowCells = null;
+ try testing.expectEqual(Result.success, row_cells_new(
+ &lib.alloc.test_allocator,
+ &cells,
+ ));
+ defer row_cells_free(cells);
+
+ try testing.expectEqual(Result.success, get(state, .row_iterator, @ptrCast(&it)));
+ try testing.expect(row_iterator_next(it));
+ try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells)));
+
+ try testing.expectEqual(Result.success, row_cells_select(cells, 0));
+
+ var text: lib.Buffer = .{};
+ try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
+ try testing.expectEqual(case.expected.len, text.len);
+
+ var small = [_]u8{'x'} ** 32;
+ const small_cap = case.expected.len - 1;
+ text = .{ .ptr = small[0..small_cap].ptr, .cap = small_cap };
+ try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
+ try testing.expectEqual(case.expected.len, text.len);
+ try testing.expectEqualSlices(u8, &([_]u8{'x'} ** 32), &small);
+
+ var buf: [32]u8 = undefined;
+ text = .{ .ptr = &buf, .cap = case.expected.len };
+ try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
+ try testing.expectEqual(case.expected.len, text.len);
+ try testing.expectEqualStrings(case.expected, buf[0..text.len]);
+ }
+
var terminal: terminal_c.Terminal = null;
try testing.expectEqual(Result.success, terminal_c.new(
&lib.alloc.test_allocator,
@@ -1331,26 +1481,9 @@ test "render: row cells get graphemes utf8" {
try testing.expect(row_iterator_next(it));
try testing.expectEqual(Result.success, row_get(it, .cells, @ptrCast(&cells)));
- try testing.expectEqual(Result.success, row_cells_select(cells, 0));
-
- var text: lib.Buffer = .{};
- try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
- try testing.expectEqual(@as(usize, input.len), text.len);
-
- var small = [_]u8{ 'x', 'x' };
- text = .{ .ptr = &small, .cap = small.len };
- try testing.expectEqual(Result.out_of_space, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
- try testing.expectEqual(@as(usize, input.len), text.len);
- try testing.expectEqualSlices(u8, &.{ 'x', 'x' }, &small);
-
- var buf: [8]u8 = undefined;
- text = .{ .ptr = &buf, .cap = buf.len };
- try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
- try testing.expectEqual(input.len, text.len);
- try testing.expectEqualStrings(input, buf[0..text.len]);
-
try testing.expectEqual(Result.success, row_cells_select(cells, 1));
- text = .{ .ptr = &buf, .cap = buf.len };
+ var buf: [8]u8 = undefined;
+ var text: lib.Buffer = .{ .ptr = &buf, .cap = buf.len };
try testing.expectEqual(Result.success, row_cells_get(cells, .graphemes_utf8, @ptrCast(&text)));
try testing.expectEqual(@as(usize, 0), text.len);
}
diff --git a/vendor/libghostty-vt/src/terminal/c/terminal.zig b/vendor/libghostty-vt/src/terminal/c/terminal.zig
index a7ad35a6..a896c0fd 100644
--- a/vendor/libghostty-vt/src/terminal/c/terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/c/terminal.zig
@@ -5,6 +5,7 @@ const lib = @import("../lib.zig");
const CAllocator = lib.alloc.Allocator;
pub const ZigTerminal = @import("../Terminal.zig");
const Stream = @import("../stream_terminal.zig").Stream;
+const Screen = @import("../Screen.zig");
const ScreenSet = @import("../ScreenSet.zig");
const PageList = @import("../PageList.zig");
const apc = @import("../apc.zig");
@@ -23,6 +24,7 @@ const grid_ref_tracked_c = @import("grid_ref_tracked.zig");
const selection_c = @import("selection.zig");
const style_c = @import("style.zig");
const color = @import("../color.zig");
+const clipboard = @import("../clipboard.zig");
const Result = @import("result.zig").Result;
const Handler = @import("../stream_terminal.zig").Handler;
@@ -39,6 +41,24 @@ const TerminalWrapper = struct {
tracked_grid_refs: std.AutoArrayHashMapUnmanaged(*grid_ref_tracked_c.TrackedGridRef, void) = .{},
};
+/// A single MIME representation in a clipboard write.
+///
+/// C: GhosttyClipboardContent
+pub const ClipboardContent = extern struct {
+ mime: lib.String,
+ data: lib.String,
+};
+
+/// A protocol-neutral request to replace or clear clipboard contents.
+///
+/// C: GhosttyClipboardWrite
+pub const ClipboardWrite = extern struct {
+ size: usize,
+ location: clipboard.Location,
+ contents: ?[*]const ClipboardContent,
+ contents_len: usize,
+};
+
/// C callback state for terminal effects. Trampolines are always
/// installed on the stream handler; they check these fields and
/// no-op when the corresponding callback is null.
@@ -51,7 +71,9 @@ const Effects = struct {
enquiry: ?EnquiryFn = null,
xtversion: ?XtversionFn = null,
title_changed: ?TitleChangedFn = null,
+ pwd_changed: ?PwdChangedFn = null,
size_cb: ?SizeFn = null,
+ clipboard_write: ?ClipboardWriteFn = null,
/// Scratch buffer for DA1 feature codes. The device attributes
/// trampoline converts C feature codes into this buffer and returns
@@ -82,9 +104,16 @@ const Effects = struct {
/// (len=0) causes the default "libghostty" to be reported.
pub const XtversionFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) lib.String;
+ /// C function pointer type for the clipboard_write callback. The request
+ /// and its contents are borrowed and only valid for the callback duration.
+ pub const ClipboardWriteFn = *const fn (Terminal, ?*anyopaque, *const ClipboardWrite) callconv(lib.calling_conv) clipboard.WriteResult;
+
/// C function pointer type for the title_changed callback.
pub const TitleChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
+ /// C function pointer type for the pwd_changed callback.
+ pub const PwdChangedFn = *const fn (Terminal, ?*anyopaque) callconv(lib.calling_conv) void;
+
/// C function pointer type for the size callback.
/// Returns true and fills out_size if size is available,
/// or returns false to silently ignore the query.
@@ -133,6 +162,44 @@ const Effects = struct {
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
+ fn clipboardWriteTrampoline(handler: *Handler, write: clipboard.Write) clipboard.WriteResult {
+ const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
+ const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
+ const func = wrapper.effects.clipboard_write orelse return .unsupported;
+
+ // Most protocols currently produce one representation, so keep that
+ // path allocation-free while supporting arbitrary multi-MIME writes.
+ var stack_contents: [4]ClipboardContent = undefined;
+ const contents: []ClipboardContent = if (write.contents.len <= stack_contents.len)
+ stack_contents[0..write.contents.len]
+ else
+ wrapper.terminal.gpa().alloc(ClipboardContent, write.contents.len) catch
+ return .io_error;
+ defer if (write.contents.len > stack_contents.len)
+ wrapper.terminal.gpa().free(contents);
+
+ for (contents, write.contents) |*c_content, content| {
+ c_content.* = .{
+ .mime = .{
+ .ptr = content.mime.ptr,
+ .len = content.mime.len,
+ },
+ .data = .{
+ .ptr = content.data.ptr,
+ .len = content.data.len,
+ },
+ };
+ }
+
+ const request: ClipboardWrite = .{
+ .size = @sizeOf(ClipboardWrite),
+ .location = write.location,
+ .contents = if (contents.len > 0) contents.ptr else null,
+ .contents_len = contents.len,
+ };
+ return func(@ptrCast(wrapper), wrapper.effects.userdata, &request);
+ }
+
fn colorSchemeTrampoline(handler: *Handler) ?device_status.ColorScheme {
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
@@ -198,6 +265,13 @@ const Effects = struct {
func(@ptrCast(wrapper), wrapper.effects.userdata);
}
+ fn pwdChangedTrampoline(handler: *Handler) void {
+ const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
+ const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
+ const func = wrapper.effects.pwd_changed orelse return;
+ func(@ptrCast(wrapper), wrapper.effects.userdata);
+ }
+
fn sizeTrampoline(handler: *Handler) ?size_report.Size {
const stream_ptr: *Stream = @fieldParentPtr("handler", handler);
const wrapper: *TerminalWrapper = @fieldParentPtr("stream", stream_ptr);
@@ -211,6 +285,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;
}
@@ -283,7 +363,9 @@ fn new_(
.enquiry = &Effects.enquiryTrampoline,
.xtversion = &Effects.xtversionTrampoline,
.title_changed = &Effects.titleChangedTrampoline,
+ .pwd_changed = &Effects.pwdChangedTrampoline,
.size = &Effects.sizeTrampoline,
+ .clipboard_write = &Effects.clipboardWriteTrampoline,
};
wrapper.* = .{
@@ -303,6 +385,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,
@@ -327,6 +433,11 @@ pub const Option = enum(c_int) {
apc_max_bytes = 19,
apc_max_bytes_kitty = 20,
selection = 21,
+ default_cursor_style = 22,
+ default_cursor_blink = 23,
+ glyph_protocol = 24,
+ pwd_changed = 25,
+ clipboard_write = 26,
/// Input type expected for setting the option.
pub fn InType(comptime self: Option) type {
@@ -339,7 +450,9 @@ pub const Option = enum(c_int) {
.enquiry => ?Effects.EnquiryFn,
.xtversion => ?Effects.XtversionFn,
.title_changed => ?Effects.TitleChangedFn,
+ .pwd_changed => ?Effects.PwdChangedFn,
.size_cb => ?Effects.SizeFn,
+ .clipboard_write => ?Effects.ClipboardWriteFn,
.title, .pwd => ?*const lib.String,
.color_foreground, .color_background, .color_cursor => ?*const color.RGB.C,
.color_palette => ?*const color.PaletteC,
@@ -347,9 +460,12 @@ pub const Option = enum(c_int) {
.kitty_image_medium_file,
.kitty_image_medium_temp_file,
.kitty_image_medium_shared_mem,
+ .glyph_protocol,
=> ?*const bool,
.apc_max_bytes, .apc_max_bytes_kitty => ?*const usize,
.selection => ?*const selection_c.CSelection,
+ .default_cursor_style => ?*const TerminalCursorStyle,
+ .default_cursor_blink => ?*const bool,
};
}
};
@@ -391,7 +507,9 @@ fn setTyped(
.enquiry => wrapper.effects.enquiry = value,
.xtversion => wrapper.effects.xtversion = value,
.title_changed => wrapper.effects.title_changed = value,
+ .pwd_changed => wrapper.effects.pwd_changed = value,
.size_cb => wrapper.effects.size_cb = value,
+ .clipboard_write => wrapper.effects.clipboard_write = value,
.title => {
const str = if (value) |v| v.ptr[0..v.len] else "";
wrapper.terminal.setTitle(str) catch return .out_of_memory;
@@ -457,6 +575,11 @@ fn setTyped(
wrapper.stream.handler.apc_handler.max_bytes.remove(.kitty);
}
},
+ .glyph_protocol => {
+ const enabled = (value orelse return .success).*;
+ wrapper.stream.handler.apc_handler.enable(.glyph, enabled);
+ if (!enabled) wrapper.terminal.glyph_glossary.clearAndFree(wrapper.terminal.gpa());
+ },
.selection => {
if (value) |ptr| {
const sel = ptr.toZig() orelse return .invalid_value;
@@ -465,10 +588,43 @@ fn setTyped(
wrapper.terminal.screens.active.clearSelection();
}
},
+ .default_cursor_style => {
+ const style = (if (value) |ptr| ptr.* else TerminalCursorStyle.block).toZig() orelse return .invalid_value;
+ wrapper.stream.handler.default_cursor_style = style;
+ if (wrapper.stream.handler.default_cursor) {
+ wrapper.terminal.screens.active.cursor.cursor_style = style;
+ }
+ },
+ .default_cursor_blink => {
+ const blink = if (value) |ptr| ptr.* else false;
+ wrapper.stream.handler.default_cursor_blink = blink;
+ if (wrapper.stream.handler.default_cursor) {
+ wrapper.terminal.modes.set(.cursor_blinking, blink);
+ }
+ },
}
return .success;
}
+/// C: GhosttyTerminalCursorStyle
+pub const TerminalCursorStyle = enum(c_int) {
+ bar = 0,
+ block = 1,
+ underline = 2,
+ block_hollow = 3,
+ _,
+
+ fn toZig(self: TerminalCursorStyle) ?Screen.CursorStyle {
+ return switch (self) {
+ .bar => .bar,
+ .block => .block,
+ .underline => .underline,
+ .block_hollow => .block_hollow,
+ _ => null,
+ };
+ }
+};
+
/// C: GhosttyDeviceAttributes
pub const DeviceAttributes = Effects.CDeviceAttributes;
@@ -484,6 +640,7 @@ pub fn scroll_viewport(
.top => .top,
.bottom => .bottom,
.delta => .{ .delta = behavior.value.delta },
+ .row => .{ .row = behavior.value.row },
});
}
@@ -938,8 +1095,211 @@ test "scroll_viewport" {
}
}
+test "scroll_viewport row" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 5,
+ .rows = 2,
+ .max_scrollback = 10_000,
+ },
+ ));
+ defer free(t);
+
+ const zt = t.?.terminal;
+
+ // Write 4 rows so that rows "1" and "2" are pushed into scrollback:
+ // total rows is 4, viewport length is 2.
+ vt_write(t, "1\r\n2\r\n3\r\n4", 10);
+
+ var viewport_active: bool = false;
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(viewport_active);
+
+ // Row 0 is the top of the scrollback.
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } });
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(!viewport_active);
+ {
+ const str = try zt.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("1\n2", str);
+ }
+
+ // An absolute row within the scrollback becomes the first visible
+ // row and round-trips through the scrollbar offset.
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 1 } });
+ {
+ const str = try zt.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("2\n3", str);
+ }
+ var scrollbar_data: TerminalScrollbar = undefined;
+ try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data)));
+ try testing.expectEqual(@as(u64, 4), scrollbar_data.total);
+ try testing.expectEqual(@as(u64, 1), scrollbar_data.offset);
+ try testing.expectEqual(@as(u64, 2), scrollbar_data.len);
+
+ // A row past the end clamps to the active area.
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } });
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(viewport_active);
+ {
+ const str = try zt.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("3\n4", str);
+ }
+ try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data)));
+ try testing.expectEqual(@as(u64, 2), scrollbar_data.offset);
+}
+
+test "scroll_viewport row alt screen" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 5,
+ .rows = 2,
+ .max_scrollback = 10_000,
+ },
+ ));
+ defer free(t);
+
+ // Enter the alternate screen, which has no scrollback.
+ vt_write(t, "\x1b[?1049h", 8);
+ var screen: TerminalScreen = undefined;
+ try testing.expectEqual(Result.success, get(t, .active_screen, @ptrCast(&screen)));
+ try testing.expectEqual(TerminalScreen.alternate, screen);
+
+ // Scrolling to any row keeps the viewport on the active area.
+ var viewport_active: bool = false;
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 0 } });
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(viewport_active);
+ scroll_viewport(t, .{ .tag = .row, .value = .{ .row = 9999 } });
+ try testing.expectEqual(Result.success, get(t, .viewport_active, @ptrCast(&viewport_active)));
+ try testing.expect(viewport_active);
+
+ // With no scrollback the scrollbar covers exactly the active area.
+ var scrollbar_data: TerminalScrollbar = undefined;
+ try testing.expectEqual(Result.success, get(t, .scrollbar, @ptrCast(&scrollbar_data)));
+ try testing.expectEqual(@as(u64, 2), scrollbar_data.total);
+ try testing.expectEqual(@as(u64, 0), scrollbar_data.offset);
+ try testing.expectEqual(@as(u64, 2), scrollbar_data.len);
+}
+
test "scroll_viewport null" {
scroll_viewport(null, .{ .tag = .top, .value = undefined });
+ 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" {
@@ -1019,11 +1379,12 @@ test "resize shrinks both axes with cursor at bottom" {
));
defer free(t);
- // CSI 24;1H parks the cursor on the bottom row.
+ // CSI 24;1H -> park the cursor on the bottom row (1-based).
const move = "\x1b[24;1H";
vt_write(t, move, move.len);
// Shrink both axes; pre-resize cursor.y sits past the new bottom row.
+ // Previously this underflowed in PageList.resizeCols.
try testing.expectEqual(Result.success, resize(t, 79, 23, 8, 16));
try testing.expectEqual(79, t.?.terminal.cols);
try testing.expectEqual(23, t.?.terminal.rows);
@@ -1401,6 +1762,45 @@ test "get invalid" {
try testing.expectEqual(Result.invalid_value, get(t, .invalid, null));
}
+test "set default cursor style and blink" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ },
+ ));
+ defer free(t);
+
+ var default_style: TerminalCursorStyle = .bar;
+ var default_blink = true;
+ try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style)));
+ try testing.expectEqual(Result.success, set(t, .default_cursor_blink, @ptrCast(&default_blink)));
+
+ // Setting defaults applies them immediately while the cursor is still default.
+ try testing.expectEqual(Screen.CursorStyle.bar, t.?.terminal.screens.active.cursor.cursor_style);
+ try testing.expect(t.?.terminal.modes.get(.cursor_blinking));
+
+ // An explicit DECSCUSR style overrides the configured defaults.
+ vt_write(t, "\x1b[2 q", 5);
+ try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style);
+ try testing.expect(!t.?.terminal.modes.get(.cursor_blinking));
+
+ // Changing defaults does not override an explicit cursor style.
+ default_style = .underline;
+ try testing.expectEqual(Result.success, set(t, .default_cursor_style, @ptrCast(&default_style)));
+ try testing.expectEqual(Screen.CursorStyle.block, t.?.terminal.screens.active.cursor.cursor_style);
+ try testing.expect(!t.?.terminal.modes.get(.cursor_blinking));
+
+ // DECSCUSR reset restores the configured default style and blink.
+ vt_write(t, "\x1b[0 q", 5);
+ try testing.expectEqual(Screen.CursorStyle.underline, t.?.terminal.screens.active.cursor.cursor_style);
+ try testing.expect(t.?.terminal.modes.get(.cursor_blinking));
+}
+
test "set and get selection" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
@@ -1982,6 +2382,46 @@ test "set write_pty callback" {
try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
}
+test "write_pty receives OSC color query response" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ },
+ ));
+ defer free(t);
+
+ const S = struct {
+ var last_data: ?[]u8 = null;
+
+ fn deinit() void {
+ if (last_data) |d| testing.allocator.free(d);
+ last_data = null;
+ }
+
+ fn writePty(_: Terminal, _: ?*anyopaque, ptr: [*]const u8, len: usize) callconv(lib.calling_conv) void {
+ if (last_data) |d| testing.allocator.free(d);
+ last_data = testing.allocator.dupe(u8, ptr[0..len]) catch @panic("OOM");
+ }
+ };
+ defer S.deinit();
+
+ try testing.expectEqual(Result.success, set(t, .write_pty, @ptrCast(&S.writePty)));
+
+ const set_fg = "\x1B]10;rgb:01/02/03\x1B\\";
+ vt_write(t, set_fg, set_fg.len);
+ try testing.expect(S.last_data == null);
+
+ const query_fg = "\x1B]10;?\x1B\\";
+ vt_write(t, query_fg, query_fg.len);
+ try testing.expect(S.last_data != null);
+ try testing.expectEqualStrings("\x1B]10;rgb:0101/0202/0303\x1B\\", S.last_data.?);
+}
+
test "set write_pty without callback ignores queries" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
@@ -2279,6 +2719,248 @@ test "title_changed without callback is silent" {
vt_write(t, "\x1B]2;Hello\x1B\\", 10);
}
+test "set pwd_changed callback" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ },
+ ));
+ defer free(t);
+
+ const S = struct {
+ var pwd_count: usize = 0;
+ var last_userdata: ?*anyopaque = null;
+
+ fn pwdChanged(_: Terminal, ud: ?*anyopaque) callconv(lib.calling_conv) void {
+ pwd_count += 1;
+ last_userdata = ud;
+ }
+ };
+ S.pwd_count = 0;
+ S.last_userdata = null;
+
+ var sentinel: u8 = 88;
+ try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
+ try testing.expectEqual(Result.success, set(t, .pwd_changed, @ptrCast(&S.pwdChanged)));
+
+ // OSC 7 ; file:///tmp ST — report pwd
+ const seq1 = "\x1B]7;file:///tmp\x1B\\";
+ vt_write(t, seq1, seq1.len);
+ try testing.expectEqual(@as(usize, 1), S.pwd_count);
+ try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
+ try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
+
+ // Another pwd change
+ const seq2 = "\x1B]7;file:///home/user\x1B\\";
+ vt_write(t, seq2, seq2.len);
+ try testing.expectEqual(@as(usize, 2), S.pwd_count);
+ try testing.expectEqualStrings("file:///home/user", zigTerminal(t).?.getPwd().?);
+}
+
+test "set clipboard_write callback" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ },
+ ));
+ defer free(t);
+
+ const S = struct {
+ var count: usize = 0;
+ var last_terminal: Terminal = null;
+ var last_userdata: ?*anyopaque = null;
+ var last_size: usize = 0;
+ var last_location: clipboard.Location = .standard;
+ var last_contents_null: bool = false;
+ var last_contents_len: usize = 0;
+ var last_mimes: [8][64]u8 = undefined;
+ var last_mime_lens: [8]usize = @splat(0);
+ var last_data: [8][64]u8 = undefined;
+ var last_data_lens: [8]usize = @splat(0);
+ var next_result: clipboard.WriteResult = .success;
+
+ fn clipboardWrite(
+ terminal_: Terminal,
+ ud: ?*anyopaque,
+ request: *const ClipboardWrite,
+ ) callconv(lib.calling_conv) clipboard.WriteResult {
+ count += 1;
+ last_terminal = terminal_;
+ last_userdata = ud;
+ last_size = request.size;
+ last_location = request.location;
+ last_contents_null = request.contents == null;
+ last_contents_len = request.contents_len;
+
+ if (request.contents) |ptr| {
+ for (ptr[0..@min(request.contents_len, last_mimes.len)], 0..) |content, i| {
+ last_mime_lens[i] = @min(content.mime.len, last_mimes[i].len);
+ @memcpy(
+ last_mimes[i][0..last_mime_lens[i]],
+ content.mime.ptr[0..last_mime_lens[i]],
+ );
+
+ last_data_lens[i] = @min(content.data.len, last_data[i].len);
+ @memcpy(
+ last_data[i][0..last_data_lens[i]],
+ content.data.ptr[0..last_data_lens[i]],
+ );
+ }
+ }
+
+ return next_result;
+ }
+ };
+ S.count = 0;
+ S.last_terminal = null;
+ S.last_userdata = null;
+ S.next_result = .denied;
+
+ var sentinel: u8 = 88;
+ try testing.expectEqual(Result.success, set(t, .userdata, @ptrCast(&sentinel)));
+ try testing.expectEqual(Result.success, set(t, .clipboard_write, @ptrCast(&S.clipboardWrite)));
+
+ // Split OSC 52 write whose decoded payload contains an embedded NUL.
+ const seq1_a = "\x1B]52;c;aGVs";
+ const seq1_b = "bG8Ad29ybGQ=\x1B\\";
+ vt_write(t, seq1_a, seq1_a.len);
+ vt_write(t, seq1_b, seq1_b.len);
+ try testing.expectEqual(@as(usize, 1), S.count);
+ try testing.expectEqual(t, S.last_terminal);
+ try testing.expectEqual(@as(?*anyopaque, @ptrCast(&sentinel)), S.last_userdata);
+ try testing.expectEqual(@sizeOf(ClipboardWrite), S.last_size);
+ try testing.expectEqual(clipboard.Location.standard, S.last_location);
+ try testing.expect(!S.last_contents_null);
+ try testing.expectEqual(@as(usize, 1), S.last_contents_len);
+ try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
+ try testing.expectEqualSlices(u8, "hello\x00world", S.last_data[0][0..S.last_data_lens[0]]);
+
+ // OSC 52 destinations are normalized rather than exposed as wire bytes.
+ const location_cases = [_]struct {
+ selector: u8,
+ expected: clipboard.Location,
+ }{
+ .{ .selector = 's', .expected = .selection },
+ .{ .selector = 'p', .expected = .primary },
+ .{ .selector = 'q', .expected = .standard },
+ };
+ for (location_cases) |case| {
+ const seq = [_]u8{ '\x1B', ']', '5', '2', ';', case.selector, ';', 'e', 'A', '=', '=', '\x1B', '\\' };
+ vt_write(t, &seq, seq.len);
+ try testing.expectEqual(case.expected, S.last_location);
+ }
+ try testing.expectEqual(@as(usize, 4), S.count);
+
+ // An empty content list is a clear and retains a null descriptor pointer.
+ const clear = "\x1B]52;s;\x1B\\";
+ vt_write(t, clear, clear.len);
+ try testing.expectEqual(@as(usize, 5), S.count);
+ try testing.expectEqual(clipboard.Location.selection, S.last_location);
+ try testing.expect(S.last_contents_null);
+ try testing.expectEqual(@as(usize, 0), S.last_contents_len);
+
+ // Read requests and malformed base64 must never reach the callback.
+ const read = "\x1B]52;c;?\x1B\\";
+ vt_write(t, read, read.len);
+ const malformed = "\x1B]52;c;%%%\x1B\\";
+ vt_write(t, malformed, malformed.len);
+ try testing.expectEqual(@as(usize, 5), S.count);
+
+ // iTerm2 Copy reaches the same normalized callback.
+ const iterm = "\x1B]1337;Copy=:aVRlcm0=\x1B\\";
+ vt_write(t, iterm, iterm.len);
+ try testing.expectEqual(@as(usize, 6), S.count);
+ try testing.expectEqual(clipboard.Location.standard, S.last_location);
+ try testing.expectEqualStrings("text/plain", S.last_mimes[0][0..S.last_mime_lens[0]]);
+ try testing.expectEqualStrings("iTerm", S.last_data[0][0..S.last_data_lens[0]]);
+
+ // Every representation is converted, and callback results propagate
+ // through the C trampoline for protocols that can acknowledge writes.
+ const internal_contents = [_]clipboard.Content{
+ .{ .mime = "text/plain", .data = "plain" },
+ .{ .mime = "application/octet-stream", .data = "a\x00b" },
+ .{ .mime = "text/html", .data = "<b>plain</b>" },
+ .{ .mime = "text/rtf", .data = "{\\rtf1 plain}" },
+ .{ .mime = "image/png", .data = "\x89PNG" },
+ };
+ S.next_result = .busy;
+ const handler = &t.?.stream.handler;
+ const write_result = handler.effects.clipboard_write.?(handler, .{
+ .location = .primary,
+ .contents = &internal_contents,
+ });
+ try testing.expectEqual(clipboard.WriteResult.busy, write_result);
+ try testing.expectEqual(@as(usize, 7), S.count);
+ try testing.expectEqual(@as(usize, 5), S.last_contents_len);
+ try testing.expectEqualStrings(
+ "application/octet-stream",
+ S.last_mimes[1][0..S.last_mime_lens[1]],
+ );
+ try testing.expectEqualSlices(u8, "a\x00b", S.last_data[1][0..S.last_data_lens[1]]);
+ try testing.expectEqualStrings("image/png", S.last_mimes[4][0..S.last_mime_lens[4]]);
+ try testing.expectEqualSlices(u8, "\x89PNG", S.last_data[4][0..S.last_data_lens[4]]);
+
+ // Removing the callback takes effect immediately.
+ try testing.expectEqual(Result.success, set(t, .clipboard_write, null));
+ const after_remove = "\x1B]52;c;eA==\x1B\\";
+ vt_write(t, after_remove, after_remove.len);
+ try testing.expectEqual(@as(usize, 7), S.count);
+}
+
+test "clipboard_write without callback is unsupported and silent" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ },
+ ));
+ defer free(t);
+
+ // OSC 52 without a callback should not crash
+ const seq = "\x1B]52;c;aGVsbG8=\x1B\\";
+ vt_write(t, seq, seq.len);
+
+ const handler = &t.?.stream.handler;
+ const result = handler.effects.clipboard_write.?(handler, .{
+ .location = .standard,
+ .contents = &.{.{ .mime = "text/plain", .data = "hello" }},
+ });
+ try testing.expectEqual(clipboard.WriteResult.unsupported, result);
+}
+
+test "pwd_changed without callback is silent" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ },
+ ));
+ defer free(t);
+
+ // OSC 7 without a callback should not crash, but should still set the pwd
+ const seq = "\x1B]7;file:///tmp\x1B\\";
+ vt_write(t, seq, seq.len);
+ try testing.expectEqualStrings("file:///tmp", zigTerminal(t).?.getPwd().?);
+}
+
test "set size callback" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
@@ -3106,6 +3788,33 @@ test "set color sets dirty flag" {
try testing.expect(zt.flags.dirty.palette);
}
+test "set glyph protocol disables APC handling and clears glossary" {
+ var t: Terminal = null;
+ try testing.expectEqual(Result.success, new(
+ &lib.alloc.test_allocator,
+ &t,
+ .{ .cols = 80, .rows = 24, .max_scrollback = 0 },
+ ));
+ defer free(t);
+
+ const register = "\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\";
+ vt_write(t, register, register.len);
+ try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0));
+
+ const disabled = false;
+ try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&disabled)));
+ try testing.expect(!t.?.stream.handler.apc_handler.enabled.contains(.glyph));
+ try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0));
+
+ vt_write(t, register, register.len);
+ try testing.expect(!t.?.terminal.glyph_glossary.contains(0xE0A0));
+
+ const enabled = true;
+ try testing.expectEqual(Result.success, set(t, .glyph_protocol, @ptrCast(&enabled)));
+ vt_write(t, register, register.len);
+ try testing.expect(t.?.terminal.glyph_glossary.contains(0xE0A0));
+}
+
test "get_multi success" {
var t: Terminal = null;
try testing.expectEqual(Result.success, new(
diff --git a/vendor/libghostty-vt/src/terminal/c/types.zig b/vendor/libghostty-vt/src/terminal/c/types.zig
index de043a03..f160f385 100644
--- a/vendor/libghostty-vt/src/terminal/c/types.zig
+++ b/vendor/libghostty-vt/src/terminal/c/types.zig
@@ -7,6 +7,7 @@
const std = @import("std");
const lib = @import("../lib.zig");
const color = @import("../color.zig");
+const color_c = @import("color.zig");
const mouse_event = @import("mouse_event.zig");
const point = @import("../point.zig");
const size_report = @import("size_report.zig");
@@ -37,8 +38,12 @@ pub const structs: std.StaticStringMap(StructInfo) = structs: {
@setEvalBranchQuota(10_000);
break :structs .initComptime(.{
.{ "GhosttyBuffer", StructInfo.init(lib.Buffer) },
+ .{ "GhosttyClipboardContent", StructInfo.init(terminal.ClipboardContent) },
+ .{ "GhosttyClipboardWrite", StructInfo.init(terminal.ClipboardWrite) },
.{ "GhosttyCodepoints", StructInfo.init(Codepoints) },
+ .{ "GhosttyColorPaletteMask", StructInfo.init(color_c.PaletteMask) },
.{ "GhosttyColorRgb", StructInfo.init(color.RGB.C) },
+ .{ "GhosttyColorX11Entry", StructInfo.init(color_c.X11Entry) },
.{ "GhosttyDeviceAttributes", StructInfo.init(terminal.DeviceAttributes) },
.{ "GhosttyDeviceAttributesPrimary", StructInfo.init(terminal.DeviceAttributes.Primary) },
.{ "GhosttyDeviceAttributesSecondary", StructInfo.init(terminal.DeviceAttributes.Secondary) },
@@ -209,9 +214,23 @@ test "json parses" {
const root = parsed.value.object;
// Verify we have all expected structs
+ try std.testing.expect(root.contains("GhosttyClipboardContent"));
+ try std.testing.expect(root.contains("GhosttyClipboardWrite"));
try std.testing.expect(root.contains("GhosttyTerminalOptions"));
try std.testing.expect(root.contains("GhosttyFormatterTerminalOptions"));
+ const clipboard_content = root.get("GhosttyClipboardContent").?.object;
+ const clipboard_content_fields = clipboard_content.get("fields").?.object;
+ try std.testing.expect(clipboard_content_fields.contains("mime"));
+ try std.testing.expect(clipboard_content_fields.contains("data"));
+
+ const clipboard_write = root.get("GhosttyClipboardWrite").?.object;
+ const clipboard_write_fields = clipboard_write.get("fields").?.object;
+ try std.testing.expect(clipboard_write_fields.contains("size"));
+ try std.testing.expect(clipboard_write_fields.contains("location"));
+ try std.testing.expect(clipboard_write_fields.contains("contents"));
+ try std.testing.expect(clipboard_write_fields.contains("contents_len"));
+
// Verify GhosttyTerminalOptions fields
const term_opts = root.get("GhosttyTerminalOptions").?.object;
try std.testing.expect(term_opts.contains("size"));
diff --git a/vendor/libghostty-vt/src/terminal/c/unicode.zig b/vendor/libghostty-vt/src/terminal/c/unicode.zig
new file mode 100644
index 00000000..14451338
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/c/unicode.zig
@@ -0,0 +1,85 @@
+const std = @import("std");
+const lib = @import("../lib.zig");
+const unicode_pkg = @import("../../unicode/main.zig");
+
+pub fn codepoint_width(cp: u32) callconv(lib.calling_conv) u8 {
+ if (cp > 0x10FFFF) return 1;
+ return unicode_pkg.codepointWidth(@intCast(cp));
+}
+
+pub fn grapheme_width(
+ cps: ?[*]const u32,
+ len: usize,
+ width: ?*u8,
+) callconv(lib.calling_conv) usize {
+ if (len == 0) {
+ if (width) |ptr| ptr.* = 0;
+ return 0;
+ }
+
+ const ptr = cps orelse {
+ if (width) |out| out.* = 0;
+ return 0;
+ };
+ const result = unicode_pkg.graphemeWidth(u32, ptr[0..len]);
+ if (width) |out| out.* = result.width;
+ return result.len;
+}
+
+test "codepoint_width narrow" {
+ const testing = std.testing;
+ try testing.expectEqual(1, codepoint_width('a'));
+}
+
+test "codepoint_width wide" {
+ const testing = std.testing;
+ try testing.expectEqual(2, codepoint_width(0x4E00));
+}
+
+test "codepoint_width zero" {
+ const testing = std.testing;
+ try testing.expectEqual(0, codepoint_width(0x0301));
+}
+
+test "codepoint_width out of range" {
+ const testing = std.testing;
+ try testing.expectEqual(1, codepoint_width(0x110000));
+ try testing.expectEqual(1, codepoint_width(std.math.maxInt(u32)));
+}
+
+test "grapheme_width empty" {
+ const testing = std.testing;
+
+ var width: u8 = 42;
+ try testing.expectEqual(@as(usize, 0), grapheme_width(null, 0, &width));
+ try testing.expectEqual(@as(u8, 0), width);
+}
+
+test "grapheme_width null width" {
+ const testing = std.testing;
+
+ const cps = [_]u32{ 0x2764, 0xFE0F };
+ try testing.expectEqual(@as(usize, 2), grapheme_width(cps[0..].ptr, cps.len, null));
+}
+
+test "grapheme_width out of range" {
+ const testing = std.testing;
+
+ var width: u8 = 0;
+ const invalid_first = [_]u32{ 0x110000, 0x0301 };
+ try testing.expectEqual(@as(usize, 1), grapheme_width(invalid_first[0..].ptr, invalid_first.len, &width));
+ try testing.expectEqual(@as(u8, 1), width);
+
+ const invalid_second = [_]u32{ 'a', 0x110000 };
+ try testing.expectEqual(@as(usize, 1), grapheme_width(invalid_second[0..].ptr, invalid_second.len, &width));
+ try testing.expectEqual(@as(u8, 1), width);
+}
+
+test "grapheme_width emoji sequence" {
+ const testing = std.testing;
+
+ var width: u8 = 0;
+ const cps = [_]u32{ 0x1F468, 0x200D, 0x1F469, 0x200D, 0x1F467 };
+ try testing.expectEqual(@as(usize, cps.len), grapheme_width(cps[0..].ptr, cps.len, &width));
+ try testing.expectEqual(@as(u8, 2), width);
+}
diff --git a/vendor/libghostty-vt/src/terminal/clipboard.zig b/vendor/libghostty-vt/src/terminal/clipboard.zig
new file mode 100644
index 00000000..554ca984
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/clipboard.zig
@@ -0,0 +1,36 @@
+/// The clipboard destination for a write.
+pub const Location = enum(c_int) {
+ standard = 0,
+ selection = 1,
+ primary = 2,
+ _,
+};
+
+/// A single representation of clipboard data.
+///
+/// The MIME type and data are borrowed and only valid for the duration of a
+/// clipboard write callback. Data is binary-safe.
+pub const Content = struct {
+ mime: []const u8,
+ data: []const u8,
+};
+
+/// One atomic clipboard write.
+///
+/// Contents are borrowed and only valid for the duration of a clipboard write
+/// callback. An empty contents slice clears the destination.
+pub const Write = struct {
+ location: Location,
+ contents: []const Content,
+};
+
+/// The result of a clipboard write.
+pub const WriteResult = enum(c_int) {
+ success = 0,
+ denied = 1,
+ unsupported = 2,
+ busy = 3,
+ invalid_data = 4,
+ io_error = 5,
+ _,
+};
diff --git a/vendor/libghostty-vt/src/terminal/color.zig b/vendor/libghostty-vt/src/terminal/color.zig
index cb1511c3..92de3578 100644
--- a/vendor/libghostty-vt/src/terminal/color.zig
+++ b/vendor/libghostty-vt/src/terminal/color.zig
@@ -47,6 +47,81 @@ pub const default: Palette = default: {
/// Palette is the 256 color palette.
pub const Palette = [256]RGB;
+/// A parsed palette entry from Ghostty's config "N=COLOR" syntax.
+pub const PaletteEntry = struct {
+ index: u8,
+ color: RGB,
+};
+
+/// Parse a palette entry in Ghostty config syntax: "N=COLOR" where N is
+/// a palette index 0-255 (decimal, or 0x/0o/0b-prefixed per Zig's
+/// parseInt base-0 rules) and COLOR is anything RGB.parse accepts.
+/// Whitespace (spaces/tabs) around N and COLOR is ignored.
+pub fn parsePaletteEntry(value: []const u8) error{ InvalidFormat, Overflow }!PaletteEntry {
+ const eql_idx = std.mem.indexOfScalar(u8, value, '=') orelse
+ return error.InvalidFormat;
+ const index = std.fmt.parseInt(
+ u8,
+ std.mem.trim(u8, value[0..eql_idx], " \t"),
+ 0,
+ ) catch |err| switch (err) {
+ error.Overflow => return error.Overflow,
+ error.InvalidCharacter => return error.InvalidFormat,
+ };
+ const rgb = try RGB.parse(value[eql_idx + 1 ..]);
+ return .{ .index = index, .color = rgb };
+}
+
+test "parsePaletteEntry" {
+ const testing = std.testing;
+
+ {
+ const entry = try parsePaletteEntry("0=#AABBCC");
+ try testing.expectEqual(@as(u8, 0), entry.index);
+ try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color);
+ }
+ {
+ const entry = try parsePaletteEntry("0b1=#014589");
+ try testing.expectEqual(@as(u8, 1), entry.index);
+ try testing.expectEqual(RGB{ .r = 1, .g = 69, .b = 137 }, entry.color);
+ }
+ {
+ const entry = try parsePaletteEntry("0o7=#234567");
+ try testing.expectEqual(@as(u8, 7), entry.index);
+ try testing.expectEqual(RGB{ .r = 35, .g = 69, .b = 103 }, entry.color);
+ }
+ {
+ const entry = try parsePaletteEntry("0xF=#ABCDEF");
+ try testing.expectEqual(@as(u8, 15), entry.index);
+ try testing.expectEqual(RGB{ .r = 171, .g = 205, .b = 239 }, entry.color);
+ }
+ {
+ const entry = try parsePaletteEntry("0 = #AABBCC");
+ try testing.expectEqual(@as(u8, 0), entry.index);
+ try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, entry.color);
+ }
+ {
+ const entry = try parsePaletteEntry(" 1= #DDEEFF ");
+ try testing.expectEqual(@as(u8, 1), entry.index);
+ try testing.expectEqual(RGB{ .r = 221, .g = 238, .b = 255 }, entry.color);
+ }
+ {
+ const entry = try parsePaletteEntry(" 2 = #123456 ");
+ try testing.expectEqual(@as(u8, 2), entry.index);
+ try testing.expectEqual(RGB{ .r = 18, .g = 52, .b = 86 }, entry.color);
+ }
+ {
+ const entry = try parsePaletteEntry("1=black");
+ try testing.expectEqual(@as(u8, 1), entry.index);
+ try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, entry.color);
+ }
+
+ try testing.expectError(error.InvalidFormat, parsePaletteEntry(" "));
+ try testing.expectError(error.InvalidFormat, parsePaletteEntry("a"));
+ try testing.expectError(error.Overflow, parsePaletteEntry("256=#AABBCC"));
+ try testing.expectError(error.InvalidFormat, parsePaletteEntry("1=notacolor"));
+}
+
/// C-compatible palette type using the extern RGB struct.
pub const PaletteC = [256]RGB.C;
@@ -441,6 +516,24 @@ pub const RGB = packed struct(u24) {
return self.r == other.r and self.g == other.g and self.b == other.b;
}
+ pub fn encodeRgb8(self: RGB, writer: *std.Io.Writer) !void {
+ try writer.print(
+ "rgb:{x:0>2}/{x:0>2}/{x:0>2}",
+ .{ self.r, self.g, self.b },
+ );
+ }
+
+ pub fn encodeRgb16(self: RGB, writer: *std.Io.Writer) !void {
+ try writer.print(
+ "rgb:{x:0>4}/{x:0>4}/{x:0>4}",
+ .{
+ @as(u16, self.r) * 257,
+ @as(u16, self.g) * 257,
+ @as(u16, self.b) * 257,
+ },
+ );
+ }
+
/// Calculates the contrast ratio between two colors. The contrast
/// ration is a value between 1 and 21 where 1 is the lowest contrast
/// and 21 is the highest contrast.
@@ -541,6 +634,8 @@ pub const RGB = packed struct(u24) {
/// Parse a color specification.
///
+ /// Leading and trailing spaces and tabs are ignored.
+ ///
/// Any of the following forms are accepted:
///
/// 1. rgb:<red>/<green>/<blue>
@@ -554,38 +649,42 @@ pub const RGB = packed struct(u24) {
/// where <red>, <green>, and <blue> are floating point values between
/// 0.0 and 1.0 (inclusive).
///
- /// 3. #rgb, #rrggbb, #rrrgggbbb #rrrrggggbbbb
+ /// 3. #rgb, #rrggbb, rgb, rrggbb, #rrrgggbbb, #rrrrggggbbbb
///
- /// where `r`, `g`, and `b` are a single hexadecimal digit.
- /// These specify a color with 4, 8, 12, and 16 bits of precision
- /// per color channel.
+ /// where `r`, `g`, and `b` are hexadecimal digits. The forms with
+ /// a leading # specify a color with 4, 8, 12, and 16 bits of
+ /// precision per color channel. The forms without a leading # are
+ /// accepted for compatibility with Ghostty config/theme color values.
+ ///
+ /// 4. X11 color names
pub fn parse(value: []const u8) error{InvalidFormat}!RGB {
- if (value.len == 0) {
+ const input = std.mem.trim(u8, value, " \t");
+ if (input.len == 0) {
@branchHint(.cold);
return error.InvalidFormat;
}
- if (value[0] == '#') {
- switch (value.len) {
+ if (input[0] == '#') {
+ switch (input.len) {
4 => return RGB{
- .r = try RGB.fromHex(value[1..2]),
- .g = try RGB.fromHex(value[2..3]),
- .b = try RGB.fromHex(value[3..4]),
+ .r = try RGB.fromHex(input[1..2]),
+ .g = try RGB.fromHex(input[2..3]),
+ .b = try RGB.fromHex(input[3..4]),
},
7 => return RGB{
- .r = try RGB.fromHex(value[1..3]),
- .g = try RGB.fromHex(value[3..5]),
- .b = try RGB.fromHex(value[5..7]),
+ .r = try RGB.fromHex(input[1..3]),
+ .g = try RGB.fromHex(input[3..5]),
+ .b = try RGB.fromHex(input[5..7]),
},
10 => return RGB{
- .r = try RGB.fromHex(value[1..4]),
- .g = try RGB.fromHex(value[4..7]),
- .b = try RGB.fromHex(value[7..10]),
+ .r = try RGB.fromHex(input[1..4]),
+ .g = try RGB.fromHex(input[4..7]),
+ .b = try RGB.fromHex(input[7..10]),
},
13 => return RGB{
- .r = try RGB.fromHex(value[1..5]),
- .g = try RGB.fromHex(value[5..9]),
- .b = try RGB.fromHex(value[9..13]),
+ .r = try RGB.fromHex(input[1..5]),
+ .g = try RGB.fromHex(input[5..9]),
+ .b = try RGB.fromHex(input[9..13]),
},
else => {
@@ -595,24 +694,36 @@ pub const RGB = packed struct(u24) {
}
}
- // Check for X11 named colors. We allow whitespace around the edges
- // of the color because Kitty allows whitespace. This is not part of
- // any spec I could find.
- if (x11_color.map.get(std.mem.trim(u8, value, " "))) |rgb| return rgb;
+ // Check for X11 named colors. We allow whitespace around the edges.
+ if (x11_color.map.get(input)) |rgb| return rgb;
+
+ switch (input.len) {
+ 3 => return RGB{
+ .r = try RGB.fromHex(input[0..1]),
+ .g = try RGB.fromHex(input[1..2]),
+ .b = try RGB.fromHex(input[2..3]),
+ },
+ 6 => return RGB{
+ .r = try RGB.fromHex(input[0..2]),
+ .g = try RGB.fromHex(input[2..4]),
+ .b = try RGB.fromHex(input[4..6]),
+ },
+ else => {},
+ }
- if (value.len < "rgb:a/a/a".len or !std.mem.eql(u8, value[0..3], "rgb")) {
+ if (input.len < "rgb:a/a/a".len or !std.mem.eql(u8, input[0..3], "rgb")) {
@branchHint(.cold);
return error.InvalidFormat;
}
var i: usize = 3;
- const use_intensity = if (value[i] == 'i') blk: {
+ const use_intensity = if (input[i] == 'i') blk: {
i += 1;
break :blk true;
} else false;
- if (value[i] != ':') {
+ if (input[i] != ':') {
@branchHint(.cold);
return error.InvalidFormat;
}
@@ -620,8 +731,8 @@ pub const RGB = packed struct(u24) {
i += 1;
const r = r: {
- const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end|
- value[i..end]
+ const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end|
+ input[i..end]
else {
@branchHint(.cold);
return error.InvalidFormat;
@@ -636,8 +747,8 @@ pub const RGB = packed struct(u24) {
};
const g = g: {
- const slice = if (std.mem.indexOfScalarPos(u8, value, i, '/')) |end|
- value[i..end]
+ const slice = if (std.mem.indexOfScalarPos(u8, input, i, '/')) |end|
+ input[i..end]
else {
@branchHint(.cold);
return error.InvalidFormat;
@@ -652,9 +763,9 @@ pub const RGB = packed struct(u24) {
};
const b = if (use_intensity)
- try RGB.fromIntensity(value[i..])
+ try RGB.fromIntensity(input[i..])
else
- try RGB.fromHex(value[i..]);
+ try RGB.fromHex(input[i..]);
return RGB{
.r = r,
@@ -780,6 +891,11 @@ test "RGB.parse" {
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#fffffffff"));
try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("#ffffffffffff"));
try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 16 }, try RGB.parse("#ff0010"));
+ try testing.expectEqual(RGB{ .r = 10, .g = 11, .b = 12 }, try RGB.parse("0A0B0C"));
+ try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFFFFF"));
+ try testing.expectEqual(RGB{ .r = 255, .g = 255, .b = 255 }, try RGB.parse("FFF"));
+ try testing.expectEqual(RGB{ .r = 51, .g = 68, .b = 85 }, try RGB.parse("#345"));
+ try testing.expectEqual(RGB{ .r = 170, .g = 187, .b = 204 }, try RGB.parse(" #AABBCC "));
try testing.expectEqual(RGB{ .r = 0, .g = 0, .b = 0 }, try RGB.parse("black"));
try testing.expectEqual(RGB{ .r = 255, .g = 0, .b = 0 }, try RGB.parse("red"));
@@ -790,8 +906,11 @@ test "RGB.parse" {
try testing.expectEqual(RGB{ .r = 124, .g = 252, .b = 0 }, try RGB.parse("LawnGreen"));
try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, try RGB.parse("medium spring green"));
try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse(" Forest Green "));
+ try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, try RGB.parse("\tForestGreen\t"));
// Invalid format
+ try testing.expectError(error.InvalidFormat, RGB.parse(""));
+ try testing.expectError(error.InvalidFormat, RGB.parse(" "));
try testing.expectError(error.InvalidFormat, RGB.parse("rgb;"));
try testing.expectError(error.InvalidFormat, RGB.parse("rgb:"));
try testing.expectError(error.InvalidFormat, RGB.parse(":a/a/a"));
@@ -807,6 +926,22 @@ test "RGB.parse" {
try testing.expectError(error.InvalidFormat, RGB.parse("#ffff"));
try testing.expectError(error.InvalidFormat, RGB.parse("#fffff"));
try testing.expectError(error.InvalidFormat, RGB.parse("#gggggg"));
+ try testing.expectError(error.InvalidFormat, RGB.parse("#12345"));
+ try testing.expectError(error.InvalidFormat, RGB.parse("12345"));
+ try testing.expectError(error.InvalidFormat, RGB.parse("nosuchcolor"));
+}
+
+test "RGB: encode" {
+ const rgb: RGB = .{ .r = 0x01, .g = 0x23, .b = 0xff };
+
+ var buf: [64]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try rgb.encodeRgb8(&writer);
+ try std.testing.expectEqualStrings("rgb:01/23/ff", writer.buffered());
+
+ writer = .fixed(&buf);
+ try rgb.encodeRgb16(&writer);
+ try std.testing.expectEqualStrings("rgb:0101/2323/ffff", writer.buffered());
}
test "DynamicPalette: init" {
diff --git a/vendor/libghostty-vt/src/terminal/compress.zig b/vendor/libghostty-vt/src/terminal/compress.zig
new file mode 100644
index 00000000..3b670ed7
--- /dev/null
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/compress/AGENTS.md b/vendor/libghostty-vt/src/terminal/compress/AGENTS.md
new file mode 100644
index 00000000..172891d9
--- /dev/null
+++ b/vendor/libghostty-vt/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=<codec>`
+- Prefer `zig build test-lib-vt -Dtest-filter=<codec>` 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/vendor/libghostty-vt/src/terminal/compress/Page.zig b/vendor/libghostty-vt/src/terminal/compress/Page.zig
new file mode 100644
index 00000000..b7ad8025
--- /dev/null
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/compress/lz4.zig b/vendor/libghostty-vt/src/terminal/compress/lz4.zig
new file mode 100644
index 00000000..a6b07763
--- /dev/null
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig b/vendor/libghostty-vt/src/terminal/compress/lz4_differential.zig
new file mode 100644
index 00000000..bbf3b276
--- /dev/null
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/device_status.zig b/vendor/libghostty-vt/src/terminal/device_status.zig
index 42b4bf01..fd1a9b7e 100644
--- a/vendor/libghostty-vt/src/terminal/device_status.zig
+++ b/vendor/libghostty-vt/src/terminal/device_status.zig
@@ -7,6 +7,32 @@ pub const ColorScheme = lib.Enum(lib.target, &.{
"dark",
});
+/// Maximum number of bytes that `encodeColorSchemeReport` will write.
+pub const max_color_scheme_report_encode_size = max: {
+ var result: usize = 0;
+ for (@typeInfo(ColorScheme).@"enum".fields) |field| {
+ var discarding: std.Io.Writer.Discarding = .init(&.{});
+ encodeColorSchemeReport(
+ &discarding.writer,
+ @enumFromInt(field.value),
+ ) catch unreachable;
+ result = @max(result, @as(usize, @intCast(discarding.count)));
+ }
+
+ break :max result;
+};
+
+/// Encode a color scheme report response for CSI ? 996 n queries.
+pub fn encodeColorSchemeReport(
+ writer: *std.Io.Writer,
+ scheme: ColorScheme,
+) std.Io.Writer.Error!void {
+ try writer.writeAll(switch (scheme) {
+ .dark => "\x1B[?997;1n",
+ .light => "\x1B[?997;2n",
+ });
+}
+
/// An enum(u16) of the available device status requests.
pub const Request = dsr_enum: {
const EnumField = std.builtin.Type.EnumField;
@@ -72,3 +98,19 @@ const entries: []const Entry = &.{
.{ .name = "cursor_position", .value = 6 },
.{ .name = "color_scheme", .value = 996, .question = true },
};
+
+test "encode color scheme report dark" {
+ try std.testing.expectEqual(@as(usize, 9), max_color_scheme_report_encode_size);
+
+ var buf: [max_color_scheme_report_encode_size]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try encodeColorSchemeReport(&writer, .dark);
+ try std.testing.expectEqualStrings("\x1B[?997;1n", writer.buffered());
+}
+
+test "encode color scheme report light" {
+ var buf: [max_color_scheme_report_encode_size]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ try encodeColorSchemeReport(&writer, .light);
+ try std.testing.expectEqualStrings("\x1B[?997;2n", writer.buffered());
+}
diff --git a/vendor/libghostty-vt/src/terminal/formatter.zig b/vendor/libghostty-vt/src/terminal/formatter.zig
index a375b4dd..553ea28d 100644
--- a/vendor/libghostty-vt/src/terminal/formatter.zig
+++ b/vendor/libghostty-vt/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("<tag>&\"'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/vendor/libghostty-vt/src/terminal/hash_map.zig b/vendor/libghostty-vt/src/terminal/hash_map.zig
index 5a38cc17..d8feddcd 100644
--- a/vendor/libghostty-vt/src/terminal/hash_map.zig
+++ b/vendor/libghostty-vt/src/terminal/hash_map.zig
@@ -23,31 +23,49 @@
//! because our terminal page representation is backed by a single large
//! allocation so we can give the HashMap a slice of memory to operate in.
//!
-//! I haven't carefully benchmarked this implementation against other hash
-//! map implementations. It's possible using some of the newer variants out
-//! there would be better. However, I trust the built-in version is pretty good
-//! and its more important to get the terminal page representation working
-//! first then we can measure and improve this later if we find it to be a
-//! bottleneck.
+//! This fork diverges from the stdlib in one significant way: removal uses
+//! backward-shift deletion (Knuth vol. 3, section 6.4, algorithm R) rather
+//! than tombstones. A fixed-capacity map cannot outgrow tombstone buildup
+//! the way an allocating map does, so tombstones require either unbounded
+//! probe lengths or periodic in-place rebuilds with subtle bookkeeping.
+//! Backward-shift deletion instead restores the table after every removal
+//! to the exact state it would be in had the removed key never been
+//! inserted. Probe chains are therefore always minimal for the insertion
+//! order, there is no fragmentation to repair, and lookup cost depends only
+//! on the current load factor.
+//!
+//! Pointer stability: insertion never moves existing entries, but removal
+//! may move *other* entries within a probe cluster. Any key or value
+//! pointers previously returned by the map must be considered invalidated
+//! by any removal.
const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
-const autoHash = std.hash.autoHash;
-const math = std.math;
const mem = std.mem;
const Allocator = mem.Allocator;
-const Wyhash = std.hash.Wyhash;
const Offset = @import("size.zig").Offset;
const OffsetBuf = @import("size.zig").OffsetBuf;
const getOffset = @import("size.zig").getOffset;
-pub fn AutoOffsetHashMap(comptime K: type, comptime V: type) type {
- return OffsetHashMap(K, V, AutoContext(K));
+/// The default allows every raw slot to be occupied. Callers whose maps see
+/// removal-heavy churn should choose a lower value to bound probe lengths.
+pub const default_max_load_percentage: u8 = 100;
+
+pub fn AutoOffsetHashMap(
+ comptime K: type,
+ comptime V: type,
+ comptime max_load_percentage: u8,
+) type {
+ return OffsetHashMap(K, V, AutoContext(K), max_load_percentage);
}
-fn AutoHashMapUnmanaged(comptime K: type, comptime V: type) type {
- return HashMapUnmanaged(K, V, AutoContext(K));
+fn AutoHashMapUnmanaged(
+ comptime K: type,
+ comptime V: type,
+ comptime max_load_percentage: u8,
+) type {
+ return HashMapUnmanaged(K, V, AutoContext(K), max_load_percentage);
}
fn AutoContext(comptime K: type) type {
@@ -64,12 +82,18 @@ pub fn OffsetHashMap(
comptime K: type,
comptime V: type,
comptime Context: type,
+ comptime max_load_percentage: u8,
) type {
return struct {
const Self = @This();
/// This is the pointer-based map that we're wrapping.
- pub const Unmanaged = HashMapUnmanaged(K, V, Context);
+ pub const Unmanaged = HashMapUnmanaged(
+ K,
+ V,
+ Context,
+ max_load_percentage,
+ );
pub const Layout = Unmanaged.Layout;
/// This is the alignment that the base pointer must have.
@@ -81,7 +105,7 @@ pub fn OffsetHashMap(
/// HashMap with the given capacity. The base ptr must also be
/// aligned to base_align.
pub fn layout(cap: Unmanaged.Size) Layout {
- return Unmanaged.layoutForCapacity(cap);
+ return Unmanaged.layoutForSize(cap);
}
/// Initialize a new HashMap with the given capacity and backing
@@ -105,19 +129,22 @@ pub fn OffsetHashMap(
}
/// Fork of stdlib.HashMap as of Zig 0.12 modified to use offsets for
-/// the key/values pointer. The metadata is still a pointer to limit
-/// the amount of arithmetic required to access it. See the file comment
-/// for full details.
+/// the key/values pointer, and backward-shift deletion in place of
+/// tombstones. The metadata is still a pointer to limit the amount of
+/// arithmetic required to access it. See the file comment for full details.
fn HashMapUnmanaged(
comptime K: type,
comptime V: type,
comptime Context: type,
+ comptime max_load_percentage: u8,
) type {
return struct {
const Self = @This();
comptime {
assert(@alignOf(Metadata) == 1);
+ assert(max_load_percentage > 0);
+ assert(max_load_percentage <= 100);
}
const header_align = @alignOf(Header);
@@ -139,10 +166,6 @@ fn HashMapUnmanaged(
/// Pointer to the metadata.
metadata: ?[*]Metadata = null,
- // This is purely empirical and not a /very smart magic constant™/.
- /// Capacity of the first grow when bootstrapping the hashmap.
- const minimal_capacity = 8;
-
// This hashmap is specially designed for sizes that fit in a u32.
pub const Size = u32;
@@ -168,11 +191,9 @@ fn HashMapUnmanaged(
size: Size,
};
- /// Metadata for a slot. It can be in three states: empty, used or
- /// tombstone. Tombstones indicate that an entry was previously used,
- /// they are a simple way to handle removal.
- /// To this state, we add 7 bits from the slot's key hash. These are
- /// used as a fast way to disambiguate between entries without
+ /// Metadata for a slot. It can be in two states: free or used.
+ /// To the used state, we add 7 bits from the slot's key hash. These
+ /// are used as a fast way to disambiguate between entries without
/// having to use the equality function. If two fingerprints are
/// different, we know that we don't have to compare the keys at all.
/// The 7 bits are the highest ones from a 64 bit hash. This way, not
@@ -182,28 +203,23 @@ fn HashMapUnmanaged(
/// Not using the equality function means we don't have to read into
/// the entries array, likely avoiding a cache miss and a potentially
/// costly function call.
- const Metadata = packed struct {
+ const Metadata = packed struct(u8) {
const FingerPrint = u7;
- const free: FingerPrint = 0;
- const tombstone: FingerPrint = 1;
-
- fingerprint: FingerPrint = free,
+ fingerprint: FingerPrint = 0,
used: u1 = 0,
- const slot_free = @as(u8, @bitCast(Metadata{ .fingerprint = free }));
- const slot_tombstone = @as(u8, @bitCast(Metadata{ .fingerprint = tombstone }));
-
pub fn isUsed(self: Metadata) bool {
return self.used == 1;
}
- pub fn isTombstone(self: Metadata) bool {
- return @as(u8, @bitCast(self)) == slot_tombstone;
- }
-
pub fn isFree(self: Metadata) bool {
- return @as(u8, @bitCast(self)) == slot_free;
+ // A free slot is always the all-zero byte: `fill` sets the
+ // used bit and removal zeroes the whole byte. Comparing the
+ // full byte (rather than testing the used bit) lets the
+ // optimizer fuse this with the fingerprint comparison in
+ // probe loops into single-byte compares.
+ return @as(u8, @bitCast(self)) == 0;
}
pub fn takeFingerprint(hash: Hash) FingerPrint {
@@ -216,11 +232,6 @@ fn HashMapUnmanaged(
self.used = 1;
self.fingerprint = fp;
}
-
- pub fn remove(self: *Metadata) void {
- self.used = 0;
- self.fingerprint = tombstone;
- }
};
comptime {
@@ -228,6 +239,9 @@ fn HashMapUnmanaged(
assert(@alignOf(Metadata) == 1);
}
+ /// Iterates the entries of the map. Any mutation of the map
+ /// invalidates the iterator: removal may move entries across the
+ /// iteration cursor.
pub const Iterator = struct {
hm: *const Self,
index: Size = 0,
@@ -310,7 +324,7 @@ fn HashMapUnmanaged(
pub fn ensureTotalCapacity(self: *Self, new_size: Size) Allocator.Error!void {
if (new_size > self.header().size) {
- try self.growIfNeeded(new_size - self.header().size);
+ try self.checkCapacity(new_size - self.header().size);
}
}
@@ -347,6 +361,13 @@ fn HashMapUnmanaged(
return self.header().capacity;
}
+ /// Maximum number of entries the map will hold. This is less than
+ /// capacity when max_load_percentage is below 100, which keeps free
+ /// slots in every probe chain and bounds probe lengths.
+ pub fn maxLoad(self: *const Self) Size {
+ return maxLoadForCapacity(self.capacity());
+ }
+
pub fn iterator(self: *const Self) Iterator {
return .{ .hm = self };
}
@@ -391,7 +412,7 @@ fn HashMapUnmanaged(
}
pub fn putNoClobberContext(self: *Self, key: K, value: V, ctx: Context) Allocator.Error!void {
assert(!self.containsContext(key, ctx));
- try self.growIfNeeded(1);
+ try self.checkCapacity(1);
self.putAssumeCapacityNoClobberContext(key, value, ctx);
}
@@ -419,6 +440,9 @@ fn HashMapUnmanaged(
pub fn putAssumeCapacityNoClobberContext(self: *Self, key: K, value: V, ctx: Context) void {
assert(!self.containsContext(key, ctx));
+ // A free slot must exist for the probe below to terminate.
+ assert(self.header().size < self.capacity());
+
const hash = ctx.hash(key);
const mask = self.capacity() - 1;
var idx = @as(usize, @truncate(hash & mask));
@@ -429,8 +453,7 @@ fn HashMapUnmanaged(
metadata = self.metadata.? + idx;
}
- const fingerprint = Metadata.takeFingerprint(hash);
- metadata[0].fill(fingerprint);
+ metadata[0].fill(Metadata.takeFingerprint(hash));
self.keys()[idx] = key;
self.values()[idx] = value;
self.header().size += 1;
@@ -476,31 +499,22 @@ fn HashMapUnmanaged(
}
/// If there is an `Entry` with a matching key, it is deleted from
- /// the hash map, and then returned from this function.
+ /// the hash map, and then returned from this function. Removal may
+ /// move other entries: any previously returned key or value
+ /// pointers are invalidated.
pub fn fetchRemove(self: *Self, key: K) ?KV {
if (@sizeOf(Context) != 0)
@compileError("Cannot infer context " ++ @typeName(Context) ++ ", call fetchRemoveContext instead.");
return self.fetchRemoveContext(key, undefined);
}
pub fn fetchRemoveContext(self: *Self, key: K, ctx: Context) ?KV {
- return self.fetchRemoveAdapted(key, ctx);
- }
- pub fn fetchRemoveAdapted(self: *Self, key: anytype, ctx: anytype) ?KV {
- if (self.getIndex(key, ctx)) |idx| {
- const old_key = &self.keys()[idx];
- const old_val = &self.values()[idx];
- const result = KV{
- .key = old_key.*,
- .value = old_val.*,
- };
- self.metadata.?[idx].remove();
- old_key.* = undefined;
- old_val.* = undefined;
- self.header().size -= 1;
- return result;
- }
-
- return null;
+ const idx = self.getIndex(key, ctx) orelse return null;
+ const result = KV{
+ .key = self.keys()[idx],
+ .value = self.values()[idx],
+ };
+ self.removeByIndexContext(idx, ctx);
+ return result;
}
/// Find the index containing the data for the given key.
@@ -524,7 +538,7 @@ fn HashMapUnmanaged(
}
const mask = self.capacity() - 1;
const fingerprint = Metadata.takeFingerprint(hash);
- // Don't loop indefinitely when there are no empty slots.
+ // Don't loop indefinitely when there are no free slots.
var limit = self.capacity();
var idx = @as(usize, @truncate(hash & mask));
@@ -664,10 +678,10 @@ fn HashMapUnmanaged(
return self.getOrPutContextAdapted(key, key_ctx);
}
pub fn getOrPutContextAdapted(self: *Self, key: anytype, key_ctx: anytype) Allocator.Error!GetOrPutResult {
- self.growIfNeeded(1) catch |err| {
- // If allocation fails, try to do the lookup anyway.
- // If we find an existing item, we can return it.
- // Otherwise return the error, we could not add another.
+ self.checkCapacity(1) catch |err| {
+ // The map is full. Try to do the lookup anyway; if we find
+ // an existing item, we can return it. Otherwise return the
+ // error, we could not add another.
const index = self.getIndex(key, key_ctx) orelse return err;
return GetOrPutResult{
.key_ptr = &self.keys()[index],
@@ -704,7 +718,6 @@ fn HashMapUnmanaged(
var limit = self.capacity();
var idx = @as(usize, @truncate(hash & mask));
- var first_tombstone_idx: usize = self.capacity(); // invalid index
var metadata = self.metadata.? + idx;
while (!metadata[0].isFree() and limit != 0) {
if (metadata[0].isUsed() and metadata[0].fingerprint == fingerprint) {
@@ -724,8 +737,6 @@ fn HashMapUnmanaged(
.found_existing = true,
};
}
- } else if (first_tombstone_idx == self.capacity() and metadata[0].isTombstone()) {
- first_tombstone_idx = idx;
}
limit -= 1;
@@ -733,11 +744,11 @@ fn HashMapUnmanaged(
metadata = self.metadata.? + idx;
}
- if (first_tombstone_idx < self.capacity()) {
- // Cheap try to lower probing lengths after deletions. Recycle a tombstone.
- idx = first_tombstone_idx;
- metadata = self.metadata.? + idx;
- }
+ // The caller guaranteed capacity for at least one new entry, so
+ // the probe must have ended at a free slot. Anything else means
+ // the assume-capacity contract was violated and we would be
+ // silently overwriting a live entry.
+ assert(metadata[0].isFree());
metadata[0].fill(fingerprint);
const new_key = &self.keys()[idx];
@@ -780,37 +791,74 @@ fn HashMapUnmanaged(
return self.getIndex(key, ctx) != null;
}
- fn removeByIndex(self: *Self, idx: usize) void {
- self.metadata.?[idx].remove();
- self.keys()[idx] = undefined;
- self.values()[idx] = undefined;
+ /// Remove the entry at the given index using backward-shift deletion
+ /// (Knuth vol. 3, section 6.4, algorithm R): rather than marking the
+ /// slot with a tombstone, restore the table to the state it would be
+ /// in had the removed key never been inserted. Any entry whose probe
+ /// sequence passes over the hole is moved into it, which moves the
+ /// hole further along the cluster, until the cluster ends at a free
+ /// slot.
+ fn removeByIndexContext(self: *Self, idx: usize, ctx: Context) void {
+ const mask: usize = self.capacity() - 1;
+ const metadata = self.metadata.?;
+ const keys_ptr = self.keys();
+ const values_ptr = self.values();
+
+ // A completely full table has no free slot to terminate the
+ // scan, so bound it to one full cycle. That is sufficient: the
+ // hole only ever moves forward to slots the scan has already
+ // visited, so each entry needs to be considered exactly once.
+ var hole = idx;
+ var j = idx;
+ var limit = self.capacity() - 1;
+ while (limit != 0) : (limit -= 1) {
+ j = (j + 1) & mask;
+ if (metadata[j].isFree()) break;
+
+ // The entry at `j` may move into the hole only if the hole
+ // lies on its probe path, i.e. cyclically within [home, j).
+ // Otherwise the move would place it before its home slot
+ // and lookups could no longer find it.
+ const home: usize = @truncate(ctx.hash(keys_ptr[j]) & mask);
+ if (((hole -% home) & mask) < ((j -% home) & mask)) {
+ metadata[hole] = metadata[j];
+ keys_ptr[hole] = keys_ptr[j];
+ values_ptr[hole] = values_ptr[j];
+ hole = j;
+ }
+ }
+
+ metadata[hole] = .{};
+ keys_ptr[hole] = undefined;
+ values_ptr[hole] = undefined;
self.header().size -= 1;
}
/// If there is an `Entry` with a matching key, it is deleted from
/// the hash map, and this function returns true. Otherwise this
- /// function returns false.
+ /// function returns false. Removal may move other entries: any
+ /// previously returned key or value pointers are invalidated.
pub fn remove(self: *Self, key: K) bool {
if (@sizeOf(Context) != 0)
@compileError("Cannot infer context " ++ @typeName(Context) ++ ", call removeContext instead.");
return self.removeContext(key, undefined);
}
pub fn removeContext(self: *Self, key: K, ctx: Context) bool {
- return self.removeAdapted(key, ctx);
- }
- pub fn removeAdapted(self: *Self, key: anytype, ctx: anytype) bool {
- if (self.getIndex(key, ctx)) |idx| {
- self.removeByIndex(idx);
- return true;
- }
-
- return false;
+ const idx = self.getIndex(key, ctx) orelse return false;
+ self.removeByIndexContext(idx, ctx);
+ return true;
}
/// Delete the entry with key pointed to by key_ptr from the hash map.
/// key_ptr is assumed to be a valid pointer to a key that is present
- /// in the hash map.
+ /// in the hash map. Removal may move other entries: any previously
+ /// returned key or value pointers are invalidated.
pub fn removeByPtr(self: *Self, key_ptr: *K) void {
+ if (@sizeOf(Context) != 0)
+ @compileError("Cannot infer context " ++ @typeName(Context) ++ ", call removeByPtrContext instead.");
+ return self.removeByPtrContext(key_ptr, undefined);
+ }
+ pub fn removeByPtrContext(self: *Self, key_ptr: *K, ctx: Context) void {
// TODO: replace with pointer subtraction once supported by zig
// if @sizeOf(K) == 0 then there is at most one item in the hash
// map, which is assumed to exist as key_ptr must be valid. This
@@ -820,18 +868,29 @@ fn HashMapUnmanaged(
else
0;
- self.removeByIndex(idx);
+ self.removeByIndexContext(idx, ctx);
}
fn initMetadatas(self: *Self) void {
@memset(@as([*]u8, @ptrCast(self.metadata.?))[0 .. @sizeOf(Metadata) * self.capacity()], 0);
}
- fn growIfNeeded(self: *Self, new_count: Size) Allocator.Error!void {
- const available = self.capacity() - self.header().size;
+ /// Returns an error if the map cannot hold `new_count` more entries.
+ /// This map is fixed-capacity so nothing can be done to make room;
+ /// the caller must grow the backing memory and rebuild the map.
+ fn checkCapacity(self: *Self, new_count: Size) Allocator.Error!void {
+ const available = self.maxLoad() - self.header().size;
if (new_count > available) return error.OutOfMemory;
}
+ fn maxLoadForCapacity(cap: Size) Size {
+ if (cap == 0) return 0;
+ return @intCast(@divFloor(
+ @as(u64, cap) * max_load_percentage,
+ 100,
+ ));
+ }
+
/// The memory layout for the underlying buffer for a given capacity.
const Layout = struct {
/// The total size of the buffer required. The buffer is expected
@@ -888,6 +947,37 @@ fn HashMapUnmanaged(
.capacity = new_capacity,
};
}
+
+ /// Returns a layout with enough raw slots to hold `new_size` entries
+ /// at the configured maximum load factor.
+ pub fn layoutForSize(new_size: Size) Layout {
+ if (new_size == 0) return layoutForCapacity(0);
+
+ // Scale the requested number of entries up to the raw slot count
+ // required by the load factor. Widen first so `new_size * 100`
+ // cannot overflow Size.
+ const minimum_capacity = std.math.divCeil(
+ u64,
+ @as(u64, new_size) * 100,
+ max_load_percentage,
+ ) catch unreachable;
+
+ // Capacities must be powers of two, so the largest capacity that
+ // fits in Size is the highest bit rather than maxInt(Size).
+ const max_capacity = @as(u64, 1) <<
+ (@typeInfo(Size).int.bits - 1);
+ if (minimum_capacity > max_capacity) {
+ return layoutForCapacity(@intCast(max_capacity));
+ }
+
+ // Linear probing uses a mask for wraparound, which requires the
+ // final raw capacity to be rounded up to a power of two.
+ const raw_capacity = std.math.ceilPowerOfTwo(
+ u64,
+ minimum_capacity,
+ ) catch unreachable;
+ return layoutForCapacity(@intCast(raw_capacity));
+ }
};
}
@@ -895,8 +985,28 @@ const testing = std.testing;
const expect = std.testing.expect;
const expectEqual = std.testing.expectEqual;
+/// Verify the canonical placement invariant that backward-shift deletion
+/// maintains: every used entry is reachable from its home slot without
+/// crossing a free slot. This is exactly the property lookups depend on.
+fn expectCanonical(map: anytype, ctx: anytype) !void {
+ const cap = map.capacity();
+ const mask = cap - 1;
+ var used: usize = 0;
+ for (0..cap) |idx| {
+ const metadata = map.metadata.?[idx];
+ if (!metadata.isUsed()) continue;
+ used += 1;
+
+ var probe: usize = @truncate(ctx.hash(map.keys()[idx]) & mask);
+ while (probe != idx) : (probe = (probe + 1) & mask) {
+ try expect(map.metadata.?[probe].isUsed());
+ }
+ }
+ try expectEqual(map.count(), used);
+}
+
test "HashMap basic usage" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const alloc = testing.allocator;
const cap = 16;
@@ -931,7 +1041,7 @@ test "HashMap basic usage" {
}
test "HashMap ensureTotalCapacity" {
- const Map = AutoHashMapUnmanaged(i32, i32);
+ const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -950,8 +1060,8 @@ test "HashMap ensureTotalCapacity" {
try testing.expect(initial_capacity == map.capacity());
}
-test "HashMap ensureUnusedCapacity with tombstones" {
- const Map = AutoHashMapUnmanaged(i32, i32);
+test "HashMap ensureUnusedCapacity with removals" {
+ const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -969,7 +1079,7 @@ test "HashMap ensureUnusedCapacity with tombstones" {
}
test "HashMap clearRetainingCapacity" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const cap = 16;
const alloc = testing.allocator;
@@ -1000,8 +1110,8 @@ test "HashMap clearRetainingCapacity" {
}
test "HashMap ensureTotalCapacity with existing elements" {
- const Map = AutoHashMapUnmanaged(u32, u32);
- const cap = Map.minimal_capacity;
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
+ const cap = 8;
const alloc = testing.allocator;
const layout = Map.layoutForCapacity(cap);
@@ -1011,15 +1121,15 @@ test "HashMap ensureTotalCapacity with existing elements" {
try map.put(0, 0);
try expectEqual(map.count(), 1);
- try expectEqual(map.capacity(), Map.minimal_capacity);
+ try expectEqual(map.capacity(), cap);
try testing.expectError(error.OutOfMemory, map.ensureTotalCapacity(65));
try expectEqual(map.count(), 1);
- try expectEqual(map.capacity(), Map.minimal_capacity);
+ try expectEqual(map.capacity(), cap);
}
test "HashMap remove" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -1057,7 +1167,7 @@ test "HashMap remove" {
}
test "HashMap reverse removes" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -1085,7 +1195,7 @@ test "HashMap reverse removes" {
}
test "HashMap multiple removes on same metadata" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -1128,7 +1238,7 @@ test "HashMap multiple removes on same metadata" {
}
test "HashMap put and remove loop in random order" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const cap = 64;
const alloc = testing.allocator;
@@ -1166,7 +1276,7 @@ test "HashMap put and remove loop in random order" {
}
test "HashMap put" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -1197,7 +1307,7 @@ test "HashMap put" {
}
test "HashMap put full load" {
- const Map = AutoHashMapUnmanaged(usize, usize);
+ const Map = AutoHashMapUnmanaged(usize, usize, default_max_load_percentage);
const cap = 16;
const alloc = testing.allocator;
@@ -1213,7 +1323,7 @@ test "HashMap put full load" {
}
test "HashMap putAssumeCapacity" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -1248,7 +1358,7 @@ test "HashMap putAssumeCapacity" {
}
test "HashMap repeat putAssumeCapacity/remove" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -1283,8 +1393,191 @@ test "HashMap repeat putAssumeCapacity/remove" {
try expectEqual(map.count(), limit);
}
+test "HashMap no-clobber move after remove at max load" {
+ const Context = struct {
+ pub fn hash(_: @This(), key: u32) u64 {
+ return key;
+ }
+
+ pub fn eql(_: @This(), a: u32, b: u32) bool {
+ return a == b;
+ }
+ };
+ const Map = HashMapUnmanaged(u32, u32, Context, 80);
+ const cap = 16;
+
+ const alloc = testing.allocator;
+ const layout = Map.layoutForCapacity(cap);
+ const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
+ defer alloc.free(buf);
+ var map = Map.init(.init(buf), layout);
+
+ // Fill the map to its maximum load.
+ const max_load = map.maxLoad();
+ for (0..max_load) |i| {
+ map.putAssumeCapacityNoClobberContext(
+ @intCast(i),
+ @intCast(i),
+ .{},
+ );
+ }
+
+ // Model a managed-cell move: remove the source and insert the value at
+ // a destination known to be absent. This must work at maximum load for
+ // any number of moves since removal genuinely frees a slot.
+ for (0..100) |i| {
+ const src: u32 = @intCast(i);
+ const dst: u32 = @intCast(i + max_load);
+ try expect(map.removeContext(src, .{}));
+ map.putAssumeCapacityNoClobberContext(dst, dst, .{});
+
+ try expectEqual(max_load, map.count());
+ try expectEqual(dst, map.getContext(dst, .{}).?);
+ try expectCanonical(&map, Context{});
+ }
+}
+
+test "HashMap removal keeps colliding clusters findable" {
+ // All keys hash to the same home slot near the end of the table so
+ // that clusters wrap around the index mask. This exercises the cyclic
+ // arithmetic in backward-shift deletion.
+ const Context = struct {
+ pub fn hash(_: @This(), _: u32) u64 {
+ return 14;
+ }
+
+ pub fn eql(_: @This(), a: u32, b: u32) bool {
+ return a == b;
+ }
+ };
+ const Map = HashMapUnmanaged(
+ u32,
+ u32,
+ Context,
+ default_max_load_percentage,
+ );
+ const cap = 16;
+
+ const alloc = testing.allocator;
+ const layout = Map.layoutForCapacity(cap);
+ const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
+ defer alloc.free(buf);
+ var map = Map.init(.init(buf), layout);
+
+ // Fill half the table: the cluster spans the wraparound point.
+ for (0..cap / 2) |i| {
+ map.putAssumeCapacityNoClobberContext(@intCast(i), @intCast(i), .{});
+ }
+
+ // Remove from the middle of the cluster and verify all remaining
+ // entries stay findable after every removal.
+ var removed: usize = 0;
+ for ([_]u32{ 3, 0, 7, 4, 1, 6, 2, 5 }) |key| {
+ try expect(map.removeContext(key, .{}));
+ removed += 1;
+
+ for (0..cap / 2) |i| {
+ const k: u32 = @intCast(i);
+ const v = map.getContext(k, .{});
+ if (map.containsContext(k, .{})) {
+ try expectEqual(k, v.?);
+ }
+ }
+ try expectEqual(cap / 2 - removed, map.count());
+ try expectCanonical(&map, Context{});
+ }
+}
+
+test "HashMap removal from a completely full table" {
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
+ const cap = 64;
+
+ const alloc = testing.allocator;
+ const layout = Map.layoutForCapacity(cap);
+ const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
+ defer alloc.free(buf);
+ var map = Map.init(.init(buf), layout);
+
+ // A 100% load factor allows filling every raw slot, so removal cannot
+ // rely on a free slot to terminate its cluster scan.
+ for (0..cap) |i| {
+ map.putAssumeCapacityNoClobber(@intCast(i), @intCast(i));
+ }
+ try expectEqual(cap, map.count());
+
+ // Remove every other key, verifying everything else stays findable.
+ var expected: usize = cap;
+ for (0..cap) |i| {
+ if (i % 2 != 0) continue;
+ try expect(map.remove(@intCast(i)));
+ expected -= 1;
+ try expectEqual(expected, map.count());
+ }
+
+ for (0..cap) |i| {
+ if (i % 2 == 0) {
+ try expectEqual(null, map.get(@intCast(i)));
+ } else {
+ try expectEqual(i, map.get(@intCast(i)).?);
+ }
+ }
+ try expectCanonical(&map, AutoContext(u32){});
+}
+
+test "HashMap random operations against an oracle" {
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
+ const cap = 64;
+
+ const alloc = testing.allocator;
+ const layout = Map.layoutForCapacity(cap);
+ const buf = try alloc.alignedAlloc(u8, Map.base_align, layout.total_size);
+ defer alloc.free(buf);
+ var map = Map.init(.init(buf), layout);
+
+ var oracle: std.AutoHashMapUnmanaged(u32, u32) = .empty;
+ defer oracle.deinit(alloc);
+
+ var prng = std.Random.DefaultPrng.init(0xdeadbeef);
+ const random = prng.random();
+
+ // A small key space forces frequent hits, misses, and re-insertions
+ // at every load factor from empty to completely full.
+ const key_space = cap + cap / 2;
+ for (0..20_000) |_| {
+ const key = random.uintLessThan(u32, key_space);
+ switch (random.uintLessThan(u8, 4)) {
+ 0, 1 => {
+ const value = random.int(u32);
+ if (map.put(key, value)) {
+ try oracle.put(alloc, key, value);
+ } else |_| {
+ // Map is full: the oracle must not know this key
+ // (put on an existing key always succeeds).
+ try expect(!oracle.contains(key));
+ try expectEqual(map.count(), map.capacity());
+ }
+ },
+ 2 => try expectEqual(
+ oracle.remove(key),
+ map.remove(key),
+ ),
+ 3 => try expectEqual(oracle.get(key), map.get(key)),
+ else => unreachable,
+ }
+
+ try expectEqual(oracle.count(), map.count());
+ }
+
+ // Final full comparison plus the canonical placement invariant.
+ var it = oracle.iterator();
+ while (it.next()) |entry| {
+ try expectEqual(entry.value_ptr.*, map.get(entry.key_ptr.*).?);
+ }
+ try expectCanonical(&map, AutoContext(u32){});
+}
+
test "HashMap getOrPut" {
- const Map = AutoHashMapUnmanaged(u32, u32);
+ const Map = AutoHashMapUnmanaged(u32, u32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -1313,7 +1606,7 @@ test "HashMap getOrPut" {
}
test "HashMap basic hash map usage" {
- const Map = AutoHashMapUnmanaged(i32, i32);
+ const Map = AutoHashMapUnmanaged(i32, i32, default_max_load_percentage);
const cap = 32;
const alloc = testing.allocator;
@@ -1364,7 +1657,7 @@ test "HashMap basic hash map usage" {
}
test "HashMap ensureUnusedCapacity" {
- const Map = AutoHashMapUnmanaged(u64, u64);
+ const Map = AutoHashMapUnmanaged(u64, u64, default_max_load_percentage);
const cap = 64;
const alloc = testing.allocator;
@@ -1378,7 +1671,7 @@ test "HashMap ensureUnusedCapacity" {
}
test "HashMap removeByPtr" {
- const Map = AutoHashMapUnmanaged(i32, u64);
+ const Map = AutoHashMapUnmanaged(i32, u64, default_max_load_percentage);
const cap = 64;
const alloc = testing.allocator;
@@ -1409,7 +1702,7 @@ test "HashMap removeByPtr" {
}
test "HashMap removeByPtr 0 sized key" {
- const Map = AutoHashMapUnmanaged(i32, u64);
+ const Map = AutoHashMapUnmanaged(i32, u64, default_max_load_percentage);
const cap = 64;
const alloc = testing.allocator;
@@ -1433,7 +1726,7 @@ test "HashMap removeByPtr 0 sized key" {
}
test "HashMap repeat fetchRemove" {
- const Map = AutoHashMapUnmanaged(u64, void);
+ const Map = AutoHashMapUnmanaged(u64, void, default_max_load_percentage);
const cap = 64;
const alloc = testing.allocator;
@@ -1461,7 +1754,11 @@ test "HashMap repeat fetchRemove" {
}
test "OffsetHashMap basic usage" {
- const OffsetMap = AutoOffsetHashMap(u32, u32);
+ const OffsetMap = AutoOffsetHashMap(
+ u32,
+ u32,
+ default_max_load_percentage,
+ );
const cap = 16;
const alloc = testing.allocator;
@@ -1496,7 +1793,11 @@ test "OffsetHashMap basic usage" {
}
test "OffsetHashMap remake map" {
- const OffsetMap = AutoOffsetHashMap(u32, u32);
+ const OffsetMap = AutoOffsetHashMap(
+ u32,
+ u32,
+ default_max_load_percentage,
+ );
const cap = 16;
const alloc = testing.allocator;
@@ -1516,12 +1817,44 @@ test "OffsetHashMap remake map" {
}
}
+test "OffsetHashMap maximum load leaves probe headroom" {
+ const OffsetMap = AutoOffsetHashMap(u32, u32, 80);
+ const alloc = testing.allocator;
+ const requested_size = 16;
+ const layout = OffsetMap.layout(requested_size);
+ const buf = try alloc.alignedAlloc(
+ u8,
+ OffsetMap.base_align,
+ layout.total_size,
+ );
+ defer alloc.free(buf);
+
+ const offset_map = OffsetMap.init(.init(buf), layout);
+ var map = offset_map.map(buf);
+
+ try testing.expect(map.capacity() > requested_size);
+ try testing.expect(map.maxLoad() >= requested_size);
+ try testing.expect(map.maxLoad() < map.capacity());
+
+ for (0..requested_size) |i| try map.put(@intCast(i), @intCast(i));
+ for (0..100) |_| {
+ for (0..requested_size) |i| {
+ try testing.expect(map.remove(@intCast(i)));
+ try map.put(@intCast(i), @intCast(i));
+ }
+ }
+
+ for (0..requested_size) |i| {
+ try testing.expectEqual(@as(u32, @intCast(i)), map.get(@intCast(i)));
+ }
+}
+
test "layoutForCapacity no overflow for large capacity" {
// Test that layoutForCapacity correctly handles large capacities without overflow.
// Prior to the fix, new_capacity (u32) was multiplied before widening to usize,
// causing overflow when new_capacity * @sizeOf(K) exceeded 2^32.
// See: https://github.com/ghostty-org/ghostty/issues/9862
- const Map = AutoHashMapUnmanaged(u64, u64);
+ const Map = AutoHashMapUnmanaged(u64, u64, default_max_load_percentage);
// Use 2^30 capacity - this would overflow in u32 when multiplied by @sizeOf(u64)=8
// 0x40000000 * 8 = 0x2_0000_0000 which wraps to 0 in u32
diff --git a/vendor/libghostty-vt/src/terminal/hyperlink.zig b/vendor/libghostty-vt/src/terminal/hyperlink.zig
index 94f86466..35a16a2a 100644
--- a/vendor/libghostty-vt/src/terminal/hyperlink.zig
+++ b/vendor/libghostty-vt/src/terminal/hyperlink.zig
@@ -20,7 +20,7 @@ pub const Id = size.HyperlinkCountInt;
// The mapping of cell to hyperlink. We use an offset hash map to save space
// since its very unlikely a cell is a hyperlink, so its a waste to store
// the hyperlink ID in the cell itself.
-pub const Map = AutoOffsetHashMap(Offset(Cell), Id);
+pub const Map = AutoOffsetHashMap(Offset(Cell), Id, 80);
/// A fully decoded hyperlink that may or may not have its
/// memory within a page. The memory location of this is dependent
diff --git a/vendor/libghostty-vt/src/terminal/kitty/color.zig b/vendor/libghostty-vt/src/terminal/kitty/color.zig
index c1072c39..3d2e950c 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/color.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/color.zig
@@ -51,6 +51,23 @@ pub const Kind = union(enum) {
return .{ .palette = std.fmt.parseUnsigned(u8, key, 10) catch return null };
}
+ /// Returns true when a terminal has built-in state for this key.
+ ///
+ /// Unsupported special colors may still be valid Kitty protocol keys, but
+ /// libghostty-vt cannot report them because Terminal does not store them.
+ pub fn hasTerminalQueryColor(self: Kind) bool {
+ return switch (self) {
+ .palette => true,
+ .special => |special| switch (special) {
+ .foreground,
+ .background,
+ .cursor,
+ => true,
+ else => false,
+ },
+ };
+ }
+
pub fn format(
self: Kind,
writer: *std.Io.Writer,
@@ -74,4 +91,8 @@ test "OSC: kitty color protocol kind string" {
const actual = try std.fmt.bufPrint(&buf, "{f}", .{Kind{ .palette = 42 }});
try testing.expectEqualStrings("42", actual);
}
+
+ try testing.expect((Kind{ .palette = 42 }).hasTerminalQueryColor());
+ try testing.expect((Kind{ .special = .foreground }).hasTerminalQueryColor());
+ try testing.expect(!(Kind{ .special = .selection_background }).hasTerminalQueryColor());
}
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig
index 6659cd31..1f639fab 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics.zig
@@ -29,6 +29,7 @@ pub const LoadingImage = image.LoadingImage;
pub const ImageStorage = storage.ImageStorage;
pub const RenderPlacement = render.Placement;
pub const Response = command.Response;
+pub const nextGeneration = storage.nextGeneration;
pub const execute = exec.execute;
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig
index aa35fbeb..bc0ffb5b 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_command.zig
@@ -149,6 +149,26 @@ pub const Parser = struct {
}
}
+ /// Feed a slice of bytes to the parser. This is equivalent to
+ /// calling feed for each byte in order, but once we're in the data
+ /// state the remainder of the slice is appended in bulk, avoiding
+ /// per-byte overhead for large payloads.
+ pub fn feedSlice(self: *Parser, bytes: []const u8) !void {
+ var rem = bytes;
+ while (rem.len > 0) {
+ if (self.state == .data) {
+ if (self.data.items.len + rem.len > self.max_bytes) {
+ return error.OutOfMemory;
+ }
+ try self.data.appendSlice(self.arena.child_allocator, rem);
+ return;
+ }
+
+ try self.feed(rem[0]);
+ rem = rem[1..];
+ }
+ }
+
/// Complete the parsing. This must be called after all the
/// bytes have been fed to the parser.
///
@@ -987,6 +1007,57 @@ test "transmission command" {
try testing.expectEqual(@as(u32, 20), v.height);
}
+test "feedSlice matches per-byte feed" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ const input = "f=24,s=10,v=20;aGVsbG8gd29ybGQ=";
+
+ var p1 = Parser.init(alloc, 1024 * 1024);
+ defer p1.deinit();
+ for (input) |c| try p1.feed(c);
+ const c1 = try p1.complete(alloc);
+ defer c1.deinit(alloc);
+
+ var p2 = Parser.init(alloc, 1024 * 1024);
+ defer p2.deinit();
+ try p2.feedSlice(input);
+ const c2 = try p2.complete(alloc);
+ defer c2.deinit(alloc);
+
+ try testing.expect(c1.control == .transmit);
+ try testing.expect(c2.control == .transmit);
+ try testing.expectEqualStrings(c1.data, c2.data);
+}
+
+test "feedSlice across slice boundaries" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var p = Parser.init(alloc, 1024 * 1024);
+ defer p.deinit();
+
+ try p.feedSlice("f=24,s=10");
+ try p.feedSlice(",v=20;aGVsbG8g");
+ try p.feedSlice("d29ybGQ=");
+ const command = try p.complete(alloc);
+ defer command.deinit(alloc);
+
+ try testing.expect(command.control == .transmit);
+
+ // The payload is base64-decoded on completion.
+ try testing.expectEqualStrings("hello world", command.data);
+}
+
+test "feedSlice respects max_bytes" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var p = Parser.init(alloc, 4);
+ defer p.deinit();
+
+ try p.feedSlice("f=24;ab");
+ try testing.expectError(error.OutOfMemory, p.feedSlice("cde"));
+}
+
test "transmission ignores 'm' if medium is not direct" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
index a6a879e5..6c72ec4e 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_exec.zig
@@ -380,7 +380,6 @@ const EncodeableError = Image.Error || Allocator.Error;
fn encodeError(r: *Response, err: EncodeableError) void {
switch (err) {
error.OutOfMemory => r.message = "ENOMEM: out of memory",
- error.InternalError => r.message = "EINVAL: internal error",
error.InvalidData => r.message = "EINVAL: invalid data",
error.DecompressionFailed => r.message = "EINVAL: decompression failed",
error.FilePathTooLong => r.message = "EINVAL: file path too long",
@@ -573,3 +572,87 @@ test "kittygfx no response with no image ID or number load and display" {
try testing.expect(resp == null);
}
}
+
+test "kittygfx retransmit same id gets fresh image generation" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+ const storage = &t.screens.active.kitty_images;
+
+ // Transmit a 1x2 RGB image with id=1.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,t=d,f=24,i=1,s=1,v=2;////////",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ const gen1 = storage.imageById(1).?.generation;
+ try testing.expect(gen1 > 0);
+ try testing.expectEqual(gen1, storage.generation);
+
+ // Retransmit the same id with identical dimensions/length. The
+ // (width, height, format, len) tuple is identical, so only the
+ // generation can reveal that the contents were replaced.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,t=d,f=24,i=1,s=1,v=2;AAAAAAAA",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ const gen2 = storage.imageById(1).?.generation;
+ try testing.expect(gen2 > gen1);
+ try testing.expectEqual(gen2, storage.generation);
+}
+
+test "kittygfx delete then retransmit same id gets fresh generation" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var t = try Terminal.init(alloc, .{ .rows = 5, .cols = 5 });
+ defer t.deinit(alloc);
+ const storage = &t.screens.active.kitty_images;
+
+ // Transmit and display, then delete everything (including image
+ // data), then retransmit the same ID.
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=T,t=d,f=24,i=1,s=1,v=2,c=1,r=1;////////",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ const gen1 = storage.imageById(1).?.generation;
+
+ {
+ const cmd = try command.Parser.parseString(alloc, "a=d,d=A");
+ defer cmd.deinit(alloc);
+ const resp = execute(alloc, &t, &cmd);
+ try testing.expect(resp == null);
+ }
+ try testing.expect(storage.imageById(1) == null);
+ const gen_delete = storage.generation;
+ try testing.expect(gen_delete > gen1);
+
+ {
+ const cmd = try command.Parser.parseString(
+ alloc,
+ "a=t,t=d,f=24,i=1,s=1,v=2;////////",
+ );
+ defer cmd.deinit(alloc);
+ const resp = execute(alloc, &t, &cmd).?;
+ try testing.expect(resp.ok());
+ }
+ const gen2 = storage.imageById(1).?.generation;
+ try testing.expect(gen2 > gen1);
+ try testing.expect(gen2 > gen_delete);
+}
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
index 8243a632..b6f44fac 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_image.zig
@@ -401,12 +401,6 @@ pub const LoadingImage = struct {
return error.InvalidData;
}
- // Set our time
- self.image.transmit_time = std.time.Instant.now() catch |err| {
- log.warn("failed to get time: {}", .{err});
- return error.InternalError;
- };
-
// Everything looks good, copy the image data over.
var result = self.image;
result.data = try self.data.toOwnedSlice(alloc);
@@ -504,6 +498,12 @@ pub const LoadingImage = struct {
};
/// Image represents a single fully loaded image.
+///
+/// The image data is always fully decoded raw pixels: loading inflates
+/// any zlib-compressed payload and decodes PNG into RGBA before an image
+/// is completed, so `compression` is always `.none` and `format` is
+/// never `.png` for a stored image, and `data.len` always equals
+/// `width * height * bytes-per-pixel`.
pub const Image = struct {
id: u32 = 0,
number: u32 = 0,
@@ -512,7 +512,14 @@ pub const Image = struct {
format: command.Transmission.Format = .rgb,
compression: command.Transmission.Compression = .none,
data: []const u8 = "",
- transmit_time: std.time.Instant = undefined,
+
+ /// Unique, monotonically increasing stamp assigned each time an
+ /// image is added to (or replaced in) an ImageStorage. A changed
+ /// generation for a given image ID means the image contents may
+ /// have changed, even if the dimensions and byte length are the
+ /// same (e.g. a retransmission of the same ID). Stamps order by
+ /// transmission time. Zero means "never stored".
+ generation: u64 = 0,
/// Set this to true if this image was loaded by a command that
/// doesn't specify an ID or number, since such commands should
@@ -521,7 +528,6 @@ pub const Image = struct {
implicit_id: bool = false,
pub const Error = error{
- InternalError,
InvalidData,
DecompressionFailed,
DimensionsRequired,
diff --git a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig
index e017d5f7..ba82ea40 100644
--- a/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig
+++ b/vendor/libghostty-vt/src/terminal/kitty/graphics_storage.zig
@@ -16,6 +16,53 @@ const Command = command.Command;
const log = std.log.scoped(.kitty_gfx);
+/// Process-global counter backing all generation stamps (see
+/// ImageStorage.generation and Image.generation). This is global rather
+/// than per-storage so that stamps are unique across every storage in
+/// the process: two mutation events never produce the same value, even
+/// across separate screens (main vs. alt), storage resets, or separate
+/// terminals. This lets consumers use a generation value alone as a
+/// cache key without any ambiguity.
+///
+/// Thread-safe because separate terminals may mutate their storages
+/// from different threads. On single-threaded targets this lowers to
+/// plain operations.
+var generation_counter: GenerationCounter = .{};
+
+/// Returns the next generation stamp. Stamps are unique and strictly
+/// monotonically increasing process-wide, starting at 1 (0 is reserved
+/// to mean "never stamped").
+pub fn nextGeneration() u64 {
+ return generation_counter.next();
+}
+
+/// Backing implementation for the generation counter. We use a
+/// lock-free atomic counter where we can, but not all targets support
+/// 64-bit atomic operations (e.g. 32-bit ARM Android), so we fall back
+/// to a mutex-protected counter on those. This is a cold path (only
+/// invoked on content mutations) so the mutex cost is irrelevant.
+///
+/// The pointer-width check is a conservative proxy for 64-bit atomic
+/// support: every 64-bit target supports 64-bit atomics, while 32-bit
+/// targets may not (per the compiler's atomic operand validation).
+const GenerationCounter = if (@bitSizeOf(usize) >= 64) struct {
+ value: std.atomic.Value(u64) = .init(0),
+
+ fn next(self: *@This()) u64 {
+ return self.value.fetchAdd(1, .monotonic) + 1;
+ }
+} else struct {
+ mutex: std.Thread.Mutex = .{},
+ value: u64 = 0,
+
+ fn next(self: *@This()) u64 {
+ self.mutex.lock();
+ defer self.mutex.unlock();
+ self.value += 1;
+ return self.value;
+ }
+};
+
/// An image storage is associated with a terminal screen (i.e. main
/// screen, alt screen) and contains all the transmitted images and
/// placements.
@@ -27,8 +74,32 @@ pub const ImageStorage = struct {
/// purely informational for the renderer and doesn't affect the
/// correctness of the program. The renderer must set this to false
/// if it cares about this value.
+ ///
+ /// Note that dirty is also set by scrolling and resizing (outside
+ /// of this struct) because those move placement pins, even though
+ /// the set of images/placements itself is unchanged. See generation
+ /// for a signal that only tracks content mutations.
+ ///
+ /// Invariant: dirty is always set when generation changes
+ /// (markMutated sets both); dirty set without a generation change
+ /// means a geometry-only event.
dirty: bool = false,
+ /// Generation stamp of the last content mutation to this storage:
+ /// any image transmit/replace, placement add, or delete of either.
+ /// Zero means the storage has never been mutated (and is therefore
+ /// empty).
+ ///
+ /// Unlike dirty, this is NOT updated by scrolling/resizing, so an
+ /// unchanged generation means the placement set and all image data
+ /// are identical; only placement geometry (pins) may have moved.
+ /// Values come from a process-global monotonic counter, so a value
+ /// observed from any storage never recurs for different content,
+ /// even across screen switches or storage resets.
+ ///
+ /// This field must only be written via markMutated.
+ generation: u64 = 0,
+
/// This is the next automatically assigned image ID. We start mid-way
/// through the u32 range to avoid collisions with buggy programs.
/// TODO: This isn't good enough, it's perfectly legal for programs
@@ -80,6 +151,19 @@ pub const ImageStorage = struct {
return self.total_limit != 0;
}
+ /// Record a content mutation: marks the storage dirty and assigns a
+ /// fresh generation stamp. Must be called by anything that changes
+ /// the set of images or placements (or image contents).
+ ///
+ /// Do NOT call this for geometry-only events (scrolling, resizing,
+ /// screen switches); those must set only the dirty flag directly.
+ /// Bumping the generation for geometry changes would break the
+ /// contract that an unchanged generation means unchanged contents.
+ fn markMutated(self: *ImageStorage) void {
+ self.dirty = true;
+ self.generation = nextGeneration();
+ }
+
/// Sets the limit in bytes for the total amount of image data that
/// can be loaded. If this limit is lower, this will do an eviction
/// if necessary. If the value is zero, then Kitty image protocol will
@@ -95,6 +179,7 @@ pub const ImageStorage = struct {
const image_limits = self.image_limits;
self.deinit(alloc, s);
self.* = .{ .image_limits = image_limits };
+ self.markMutated();
}
// If we re lowering our limit, check if we need to evict.
@@ -144,7 +229,12 @@ pub const ImageStorage = struct {
gop.value_ptr.* = img;
self.total_bytes += img.data.len;
- self.dirty = true;
+ // Stamp the stored image with a fresh generation. This gives
+ // every add/replace a unique stamp even when the same image ID
+ // is retransmitted with identical dimensions, so consumers
+ // (e.g. renderer texture caches) can detect content changes.
+ self.markMutated();
+ gop.value_ptr.generation = self.generation;
}
/// Add a placement for a given image. The caller must verify in advance
@@ -185,7 +275,7 @@ pub const ImageStorage = struct {
const gop = try self.placements.getOrPut(alloc, key);
gop.value_ptr.* = p;
- self.dirty = true;
+ self.markMutated();
}
fn clearPlacements(self: *ImageStorage, s: *terminal.Screen) void {
@@ -207,7 +297,7 @@ pub const ImageStorage = struct {
while (it.next()) |kv| {
if (kv.value_ptr.number == image_number) {
if (newest == null or
- kv.value_ptr.transmit_time.order(newest.?.transmit_time) == .gt)
+ kv.value_ptr.generation > newest.?.generation)
{
newest = kv.value_ptr.*;
}
@@ -224,6 +314,17 @@ pub const ImageStorage = struct {
t: *terminal.Terminal,
cmd: command.Delete,
) void {
+ // Deletes only ever remove placements/images, so comparing counts
+ // before and after tells us whether anything actually changed.
+ // Only then do we mark a mutation. This matters because a
+ // delete-all runs on every screen clear (e.g. `ESC [ 2 J`), and
+ // we don't want empty clears to dirty the image state or bump
+ // the generation.
+ const placements_before = self.placements.count();
+ const images_before = self.images.count();
+ defer if (self.placements.count() != placements_before or
+ self.images.count() != images_before) self.markMutated();
+
switch (cmd) {
.all => |delete_images| {
var it = self.placements.iterator();
@@ -245,8 +346,6 @@ pub const ImageStorage = struct {
var image_it = self.images.iterator();
while (image_it.next()) |kv| self.deleteIfUnused(alloc, kv.key_ptr.*);
}
-
- self.dirty = true;
},
.id => |v| self.deleteById(
@@ -341,9 +440,6 @@ pub const ImageStorage = struct {
if (v.delete) self.deleteIfUnused(alloc, img.id);
}
}
-
- // Mark dirty to force redraw
- self.dirty = true;
},
.row => |v| row: {
@@ -373,9 +469,6 @@ pub const ImageStorage = struct {
if (v.delete) self.deleteIfUnused(alloc, img.id);
}
}
-
- // Mark dirty to force redraw
- self.dirty = true;
},
.z => |v| {
@@ -396,9 +489,6 @@ pub const ImageStorage = struct {
if (v.delete) self.deleteIfUnused(alloc, image_id);
}
}
-
- // Mark dirty to force redraw
- self.dirty = true;
},
.range => |v| range: {
@@ -420,9 +510,6 @@ pub const ImageStorage = struct {
if (v.delete) self.deleteIfUnused(alloc, image_id);
}
}
-
- // Mark dirty to force redraw
- self.dirty = true;
},
// We don't support animation frames yet so they are successfully
@@ -461,9 +548,6 @@ pub const ImageStorage = struct {
// If this is specified, then we also delete the image
// if it is no longer in use.
if (delete_unused) self.deleteIfUnused(alloc, image_id);
-
- // Mark dirty to force redraw
- self.dirty = true;
}
/// Delete an image if it is unused.
@@ -507,9 +591,6 @@ pub const ImageStorage = struct {
if (delete_unused) self.deleteIfUnused(alloc, img.id);
}
}
-
- // Mark dirty to force redraw
- self.dirty = true;
}
/// Evict image to make space. This will evict the oldest image,
@@ -526,7 +607,7 @@ pub const ImageStorage = struct {
// bit is fine compared to the megabytes we're looking to save.
const Candidate = struct {
id: u32,
- time: std.time.Instant,
+ generation: u64,
used: bool,
};
@@ -554,7 +635,7 @@ pub const ImageStorage = struct {
try candidates.append(alloc, .{
.id = img.id,
- .time = img.transmit_time,
+ .generation = img.generation,
.used = used,
});
}
@@ -572,12 +653,14 @@ pub const ImageStorage = struct {
) bool {
_ = ctx;
- // If they're usage matches, then its based on time.
- if (lhs.used == rhs.used) return switch (lhs.time.order(rhs.time)) {
- .lt => true,
- .gt => false,
- .eq => lhs.id < rhs.id,
- };
+ // If their usage matches, then it's based on the
+ // generation stamp, which orders by transmit time.
+ // (Stamps are unique but tie-break by ID anyway to
+ // stay deterministic for hand-built test images.)
+ if (lhs.used == rhs.used) return if (lhs.generation == rhs.generation)
+ lhs.id < rhs.id
+ else
+ lhs.generation < rhs.generation;
// If not used, then its a better candidate
return !lhs.used;
@@ -585,6 +668,11 @@ pub const ImageStorage = struct {
}.lessThan,
);
+ // Evicting anything is a content mutation. This matters for the
+ // setLimit path in particular, which doesn't otherwise mark it.
+ var any_evicted = false;
+ defer if (any_evicted) self.markMutated();
+
// They're in order of best to evict.
var evicted: usize = 0;
for (candidates.items) |c| {
@@ -593,6 +681,7 @@ pub const ImageStorage = struct {
while (p_it.next()) |entry| {
if (entry.key_ptr.image_id == c.id) {
self.placements.removeByPtr(entry.key_ptr);
+ any_evicted = true;
}
}
@@ -604,6 +693,7 @@ pub const ImageStorage = struct {
entry.value_ptr.deinit(alloc);
self.images.removeByPtr(entry.key_ptr);
+ any_evicted = true;
if (evicted > req) return true;
}
@@ -1362,3 +1452,150 @@ test "storage: aspect ratio calculation when only columns or rows specified" {
try testing.expectEqual(@as(u32, 100), calc_size.height);
}
}
+
+test "storage: generation stamps on image add and replace" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
+ defer t.deinit(alloc);
+
+ var s: ImageStorage = .{};
+ defer s.deinit(alloc, t.screens.active);
+
+ // Fresh storage has generation zero (never mutated).
+ try testing.expectEqual(@as(u64, 0), s.generation);
+
+ try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 });
+ const gen1 = s.generation;
+ try testing.expect(gen1 > 0);
+
+ const img1 = s.imageById(1).?;
+ try testing.expectEqual(gen1, img1.generation);
+
+ // A second image gets a strictly greater stamp.
+ try s.addImage(alloc, .{ .id = 2, .width = 1, .height = 1 });
+ const gen2 = s.generation;
+ try testing.expect(gen2 > gen1);
+ try testing.expectEqual(gen2, s.imageById(2).?.generation);
+
+ // Retransmitting the same image ID (identical dimensions) gets a
+ // fresh stamp: this is what makes same-sized retransmissions
+ // detectable by renderers.
+ try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1 });
+ const gen3 = s.generation;
+ try testing.expect(gen3 > gen2);
+ try testing.expectEqual(gen3, s.imageById(1).?.generation);
+
+ // Image 2 kept its stamp.
+ try testing.expectEqual(gen2, s.imageById(2).?.generation);
+}
+
+test "storage: generation bumps on placement and delete" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
+ defer t.deinit(alloc);
+
+ var s: ImageStorage = .{};
+ defer s.deinit(alloc, t.screens.active);
+ try s.addImage(alloc, .{ .id = 1 });
+ const gen_add = s.generation;
+
+ try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } });
+ const gen_place = s.generation;
+ try testing.expect(gen_place > gen_add);
+
+ // Reads don't change the generation.
+ _ = s.imageById(1);
+ _ = s.imageByNumber(1);
+ try testing.expectEqual(gen_place, s.generation);
+
+ s.delete(alloc, &t, .{ .all = true });
+ try testing.expect(s.generation > gen_place);
+}
+
+test "storage: generation bumps when setLimit evicts or disables" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
+ defer t.deinit(alloc);
+
+ var s: ImageStorage = .{};
+ defer s.deinit(alloc, t.screens.active);
+
+ const data = try alloc.dupe(u8, "1234");
+ try s.addImage(alloc, .{ .id = 1, .width = 1, .height = 1, .data = data });
+ const gen_add = s.generation;
+
+ // Lowering the limit evicts the image and must mark a mutation.
+ s.dirty = false;
+ try s.setLimit(alloc, t.screens.active, 1);
+ try testing.expect(s.dirty);
+ try testing.expect(s.generation > gen_add);
+ try testing.expectEqual(@as(usize, 0), s.images.count());
+ const gen_evict = s.generation;
+
+ // Disabling (limit=0) resets the storage and must mark a mutation.
+ s.dirty = false;
+ try s.setLimit(alloc, t.screens.active, 0);
+ try testing.expect(s.dirty);
+ try testing.expect(s.generation > gen_evict);
+}
+
+test "storage: imageByNumber returns most recently transmitted" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
+ defer t.deinit(alloc);
+
+ var s: ImageStorage = .{};
+ defer s.deinit(alloc, t.screens.active);
+
+ // Two images sharing a number: the newest transmission wins,
+ // regardless of insertion order or clock resolution.
+ try s.addImage(alloc, .{ .id = 1, .number = 7 });
+ try s.addImage(alloc, .{ .id = 2, .number = 7 });
+ try testing.expectEqual(@as(u32, 2), s.imageByNumber(7).?.id);
+
+ // Retransmit the first: it becomes the newest.
+ try s.addImage(alloc, .{ .id = 1, .number = 7 });
+ try testing.expectEqual(@as(u32, 1), s.imageByNumber(7).?.id);
+}
+
+test "storage: nextGeneration is unique and monotonic" {
+ const testing = std.testing;
+ const a = nextGeneration();
+ const b = nextGeneration();
+ try testing.expect(b > a);
+ try testing.expect(a > 0);
+}
+
+test "storage: no-op delete does not mark a mutation" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+ var t = try terminal.Terminal.init(alloc, .{ .rows = 3, .cols = 3 });
+ defer t.deinit(alloc);
+
+ var s: ImageStorage = .{};
+ defer s.deinit(alloc, t.screens.active);
+
+ // A delete-all on an empty storage (this runs on every screen
+ // clear) must not dirty the state or bump the generation.
+ s.delete(alloc, &t, .{ .all = true });
+ try testing.expect(!s.dirty);
+ try testing.expectEqual(@as(u64, 0), s.generation);
+
+ // Same for a delete that matches nothing.
+ try s.addImage(alloc, .{ .id = 1 });
+ try s.addPlacement(alloc, 1, 1, .{ .location = .{ .pin = try trackPin(&t, .{ .x = 1, .y = 1 }) } });
+ const gen = s.generation;
+ s.dirty = false;
+ s.delete(alloc, &t, .{ .id = .{ .image_id = 42 } });
+ try testing.expect(!s.dirty);
+ try testing.expectEqual(gen, s.generation);
+
+ // But a delete that removes something does mark a mutation.
+ s.delete(alloc, &t, .{ .id = .{ .image_id = 1 } });
+ try testing.expect(s.dirty);
+ try testing.expect(s.generation > gen);
+}
diff --git a/vendor/libghostty-vt/src/terminal/main.zig b/vendor/libghostty-vt/src/terminal/main.zig
index 53491a00..af277f97 100644
--- a/vendor/libghostty-vt/src/terminal/main.zig
+++ b/vendor/libghostty-vt/src/terminal/main.zig
@@ -10,6 +10,7 @@ pub const dcs = @import("dcs.zig");
pub const osc = @import("osc.zig");
pub const point = @import("point.zig");
pub const color = @import("color.zig");
+pub const clipboard = @import("clipboard.zig");
pub const device_attributes = @import("device_attributes.zig");
pub const device_status = @import("device_status.zig");
pub const focus = @import("focus.zig");
@@ -75,6 +76,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 +87,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/vendor/libghostty-vt/src/terminal/mem.zig b/vendor/libghostty-vt/src/terminal/mem.zig
new file mode 100644
index 00000000..7fa31480
--- /dev/null
+++ b/vendor/libghostty-vt/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/vendor/libghostty-vt/src/terminal/osc.zig b/vendor/libghostty-vt/src/terminal/osc.zig
index 36cfd7f8..26ce1267 100644
--- a/vendor/libghostty-vt/src/terminal/osc.zig
+++ b/vendor/libghostty-vt/src/terminal/osc.zig
@@ -156,6 +156,9 @@ pub const Command = union(Key) {
kitty_clipboard_protocol: KittyClipboardProtocol,
+ /// Kitty drag and drop protocol (OSC 72)
+ kitty_dnd_protocol: KittyDndProtocol,
+
/// OSC 3008. Hierarchical context signalling (UAPI spec).
/// https://uapi-group.org/specifications/specs/osc_context/
context_signal: parsers.context_signal.Command,
@@ -164,6 +167,8 @@ pub const Command = union(Key) {
pub const KittyClipboardProtocol = parsers.kitty_clipboard_protocol.OSC;
+ pub const KittyDndProtocol = parsers.kitty_dnd_protocol.OSC;
+
pub const Key = LibEnum(
lib.target,
// NOTE: Order matters, see LibEnum documentation.
@@ -192,6 +197,7 @@ pub const Command = union(Key) {
"conemu_comment",
"kitty_text_sizing",
"kitty_clipboard_protocol",
+ "kitty_dnd_protocol",
"context_signal",
},
);
@@ -344,6 +350,7 @@ pub const Parser = struct {
@"52",
@"55",
@"66",
+ @"72",
@"77",
@"104",
@"110",
@@ -398,10 +405,12 @@ pub const Parser = struct {
.kitty_color_protocol => |*v| kitty_color_protocol: {
v.deinit(self.alloc orelse break :kitty_color_protocol);
},
+ .color_operation => |*v| color_operation: {
+ v.requests.deinit(self.alloc orelse break :color_operation);
+ },
.change_window_icon,
.change_window_title,
.clipboard_contents,
- .color_operation,
.conemu_change_tab_title,
.conemu_comment,
.conemu_guimacro,
@@ -421,6 +430,7 @@ pub const Parser = struct {
.show_desktop_notification,
.kitty_text_sizing,
.kitty_clipboard_protocol,
+ .kitty_dnd_protocol,
.context_signal,
=> {},
}
@@ -691,10 +701,16 @@ pub const Parser = struct {
.@"7" => switch (c) {
';' => self.captureTrailing(.fixed),
+ '2' => self.state = .@"72",
'7' => self.state = .@"77",
else => self.state = .invalid,
},
+ .@"72" => switch (c) {
+ ';' => self.captureTrailing(.allocating),
+ else => self.state = .invalid,
+ },
+
.@"77" => switch (c) {
'7' => self.state = .@"777",
else => self.state = .invalid,
@@ -805,6 +821,8 @@ pub const Parser = struct {
.@"66" => parsers.kitty_text_sizing.parse(self, terminator_ch),
+ .@"72" => parsers.kitty_dnd_protocol.parse(self, terminator_ch),
+
.@"77" => null,
.@"133" => parsers.semantic_prompt.parse(self, terminator_ch),
diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers.zig b/vendor/libghostty-vt/src/terminal/osc/parsers.zig
index a56b38e8..8cb91fb9 100644
--- a/vendor/libghostty-vt/src/terminal/osc/parsers.zig
+++ b/vendor/libghostty-vt/src/terminal/osc/parsers.zig
@@ -9,6 +9,7 @@ pub const hyperlink = @import("parsers/hyperlink.zig");
pub const iterm2 = @import("parsers/iterm2.zig");
pub const kitty_clipboard_protocol = @import("parsers/kitty_clipboard_protocol.zig");
pub const kitty_color = @import("parsers/kitty_color.zig");
+pub const kitty_dnd_protocol = @import("parsers/kitty_dnd_protocol.zig");
pub const kitty_text_sizing = @import("parsers/kitty_text_sizing.zig");
pub const mouse_shape = @import("parsers/mouse_shape.zig");
pub const osc9 = @import("parsers/osc9.zig");
diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig
new file mode 100644
index 00000000..35d23906
--- /dev/null
+++ b/vendor/libghostty-vt/src/terminal/osc/parsers/kitty_dnd_protocol.zig
@@ -0,0 +1,363 @@
+//! Kitty's drag and drop protocol (OSC 72)
+//! Specification: https://sw.kovidgoyal.net/kitty/drag-and-drop-protocol/
+
+const std = @import("std");
+
+const assert = @import("../../../quirks.zig").inlineAssert;
+
+const Parser = @import("../../osc.zig").Parser;
+const Command = @import("../../osc.zig").Command;
+const Terminator = @import("../../osc.zig").Terminator;
+
+const log = std.log.scoped(.kitty_dnd_protocol);
+
+pub const OSC = struct {
+ /// The raw metadata that was received. Parse individual values with `readOption`.
+ metadata: []const u8,
+ /// The raw payload. Its meaning and encoding depend on the event type (`t` key).
+ payload: ?[]const u8,
+ /// The terminator used for this OSC, so any response can match it.
+ terminator: Terminator,
+
+ pub fn readOption(self: OSC, comptime key: Option) ?key.Type() {
+ return key.read(self.metadata);
+ }
+};
+
+/// Values for the `t` (event type) metadata key.
+pub const EventType = enum {
+ /// ('a') Terminal registers itself as willing to accept drops.
+ accept_drops,
+ /// ('A') Terminal unregisters itself; drops should no longer be forwarded.
+ stop_accepting_drops,
+ /// ('m') Pointer is moving over the terminal while a drag is in progress.
+ /// Carries `x`/`y` cursor position; -1 signals the drag left the window.
+ drop_move,
+ /// ('M') Items were dropped onto the terminal.
+ /// Carries `x`/`y` drop position and `i` (multiplexer session ID).
+ drop_dropped,
+ /// ('r') Terminal requests data for a specific MIME type from the drag source.
+ /// Carries `i` (multiplexer session ID) and `y` (1-based MIME type index).
+ request_data,
+ /// ('R') Error response to a `request_data` event.
+ request_error,
+ /// ('o') Terminal offers data for an outgoing drag (drag-out from terminal).
+ offer_drag,
+ /// ('p') Drag source presents the actual payload for a previously requested MIME type.
+ /// Carries `i` (multiplexer session ID), `o` (operation), and `m` (chunking flag).
+ present_data,
+ /// ('P') Replace the current drag image with a new one.
+ /// Payload is the image data; `X`/`Y` carry image dimensions in pixels.
+ change_drag_image,
+ /// ('e') Notification of an event on an outgoing drag offer (e.g., accepted or rejected).
+ drag_offer_event,
+ /// ('E') Error on an outgoing drag offer.
+ drag_offer_error,
+ /// ('k') URI list data delivered as part of a drag or clipboard transfer.
+ uri_list_data,
+ /// ('q') Query terminal capabilities related to the drag-and-drop protocol.
+ query,
+
+ pub fn init(str: []const u8) ?EventType {
+ if (str.len != 1) return null;
+ return switch (str[0]) {
+ 'a' => .accept_drops,
+ 'A' => .stop_accepting_drops,
+ 'm' => .drop_move,
+ 'M' => .drop_dropped,
+ 'r' => .request_data,
+ 'R' => .request_error,
+ 'o' => .offer_drag,
+ 'p' => .present_data,
+ 'P' => .change_drag_image,
+ 'e' => .drag_offer_event,
+ 'E' => .drag_offer_error,
+ 'k' => .uri_list_data,
+ 'q' => .query,
+ else => null,
+ };
+ }
+};
+
+/// Metadata keys defined by the protocol. Keys are case-sensitive: `x` and `X` are distinct.
+pub const Option = enum {
+ /// Event type. Maps to `EventType`; present in every OSC 72 sequence.
+ t,
+ /// Chunking flag. `0` = this is the final (or only) chunk; `1` = more chunks follow.
+ m,
+ /// Multiplexer session ID. Echoed back in responses so a terminal multiplexer
+ /// (e.g. tmux) can route data to the correct pane.
+ i,
+ /// Drop operation. `0` = reject, `1` = copy, `2` = move, `3` = copy or move.
+ o,
+ /// Cursor column in cell units (zero-based). -1 signals the drag has left the window.
+ x,
+ /// Cursor row in cell units (zero-based). Also used as a 1-based MIME type index
+ /// in some events (e.g. `request_data`). -1 signals the drag has left the window.
+ y,
+ /// Pixel offset from the left edge of the cell; also used as image width
+ /// (with `change_drag_image`) or as a symlink/directory marker.
+ X,
+ /// Pixel offset from the top edge of the cell; also used as image height
+ /// (with `change_drag_image`) or as a parent directory handle.
+ Y,
+
+ pub fn Type(comptime key: Option) type {
+ return switch (key) {
+ .t => EventType,
+ // The spec uses 32-bit signed or unsigned; we standardize on
+ // i32 because the location keys legitimately take -1 (drag
+ // leaves the window) and other keys never exceed i32 range.
+ .m, .i, .o, .x, .y, .X, .Y => i32,
+ };
+ }
+
+ pub fn read(comptime key: Option, metadata: []const u8) ?key.Type() {
+ const name = @tagName(key);
+
+ const value: []const u8 = value: {
+ var pos: usize = 0;
+ while (pos < metadata.len) {
+ while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
+ if (pos >= metadata.len) return null;
+
+ // Case-sensitive match: x and X must not be confused.
+ if (!std.mem.startsWith(u8, metadata[pos..], name)) {
+ pos = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse return null;
+ pos += 1;
+ continue;
+ }
+ pos += name.len;
+
+ while (pos < metadata.len and std.ascii.isWhitespace(metadata[pos])) pos += 1;
+ if (pos >= metadata.len) return null;
+ if (metadata[pos] != '=') return null;
+
+ const end = std.mem.indexOfScalarPos(u8, metadata, pos, ':') orelse metadata.len;
+ const start = pos + 1;
+ break :value std.mem.trim(u8, metadata[start..end], &std.ascii.whitespace);
+ }
+ return null;
+ };
+
+ return switch (key) {
+ .t => .init(value),
+ .m, .i, .o, .x, .y, .X, .Y => std.fmt.parseInt(i32, value, 10) catch null,
+ };
+ }
+};
+
+test "OSC 72: metadata only, no payload" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const input = "72;t=a";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expectEqualStrings("t=a", cmd.kitty_dnd_protocol.metadata);
+ try testing.expect(cmd.kitty_dnd_protocol.payload == null);
+}
+
+test "OSC 72: metadata and empty payload" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const input = "72;t=a;";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expectEqualStrings("t=a", cmd.kitty_dnd_protocol.metadata);
+ try testing.expectEqualStrings("", cmd.kitty_dnd_protocol.payload.?);
+}
+
+test "OSC 72: metadata and non-empty payload" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const input = "72;t=a:i=5;text/plain text/uri-list";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expectEqualStrings("t=a:i=5", cmd.kitty_dnd_protocol.metadata);
+ try testing.expectEqualStrings("text/plain text/uri-list", cmd.kitty_dnd_protocol.payload.?);
+}
+
+test "OSC 72: readOption .t valid event types" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const cases = .{
+ .{ "72;t=a", EventType.accept_drops },
+ .{ "72;t=A", EventType.stop_accepting_drops },
+ .{ "72;t=m", EventType.drop_move },
+ .{ "72;t=M", EventType.drop_dropped },
+ .{ "72;t=r", EventType.request_data },
+ .{ "72;t=R", EventType.request_error },
+ .{ "72;t=o", EventType.offer_drag },
+ .{ "72;t=p", EventType.present_data },
+ .{ "72;t=P", EventType.change_drag_image },
+ .{ "72;t=e", EventType.drag_offer_event },
+ .{ "72;t=E", EventType.drag_offer_error },
+ .{ "72;t=k", EventType.uri_list_data },
+ .{ "72;t=q", EventType.query },
+ };
+
+ inline for (cases) |case| {
+ p.deinit();
+ p = .init(testing.allocator);
+ for (case[0]) |ch| p.next(ch);
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expectEqual(case[1], cmd.kitty_dnd_protocol.readOption(.t).?);
+ }
+}
+
+test "OSC 72: readOption .t unknown value returns null" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const input = "72;t=z";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expect(cmd.kitty_dnd_protocol.readOption(.t) == null);
+}
+
+test "OSC 72: readOption integer keys" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const input = "72;t=m:i=3:x=10:y=5:X=320:Y=200:o=1:m=0";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expectEqual(@as(i32, 3), cmd.kitty_dnd_protocol.readOption(.i).?);
+ try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?);
+ try testing.expectEqual(@as(i32, 5), cmd.kitty_dnd_protocol.readOption(.y).?);
+ try testing.expectEqual(@as(i32, 320), cmd.kitty_dnd_protocol.readOption(.X).?);
+ try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?);
+ try testing.expectEqual(@as(i32, 1), cmd.kitty_dnd_protocol.readOption(.o).?);
+ try testing.expectEqual(@as(i32, 0), cmd.kitty_dnd_protocol.readOption(.m).?);
+}
+
+test "OSC 72: readOption negative sentinel (-1 for drag leave)" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const input = "72;t=m:x=-1:y=-1";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.x).?);
+ try testing.expectEqual(@as(i32, -1), cmd.kitty_dnd_protocol.readOption(.y).?);
+}
+
+test "OSC 72: readOption case-sensitive key matching" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ // x=10 must not be returned when asking for .X
+ const input = "72;x=10:Y=200";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expectEqual(@as(i32, 10), cmd.kitty_dnd_protocol.readOption(.x).?);
+ try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null);
+ try testing.expectEqual(@as(i32, 200), cmd.kitty_dnd_protocol.readOption(.Y).?);
+ try testing.expect(cmd.kitty_dnd_protocol.readOption(.y) == null);
+}
+
+test "OSC 72: readOption absent key returns null" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const input = "72;t=a";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expect(cmd.kitty_dnd_protocol.readOption(.i) == null);
+ try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null);
+ try testing.expect(cmd.kitty_dnd_protocol.readOption(.X) == null);
+ try testing.expect(cmd.kitty_dnd_protocol.readOption(.m) == null);
+}
+
+test "OSC 72: readOption malformed integer returns null" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const input = "72;x=notanumber";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end('\x1b').?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expect(cmd.kitty_dnd_protocol.readOption(.x) == null);
+}
+
+test "OSC 72: BEL terminator recorded" {
+ const testing = std.testing;
+
+ var p: Parser = .init(testing.allocator);
+ defer p.deinit();
+
+ const input = "72;t=q";
+ for (input) |ch| p.next(ch);
+
+ const cmd = p.end(0x07).?.*;
+ try testing.expect(cmd == .kitty_dnd_protocol);
+ try testing.expect(cmd.kitty_dnd_protocol.terminator == .bel);
+}
+
+pub fn parse(parser: *Parser, terminator_ch: ?u8) ?*Command {
+ assert(parser.state == .@"72");
+
+ const cap = if (parser.capture) |*c| c else {
+ parser.state = .invalid;
+ return null;
+ };
+
+ const data = cap.trailing();
+
+ const metadata: []const u8, const payload: ?[]const u8 = result: {
+ const sep = std.mem.indexOfScalar(u8, data, ';') orelse break :result .{ data, null };
+ break :result .{ data[0..sep], data[sep + 1 .. data.len] };
+ };
+
+ parser.command = .{
+ .kitty_dnd_protocol = .{
+ .metadata = metadata,
+ .payload = payload,
+ .terminator = .init(terminator_ch),
+ },
+ };
+
+ return &parser.command;
+}
diff --git a/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig b/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig
index 25152d7c..97212fbb 100644
--- a/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig
+++ b/vendor/libghostty-vt/src/terminal/osc/parsers/semantic_prompt.zig
@@ -60,6 +60,16 @@ pub const Command = struct {
}
};
+// ClickEvents can either be a click_events=1 or click_events=2.
+// The click_events=1 sends a click event with the absolute coordinates
+// of the click.
+// The click_events=2 sends a click event with the coordinates of the click
+// relative to the prompt area.
+// See https://github.com/ghostty-org/ghostty/issues/10865 and
+// https://github.com/kovidgoyal/kitty/issues/9500
+// for further details.
+pub const ClickEvents = enum { absolute, relative };
+
pub const Option = enum {
aid,
cl,
@@ -102,7 +112,7 @@ pub const Option = enum {
.err => []const u8,
.redraw => Redraw,
.special_key => bool,
- .click_events => bool,
+ .click_events => ClickEvents,
.cmdline => []const u8,
.cmdline_url => []const u8,
.exit_code => i32,
@@ -200,7 +210,12 @@ pub const Option = enum {
.last
else
null,
- .special_key, .click_events => if (value.len == 1) switch (value[0]) {
+ .click_events => if (value.len == 1) switch (value[0]) {
+ '1' => .absolute,
+ '2' => .relative,
+ else => null,
+ } else null,
+ .special_key => if (value.len == 1) switch (value[0]) {
'0' => false,
'1' => true,
else => null,
@@ -1249,9 +1264,10 @@ test "Option.read special_key" {
test "Option.read click_events" {
const testing = std.testing;
- try testing.expect(Option.click_events.read("click_events=1").? == true);
- try testing.expect(Option.click_events.read("click_events=0").? == false);
try testing.expect(Option.click_events.read("click_events=yes") == null);
+ try testing.expect(Option.click_events.read("click_events=0") == null);
+ try testing.expect(Option.click_events.read("click_events=1").? == .absolute);
+ try testing.expect(Option.click_events.read("click_events=2").? == .relative);
}
test "Option.read exit_code" {
diff --git a/vendor/libghostty-vt/src/terminal/page.zig b/vendor/libghostty-vt/src/terminal/page.zig
index 0942e260..ca6d9854 100644
--- a/vendor/libghostty-vt/src/terminal/page.zig
+++ b/vendor/libghostty-vt/src/terminal/page.zig
@@ -92,7 +92,11 @@ const grapheme_chunk = grapheme_chunk_len * @sizeOf(u21);
const GraphemeAlloc = BitmapAllocator(grapheme_chunk);
const grapheme_count_default = GraphemeAlloc.bitmap_bit_size;
pub const grapheme_bytes_default = grapheme_count_default * grapheme_chunk;
-const GraphemeMap = AutoOffsetHashMap(Offset(Cell), Offset(u21).Slice);
+const GraphemeMap = AutoOffsetHashMap(
+ Offset(Cell),
+ Offset(u21).Slice,
+ hash_map.default_max_load_percentage,
+);
/// The allocator used for shared utf8-encoded strings within a page.
/// Note the chunk size below is the minimum size of a single allocation
@@ -145,6 +149,15 @@ pub const Page = struct {
@alignOf(Cell),
StyleSet.base_align.toByteUnits(),
) == 0);
+
+ // The PageList memory pool requires that initBuf overwrites at
+ // least the first pointer-size bytes of the backing buffer:
+ // std.heap.MemoryPool stores its free list node there when a
+ // page buffer is returned to it, and pool reuse skips zeroing
+ // in release builds. This holds because the rows array is at
+ // offset 0 (see layout), a page always has at least one row,
+ // and initBuf fully rewrites every row.
+ assert(@sizeOf(Row) >= @sizeOf(usize));
}
/// The backing memory for the page. A page is always made up of a
@@ -238,6 +251,13 @@ pub const Page = struct {
/// It is up to the caller to not call deinit on these pages.
pub inline fn initBuf(buf: OffsetBuf, l: Layout) Page {
const cap = l.capacity;
+
+ // A page must always have at least one row. Aside from being
+ // useless otherwise, the row initialization below must always
+ // overwrite the start of the buffer for pool reuse. See the
+ // comptime assert at the top of Page.
+ assert(cap.rows > 0);
+
const rows = buf.member(Row, l.rows_start);
const cells = buf.member(Cell, l.cells_start);
@@ -754,11 +774,11 @@ pub const Page = struct {
}
}
- // The hyperlink_map capacity in layout() is computed as:
- // hyperlink_count * hyperlink_cell_multiplier (rounded to power of 2)
- // We need enough hyperlink_bytes so that when layout() computes
- // the map capacity, it can accommodate all hyperlink cells. This
- // is unit tested.
+ // layout() requests `hyperlink_count * hyperlink_cell_multiplier`
+ // usable map entries. The map layout adds load-factor headroom and
+ // rounds the raw slot count to a power of two. We need enough
+ // hyperlink_bytes for that requested entry count to accommodate all
+ // hyperlink cells. This is unit tested.
const hyperlink_cap = cap: {
const hyperlink_count = id_set.count();
const hyperlink_set_cap = hyperlink.Set.capacityForCount(hyperlink_count);
@@ -1461,7 +1481,7 @@ pub const Page = struct {
const entry = map.getEntry(src_offset).?;
const value = entry.value_ptr.*;
map.removeByPtr(entry.key_ptr);
- map.putAssumeCapacity(dst_offset, value);
+ map.putAssumeCapacityNoClobber(dst_offset, value);
// NOTE: We must not set src/dst.hyperlink here because this
// function is used in various cases where we swap cell contents
@@ -1478,7 +1498,7 @@ pub const Page = struct {
/// Returns the hyperlink capacity for the page. This isn't the byte
/// size but the number of unique cells that can have hyperlink data.
pub inline fn hyperlinkCapacity(self: *const Page) usize {
- return self.hyperlink_map.map(self.memory).capacity();
+ return self.hyperlink_map.map(self.memory).maxLoad();
}
/// Set the graphemes for the given cell. This asserts that the cell
@@ -1609,7 +1629,7 @@ pub const Page = struct {
const entry = map.getEntry(src_offset).?;
const value = entry.value_ptr.*;
map.removeByPtr(entry.key_ptr);
- map.putAssumeCapacity(dst_offset, value);
+ map.putAssumeCapacityNoClobber(dst_offset, value);
}
/// Clear the graphemes for a given cell.
@@ -1703,6 +1723,11 @@ pub const Page = struct {
/// and rows size.
pub inline fn layout(cap: Capacity) Layout {
const rows_count: usize = @intCast(cap.rows);
+
+ // The rows array must stay at offset 0: the PageList memory
+ // pool relies on initBuf overwriting the first bytes of a
+ // reused page buffer, which hold the pool's free list node.
+ // See the comptime assert at the top of Page.
const rows_start = 0;
const rows_end: usize = rows_start + (rows_count * @sizeOf(Row));
@@ -1744,7 +1769,7 @@ pub const Page = struct {
u32,
hyperlink_count * hyperlink_cell_multiplier,
) orelse break :count std.math.maxInt(u32);
- break :count std.math.ceilPowerOfTwoAssert(u32, mult);
+ break :count mult;
};
const hyperlink_map_layout = hyperlink.Map.layout(hyperlink_map_count);
const hyperlink_map_start = alignForward(usize, hyperlink_set_end, hyperlink.Map.base_align.toByteUnits());
@@ -1988,6 +2013,11 @@ pub const Row = packed struct(u64) {
prompt_continuation = 2,
};
+ /// The backing integer of this packed struct. Prefer this over
+ /// hardcoding the integer type so that code is resilient to the
+ /// size changing.
+ pub const Backing = @typeInfo(Row).@"struct".backing_integer.?;
+
/// C ABI type.
pub const C = u64;
@@ -2100,6 +2130,11 @@ pub const Cell = packed struct(u64) {
prompt = 2,
};
+ /// The backing integer of this packed struct. Prefer this over
+ /// hardcoding the integer type so that code is resilient to the
+ /// size changing.
+ pub const Backing = @typeInfo(Cell).@"struct".backing_integer.?;
+
/// C ABI type.
pub const C = u64;
@@ -2194,6 +2229,270 @@ pub const Cell = packed struct(u64) {
}
};
+/// Returns a mask with all bits set for the given fields of the packed
+/// struct T, used for masked compares of raw backing-integer values
+/// (e.g. `Row.Backing`, `Cell.Backing`). This is an implementation
+/// detail of `Mask`, which is the public API built on top of this.
+fn fieldMask(
+ comptime T: type,
+ comptime fields: []const []const u8,
+) @typeInfo(T).@"struct".backing_integer.? {
+ // Backing int of the packed struct
+ const Int = @typeInfo(T).@"struct".backing_integer.?;
+
+ var mask: Int = 0;
+ inline for (fields) |field| {
+ // The type that fits all the bits we need to set.
+ const Ones = std.meta.Int(
+ .unsigned,
+ @bitSizeOf(@FieldType(T, field)),
+ );
+
+ // Mask out the ones
+ mask |= @as(Int, std.math.maxInt(Ones)) << @bitOffsetOf(T, field);
+ }
+
+ return mask;
+}
+
+/// A comptime-generated helper for classifying and comparing packed
+/// struct values (e.g. Row, Cell) in bulk, using masked compares of
+/// their raw backing integers.
+///
+/// Masked compares are the key to making bulk row/cell processing fast.
+/// Rows and cells are small packed structs specifically so that a single
+/// integer load observes every field at once. A masked compare can then
+/// answer a multi-field question with one AND and one compare, instead
+/// of extracting and branching on each field individually (each packed
+/// field access compiles to its own shift/mask). Just as importantly,
+/// the integer form vectorizes trivially: `@splat` the mask and expected
+/// value, and whole groups of rows or cells can be classified with a
+/// few SIMD instructions.
+///
+/// Some real examples of this in use:
+///
+/// - Terminal print fast path: a cell can be overwritten by the
+/// simple/fast path only if its content tag is a plain codepoint,
+/// it has no style, isn't wide, and isn't a hyperlink. Masking
+/// with those fields and comparing against a template answers all
+/// four questions in one compare per cell.
+///
+/// - Render state updates: a cell whose masked
+/// `{content_tag, style_id}` bits are zero is a plain cell that
+/// needs no managed-memory handling, so a vector OR-reduce can
+/// skip entire groups of plain cells at once. Similarly, cells
+/// whose masked bits equal the first cell's form a run sharing one
+/// style, letting the update record one style lookup per run
+/// rather than per cell.
+///
+/// - Dirty scans: OR-reducing groups of rows against the `dirty`
+/// field mask finds whether any row in the group needs a rebuild
+/// without touching each row's flag individually.
+///
+/// T is the packed struct type, fields are the fields covered by the
+/// mask, and group_len is the number of values processed at once by
+/// the group (vectorized) operations. Callers typically scan a slice
+/// with the group operations and fall back to the scalar variants for
+/// the remainder and for pinpointing values within a matched group.
+pub fn Mask(
+ comptime T: type,
+ comptime fields: []const []const u8,
+ comptime group_len_param: comptime_int,
+) type {
+ return struct {
+ const Backing = @typeInfo(T).@"struct".backing_integer.?;
+ const mask: Backing = fieldMask(T, fields);
+
+ /// The number of values processed at once by group operations.
+ pub const group_len = group_len_param;
+
+ /// A group of raw values for the vectorized operations.
+ const Group = @Vector(group_len, Backing);
+
+ /// Load a group of values from the slice starting at index i.
+ /// Asserts that at least group_len values are available.
+ inline fn load(values: []const T, i: usize) Group {
+ return @bitCast(values[i..][0..group_len].*);
+ }
+
+ /// Returns the raw backing bits of a single value.
+ pub inline fn bits(v: T) Backing {
+ return @bitCast(v);
+ }
+
+ /// Returns the masked bits of a single value: the bits of the
+ /// masked fields with all other fields zeroed. Use this to
+ /// build the expected value for the eql functions.
+ pub inline fn pattern(v: T) Backing {
+ return bits(v) & mask;
+ }
+
+ /// Returns the backing bits of a single value with the masked
+ /// fields zeroed: the complement of `pattern`. Use this to
+ /// compare values while ignoring the masked fields.
+ pub inline fn strip(v: T) Backing {
+ return bits(v) & ~mask;
+ }
+
+ /// Returns true if every value in the group of group_len
+ /// values starting at index i matches, where a value matches
+ /// when none of the masked fields have any bits set: false
+ /// for bools, zero for ints, the zero tag for enums, and so
+ /// on. Asserts that at least group_len values are available.
+ pub inline fn match(values: []const T, i: usize) bool {
+ return @reduce(.Or, load(values, i)) & mask == 0;
+ }
+
+ /// Scalar variant of `match` for a single value.
+ pub inline fn matchScalar(v: T) bool {
+ return bits(v) & mask == 0;
+ }
+
+ /// Returns true if the masked fields of every value in the
+ /// group of group_len values starting at index i equal the
+ /// expected pattern (see `pattern`).
+ ///
+ /// This is a masked compare: fields outside the mask may vary
+ /// freely. Use this to detect runs of values that share the
+ /// masked field contents while other fields differ, e.g. a run
+ /// of cells with the same style ID but different codepoints.
+ /// If the result you derive from a run depends on fields
+ /// outside the mask, use `eqlExact` instead.
+ pub inline fn eql(
+ values: []const T,
+ i: usize,
+ expected: Backing,
+ ) bool {
+ const masked = load(values, i) & @as(Group, @splat(mask));
+ return @reduce(.And, masked == @as(Group, @splat(expected)));
+ }
+
+ /// Scalar variant of `eql` for a single value.
+ pub inline fn eqlScalar(v: T, expected: Backing) bool {
+ return pattern(v) == expected;
+ }
+
+ /// Like `eql` but returns the number of leading values whose
+ /// masked fields equal the expected pattern, i.e. group_len if
+ /// the entire group matches. This is useful for early-exit run
+ /// scans that need to pinpoint exactly where a run ends rather
+ /// than only whether the whole group matches.
+ pub inline fn eqlPrefix(
+ values: []const T,
+ i: usize,
+ expected: Backing,
+ ) usize {
+ const masked = load(values, i) & @as(Group, @splat(mask));
+ const ok = masked == @as(Group, @splat(expected));
+
+ // Test the whole group before extracting the prefix
+ // count: turning a vector compare into a scalar bitmask
+ // is expensive on some targets (e.g. NEON has no movemask
+ // instruction) and run scans overwhelmingly see fully
+ // matching groups, so we only pay for the extraction on
+ // the final group of a run.
+ if (@reduce(.And, ok)) {
+ @branchHint(.likely);
+ return group_len;
+ }
+
+ const ok_bits: std.meta.Int(
+ .unsigned,
+ group_len,
+ ) = @bitCast(ok);
+ return @ctz(~ok_bits);
+ }
+
+ /// Returns true if every value in the group of group_len
+ /// values starting at index i is bit-identical to the expected
+ /// value. Note: this compares entire values; it is NOT
+ /// affected by the field mask.
+ ///
+ /// This exists alongside `eql` for run detection where the
+ /// derived result depends on fields outside the mask, so a
+ /// masked compare would incorrectly extend the run. For
+ /// example, the background color of a bg-color cell lives in
+ /// the content field: two such cells only share a background
+ /// if their content bits are identical, not merely their
+ /// content tag. Values in such runs are typically produced by
+ /// bulk fills (e.g. erase with a pending background) and are
+ /// bit-identical in practice, so exact equality is both
+ /// correct and cheap.
+ pub inline fn eqlExact(
+ values: []const T,
+ i: usize,
+ expected: Backing,
+ ) bool {
+ const group = load(values, i);
+ return @reduce(.And, group == @as(Group, @splat(expected)));
+ }
+ };
+}
+
+test "Mask" {
+ const M = Mask(Cell, &.{ "content_tag", "style_id" }, 4);
+
+ const plain: Cell = .init('A');
+ var styled: Cell = .init('B');
+ styled.style_id = 5;
+ var styled2: Cell = .init('C');
+ styled2.style_id = 5;
+ var other: Cell = .init('D');
+ other.style_id = 6;
+
+ // match: plain cells only
+ {
+ var cells: [4]Cell = .{ plain, plain, plain, plain };
+ try testing.expect(M.match(&cells, 0));
+ try testing.expect(M.matchScalar(plain));
+
+ cells[2] = styled;
+ try testing.expect(!M.match(&cells, 0));
+ try testing.expect(!M.matchScalar(styled));
+ }
+
+ // eql: runs of matching masked fields, other fields may vary
+ {
+ const expected = M.pattern(styled);
+ var cells: [4]Cell = .{ styled, styled2, styled, styled2 };
+ try testing.expect(M.eql(&cells, 0, expected));
+ try testing.expect(M.eqlScalar(styled2, expected));
+
+ cells[1] = other;
+ try testing.expect(!M.eql(&cells, 0, expected));
+ try testing.expect(!M.eqlScalar(other, expected));
+ }
+
+ // eqlPrefix: count of leading values matching the pattern
+ {
+ const expected = M.pattern(styled);
+ var cells: [4]Cell = .{ styled, styled2, other, styled };
+ try testing.expectEqual(2, M.eqlPrefix(&cells, 0, expected));
+
+ cells[2] = styled;
+ try testing.expectEqual(4, M.eqlPrefix(&cells, 0, expected));
+ }
+
+ // eqlExact: bit-identical values only
+ {
+ const expected = M.bits(styled);
+ var cells: [4]Cell = .{ styled, styled, styled, styled };
+ try testing.expect(M.eqlExact(&cells, 0, expected));
+
+ // Same masked fields but different codepoint is not exact.
+ cells[3] = styled2;
+ try testing.expect(!M.eqlExact(&cells, 0, expected));
+ }
+
+ // strip: compare values while ignoring the masked fields
+ {
+ var styled_other: Cell = .init('B');
+ styled_other.style_id = 6;
+ try testing.expectEqual(M.strip(styled), M.strip(styled_other));
+ try testing.expect(M.strip(styled) != M.strip(styled2));
+ }
+}
+
// Uncomment this when you want to do some math.
// test "Page size calculator" {
// const total_size = alignForward(
@@ -3916,3 +4215,20 @@ test "Page exactRowCapacity hyperlink map capacity for many cells" {
try testing.expect(cloned_cell.hyperlink);
}
}
+
+test "Page layout avoids double rounding hyperlink map capacity" {
+ const hyperlink_count = 3;
+ const layout = Page.layout(.{
+ .cols = 1,
+ .rows = 1,
+ .hyperlink_bytes = hyperlink_count * @sizeOf(hyperlink.Set.Item),
+ });
+
+ // Three set entries request 48 usable map entries. Scaling that for the
+ // 80% load factor needs 60 raw slots, which rounds once to 64. Rounding
+ // the request before applying the load factor would allocate 128 slots.
+ try std.testing.expectEqual(
+ @as(u32, 64),
+ layout.hyperlink_map_layout.capacity,
+ );
+}
diff --git a/vendor/libghostty-vt/src/terminal/ref_counted_set.zig b/vendor/libghostty-vt/src/terminal/ref_counted_set.zig
index 8040039a..dd76079f 100644
--- a/vendor/libghostty-vt/src/terminal/ref_counted_set.zig
+++ b/vendor/libghostty-vt/src/terminal/ref_counted_set.zig
@@ -497,6 +497,15 @@ pub fn RefCountedSet(
return self.lookupContext(base, value, self.context);
}
pub fn lookupContext(self: *const Self, base: anytype, value: T, ctx: Context) ?Id {
+ // A zero-capacity set (a valid special case of Layout.init)
+ // contains nothing and has a zero-size table, so we can't
+ // probe it: table[0] would read whatever memory follows the
+ // set in the backing buffer.
+ if (self.layout.table_cap == 0) {
+ @branchHint(.cold);
+ return null;
+ }
+
const table = self.table.ptr(base);
const items = self.items.ptr(base);
diff --git a/vendor/libghostty-vt/src/terminal/render.zig b/vendor/libghostty-vt/src/terminal/render.zig
index 98e14224..04f0c967 100644
--- a/vendor/libghostty-vt/src/terminal/render.zig
+++ b/vendor/libghostty-vt/src/terminal/render.zig
@@ -41,6 +41,29 @@ const Terminal = @import("Terminal.zig");
/// defer state.deinit(alloc);
/// state.update(alloc, &terminal);
///
+/// ## Two-Phase Updates
+///
+/// For callers that synchronize terminal access (e.g. a renderer thread
+/// sharing a lock with an IO thread), the update can be split into two
+/// phases to minimize the time the terminal must be held exclusively:
+/// `beginUpdate` requires terminal access, while `endUpdate` completes
+/// any deferred work using only memory owned by the render state.
+///
+/// {
+/// mutex.lock();
+/// defer mutex.unlock();
+/// try state.beginUpdate(alloc, &terminal);
+/// }
+///
+/// // The IO thread is free to modify the terminal while we
+/// // complete the update.
+/// state.endUpdate();
+///
+/// The render state must be treated as incomplete between the two calls.
+/// `update` is a convenience that performs both phases in one call.
+///
+/// ## Memory
+///
/// Note: the render state retains as much memory as possible between updates
/// to prevent future allocations. If a very large frame is rendered once,
/// the render state will retain that much memory until deinit. To avoid
@@ -91,6 +114,16 @@ pub const RenderState = struct {
/// if possible.
selection_cache: ?SelectionCache = null,
+ /// The pending style runs requiring an endUpdate call, in the
+ /// order they were recorded. If multiple begins happen without an
+ /// endUpdate call, runs accumulate; rows rebuilt more than once
+ /// may then have superseded (stale) runs in this list, which is
+ /// harmless: newer runs are appended later so they win, and cells
+ /// not covered by newer runs have a default style ID in their raw
+ /// data so their style is undefined by contract anyway. See
+ /// beginUpdate.
+ pending_styles: std.ArrayList(StyleRun) = .empty,
+
/// Initial state.
pub const empty: RenderState = .{
.rows = 0,
@@ -176,10 +209,16 @@ pub const RenderState = struct {
/// change often.
arena: ArenaAllocator.State,
- /// The page pin. This is not safe to read unless you can guarantee
- /// the terminal state hasn't changed since the last `update` call.
+ /// The page pin. Its copied values may be compared, but its node must
+ /// not be dereferenced unless the terminal state is protected from
+ /// changes since the last `update` call.
pin: PageList.Pin,
+ /// The page node generation captured alongside `pin`. This lets
+ /// consumers validate the pin without dereferencing its node after
+ /// the terminal lock has been released.
+ serial: u64,
+
/// Raw row data.
raw: page.Row,
@@ -244,6 +283,23 @@ pub const RenderState = struct {
br_pin: PageList.Pin,
};
+ /// A run of cells within one row sharing one style, pending
+ /// denormalization into the per-cell data. This is populated by
+ /// `beginUpdate` and consumed by `endUpdate`. This exists so that
+ /// the (potentially large) denormalization of styles into cells
+ /// can happen outside of any terminal locks. See `beginUpdate`.
+ pub const StyleRun = struct {
+ /// The viewport row.
+ y: size.CellCountInt,
+
+ /// Start (inclusive) and end (exclusive) x coordinates.
+ start: size.CellCountInt,
+ end: size.CellCountInt,
+
+ /// The style for this cell range.
+ style: Style,
+ };
+
pub fn deinit(self: *RenderState, alloc: Allocator) void {
for (
self.row_data.items(.arena),
@@ -254,10 +310,17 @@ pub const RenderState = struct {
cells.deinit(alloc);
}
self.row_data.deinit(alloc);
+ self.pending_styles.deinit(alloc);
}
/// Update the render state to the latest terminal state.
///
+ /// This is a convenience function that performs a full update in
+ /// one call, equivalent to `beginUpdate` immediately followed by
+ /// `endUpdate`. Callers that hold a lock over the terminal state
+ /// should prefer calling the two phases directly so that the lock
+ /// is only held for `beginUpdate`.
+ ///
/// This will reset the terminal dirty state since it is consumed
/// by this render state update.
pub fn update(
@@ -265,6 +328,36 @@ pub const RenderState = struct {
alloc: Allocator,
t: *Terminal,
) Allocator.Error!void {
+ try self.beginUpdate(alloc, t);
+ self.endUpdate();
+ }
+
+ /// Begin an update of the render state to the latest terminal
+ /// state. Every begin must be completed with an `endUpdate` call
+ /// before the render state is read.
+ ///
+ /// This two-phase structure exists for callers that lock the
+ /// terminal state: only this function requires terminal access, so
+ /// a caller can hold its lock for this call only and then call
+ /// `endUpdate` after releasing it. `endUpdate` exclusively reads
+ /// and writes memory owned by the render state.
+ ///
+ /// Work that doesn't require terminal access may be deferred to
+ /// `endUpdate` to keep this call (and therefore lock hold time) as
+ /// short as possible. At the time of writing, the deferred work is
+ /// the per-cell style denormalization, so between this call and
+ /// `endUpdate` the per-cell `style` data of any updated rows is
+ /// stale and must not be read. More work may be deferred in the
+ /// future; callers should treat the render state as incomplete
+ /// until `endUpdate` is called.
+ ///
+ /// This will reset the terminal dirty state since it is consumed
+ /// by this render state update.
+ pub fn beginUpdate(
+ self: *RenderState,
+ alloc: Allocator,
+ t: *Terminal,
+ ) Allocator.Error!void {
const s: *Screen = t.screens.active;
const viewport_pin = s.pages.getTopLeft(.viewport);
const redraw = redraw: {
@@ -322,7 +415,14 @@ pub const RenderState = struct {
// Colors.
self.colors.cursor = t.colors.cursor.get();
- self.colors.palette = t.colors.palette.current;
+
+ // The palette is a relatively large copy (768 bytes at the time
+ // of writing) so we only copy it when it could have changed. All
+ // palette modifications set a terminal-level dirty flag (see
+ // Terminal.Dirty.palette), and any terminal-level dirty flag
+ // forces a redraw, so checking redraw is sufficient.
+ if (redraw) self.colors.palette = t.colors.palette.current;
+
bg_fg: {
// Background/foreground can be unset initially which would
// depend on "default" background/foreground. The expected use
@@ -358,6 +458,7 @@ pub const RenderState = struct {
row_data.set(y, .{
.arena = .{},
.pin = undefined,
+ .serial = undefined,
.raw = undefined,
.cells = .empty,
.dirty = true,
@@ -383,33 +484,62 @@ pub const RenderState = struct {
const row_data = self.row_data.slice();
const row_arenas = row_data.items(.arena);
const row_pins = row_data.items(.pin);
+ const row_serials = row_data.items(.serial);
const row_rows = row_data.items(.raw);
const row_cells = row_data.items(.cells);
const row_sels = row_data.items(.selection);
const row_highlights = row_data.items(.highlights);
const row_dirties = row_data.items(.dirty);
- // Track the last page that we know was dirty. This lets us
- // more quickly do the full-page dirty check.
- var last_dirty_page: ?*page.Page = null;
-
- // Go through and setup our rows.
- var row_it = s.pages.rowIterator(
- .right_down,
- .{ .viewport = .{} },
- null,
- );
- var y: size.CellCountInt = 0;
+ // If we're redrawing then every row will be rebuilt, superseding
+ // any pending style runs from prior updates. Clearing also
+ // guarantees pending runs always match the current dimensions
+ // (dimension changes force a redraw).
+ if (redraw) self.pending_styles.clearRetainingCapacity();
+
+ // Go through and setup our rows. We iterate page chunks rather
+ // than individual rows so that per-page work (dirty flags, cursor
+ // detection, memory pointers) is hoisted out of the row loop. This
+ // makes the common case of a clean (or mostly clean) frame very
+ // cheap: a contiguous scan of row dirty flags.
+ const builder: RowBuilder = .{
+ .alloc = alloc,
+ .cols = self.cols,
+ .arenas = row_arenas,
+ .raws = row_rows,
+ .cells = row_cells,
+ .sels = row_sels,
+ .highlights = row_highlights,
+ .dirties = row_dirties,
+ .pending_styles = &self.pending_styles,
+ };
+ var y: usize = 0;
var any_dirty: bool = false;
- while (row_it.next()) |row_pin| : (y = y + 1) {
+ var page_it = viewport_pin.pageIterator(.right_down, null);
+ while (y < self.rows) {
+ const chunk = page_it.next() orelse break;
+ const node = chunk.node;
+ const node_serial = node.serial;
+ 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
+ // exactly `rows` tall) so we clamp.
+ const take: usize = @min(
+ @as(usize, chunk.end - chunk.start),
+ self.rows - y,
+ );
+
// Find our cursor if we haven't found it yet. We do this even
- // if the row is not dirty because the cursor is unrelated.
+ // if rows are not dirty because the cursor is unrelated. We
+ // can check the chunk bounds once rather than every row.
if (self.cursor.viewport == null and
- row_pin.node == s.cursor.page_pin.node and
- row_pin.y == s.cursor.page_pin.y)
- {
+ node == s.cursor.page_pin.node)
+ cursor: {
+ const cy = s.cursor.page_pin.y;
+ if (cy < chunk.start or cy >= chunk.start + take) break :cursor;
self.cursor.viewport = .{
- .y = y,
+ .y = @intCast(y + (cy - chunk.start)),
.x = s.cursor.x,
// Future: we should use our own state here to look this
@@ -421,135 +551,82 @@ pub const RenderState = struct {
};
}
- // Store our pin. We have to store these even if we're not dirty
- // because dirty is only a renderer optimization. It doesn't
- // apply to memory movement. This will let us remap any cell
- // pins back to an exact entry in our RenderState.
- row_pins[y] = row_pin;
-
- // Get all our cells in the page.
- const p: *page.Page = &row_pin.node.data;
- const page_rac = row_pin.rowAndCell();
-
- dirty: {
- // If we're redrawing then we're definitely dirty.
- if (redraw) break :dirty;
-
- // If our page is the same as last time then its dirty.
- if (p == last_dirty_page) break :dirty;
- if (p.dirty) {
- // If this page is dirty then clear the dirty flag
- // of the last page and then store this one. This benchmarks
- // faster than iterating pages again later.
- if (last_dirty_page) |last_p| last_p.dirty = false;
- last_dirty_page = p;
- break :dirty;
+ // The page-level dirty flag applies to every row in the chunk.
+ // We consume (clear) it now; each node appears at most once in
+ // this iteration and we're the only consumer of dirty state.
+ const page_dirty = p.dirty;
+ if (page_dirty) p.dirty = false;
+
+ // Get our contiguous rows for this chunk.
+ const page_rows: []page.Row = p.rows.ptr(p.memory)[chunk.start..][0..take];
+ assert(p.size.cols == self.cols);
+
+ // Store our pins and their node generations. We have to store
+ // these even for rows that aren't dirty because dirty is only a
+ // renderer optimization; it doesn't apply to memory movement.
+ // This lets us remap any cell pins back to an exact entry in our
+ // RenderState and validate them later without dereferencing a
+ // potentially stale node.
+ //
+ // We can skip the writes when the pins and serials are unchanged:
+ // if we're not redrawing, every value was stored by a prior update
+ // (row count changes force a redraw). Within a single update a
+ // node appears at most once and its stored pins have consecutive
+ // y values, so if the first and last entries of this chunk's range
+ // already match then every entry in between matches too.
+ if (redraw or
+ row_pins[y].node != node or
+ row_pins[y].y != chunk.start or
+ row_serials[y] != node_serial or
+ row_pins[y + take - 1].node != node or
+ row_pins[y + take - 1].y != chunk.start + take - 1 or
+ row_serials[y + take - 1] != node_serial)
+ {
+ for (
+ row_pins[y..][0..take],
+ row_serials[y..][0..take],
+ chunk.start..,
+ ) |*pin, *serial, py| {
+ pin.* = .{ .node = node, .y = @intCast(py) };
+ serial.* = node_serial;
}
-
- // If our row is dirty then we're dirty.
- if (page_rac.row.dirty) break :dirty;
-
- // Not dirty!
- continue;
}
- // Set that at least one row was dirty.
- any_dirty = true;
-
- // Clear our row dirty, we'll clear our page dirty later.
- // We can't clear it now because we have more rows to go through.
- page_rac.row.dirty = false;
-
- // Promote our arena. State is copied by value so we need to
- // restore it on all exit paths so we don't leak memory.
- var arena = row_arenas[y].promote(alloc);
- defer row_arenas[y] = arena.state;
-
- // Reset our cells if we're rebuilding this row.
- if (row_cells[y].len > 0) {
- _ = arena.reset(.retain_capacity);
- row_cells[y].clearRetainingCapacity();
- row_sels[y] = null;
- row_highlights[y] = .empty;
- }
- row_dirties[y] = true;
-
- // Get all our cells in the page.
- const page_cells: []const page.Cell = p.getCells(page_rac.row);
- assert(page_cells.len == self.cols);
-
- // Copy our raw row data
- row_rows[y] = page_rac.row.*;
-
- // Note: our cells MultiArrayList uses our general allocator.
- // We do this on purpose because as rows become dirty, we do
- // not want to reallocate space for cells (which are large). This
- // was a source of huge slowdown.
- //
- // Our per-row arena is only used for temporary allocations
- // pertaining to cells directly (e.g. graphemes, hyperlinks).
- const cells: *std.MultiArrayList(Cell) = &row_cells[y];
- try cells.resize(alloc, self.cols);
-
- // We always copy our raw cell data. In the case we have no
- // managed memory, we can skip setting any other fields.
- //
- // This is an important optimization. For plain-text screens
- // this ends up being something around 300% faster based on
- // the `screen-clone` benchmark.
- const cells_slice = cells.slice();
- fastmem.copy(
- page.Cell,
- cells_slice.items(.raw),
- page_cells,
- );
- if (!page_rac.row.managedMemory()) continue;
-
- const arena_alloc = arena.allocator();
- const cells_grapheme = cells_slice.items(.grapheme);
- const cells_style = cells_slice.items(.style);
- for (page_cells, 0..) |*page_cell, x| {
- // Append assuming its a single-codepoint, styled cell
- // (most common by far).
- if (page_cell.style_id > 0) cells_style[x] = p.styles.get(
- p.memory,
- page_cell.style_id,
- ).*;
-
- // Switch on our content tag to handle less likely cases.
- switch (page_cell.content_tag) {
- .codepoint => {
+ if (!redraw and !page_dirty) {
+ // Only dirty rows (usually none) need a rebuild. Scan the
+ // dirty flags a group at a time; the dirty bit is directly
+ // testable on the packed row representation.
+ var i: usize = 0;
+ while (take - i >= RowDirtyMask.group_len) : (i += RowDirtyMask.group_len) {
+ if (RowDirtyMask.match(page_rows, i)) {
@branchHint(.likely);
- // Primary codepoint goes into `raw` field.
- },
-
- // If we have a multi-codepoint grapheme, look it up and
- // set our content type.
- .codepoint_grapheme => {
- @branchHint(.unlikely);
- cells_grapheme[x] = try arena_alloc.dupe(
- u21,
- p.lookupGrapheme(page_cell) orelse &.{},
- );
- },
-
- .bg_color_rgb => {
- @branchHint(.unlikely);
- cells_style[x] = .{ .bg_color = .{ .rgb = .{
- .r = page_cell.content.color_rgb.r,
- .g = page_cell.content.color_rgb.g,
- .b = page_cell.content.color_rgb.b,
- } } };
- },
+ continue;
+ }
- .bg_color_palette => {
- @branchHint(.unlikely);
- cells_style[x] = .{ .bg_color = .{
- .palette = page_cell.content.color_palette,
- } };
- },
+ for (page_rows[i..][0..RowDirtyMask.group_len], i..) |*page_row, j| {
+ if (!page_row.dirty) continue;
+ page_row.dirty = false;
+ any_dirty = true;
+ try builder.row(p, page_row, y + j);
+ }
+ }
+ while (i < take) : (i += 1) {
+ const page_row = &page_rows[i];
+ if (!page_row.dirty) continue;
+ page_row.dirty = false;
+ any_dirty = true;
+ try builder.row(p, page_row, y + i);
+ }
+ } else {
+ // Rebuild every row in the chunk.
+ any_dirty = true;
+ for (page_rows, 0..) |*page_row, i| {
+ page_row.dirty = false;
+ try builder.row(p, page_row, y + i);
}
}
+
+ y += take;
}
assert(y == self.rows);
@@ -640,14 +717,40 @@ pub const RenderState = struct {
self.dirty = .partial;
}
- // Finalize our final dirty page
- if (last_dirty_page) |last_p| last_p.dirty = false;
-
// Clear our dirty flags
t.flags.dirty = .{};
s.dirty = .{};
}
+ /// Complete a prior `beginUpdate` call by performing any deferred
+ /// work. At the time of writing, this denormalizes the pending
+ /// style runs into the per-cell style data.
+ ///
+ /// This only reads and writes memory owned by the render state, so
+ /// it is safe to call while the terminal is being modified (no
+ /// terminal lock is required).
+ pub fn endUpdate(self: *RenderState) void {
+ // Common case: no styled rows were rebuilt.
+ if (self.pending_styles.items.len == 0) return;
+
+ const row_data = self.row_data.slice();
+ const row_cells = row_data.items(.cells);
+ for (self.pending_styles.items) |run| {
+ // Defensive: the row data may have changed shape if the
+ // caller violated ordering (e.g. an error path skipped an
+ // endUpdate between updates). Any update that changes
+ // dimensions clears the pending list (redraw), so this
+ // should never actually trigger, but the cost is trivial.
+ if (run.y >= row_cells.len) continue;
+ const styles = row_cells[run.y].slice().items(.style);
+ const end = @min(run.end, styles.len);
+ const start = @min(run.start, end);
+
+ @memset(styles[start..end], run.style);
+ }
+ self.pending_styles.clearRetainingCapacity();
+ }
+
/// Update the highlights in the render state from the given flattened
/// highlights. Because this uses flattened highlights, it does not require
/// reading from the terminal state so it should be done outside of
@@ -678,22 +781,28 @@ pub const RenderState = struct {
const row_arenas = row_data.items(.arena);
const row_dirties = row_data.items(.dirty);
const row_pins = row_data.items(.pin);
+ const row_serials = row_data.items(.serial);
const row_highlights_slice = row_data.items(.highlights);
for (
row_arenas,
row_pins,
+ row_serials,
row_highlights_slice,
row_dirties,
- ) |*row_arena, row_pin, *row_highlights, *dirty| {
+ ) |*row_arena, row_pin, row_serial, *row_highlights, *dirty| {
for (hls) |hl| {
const chunks_slice = hl.chunks.slice();
const nodes = chunks_slice.items(.node);
+ const serials = chunks_slice.items(.serial);
const starts = chunks_slice.items(.start);
const ends = chunks_slice.items(.end);
for (0.., nodes) |i, node| {
- // If this node doesn't match or we're not within
- // the row range, skip it.
+ // If this node generation doesn't match or we're not
+ // within the row range, skip it. Both serials are copied
+ // values, so this never dereferences a node outside the
+ // terminal lock.
if (node != row_pin.node or
+ serials[i] != row_serial or
row_pin.y < starts[i] or
row_pin.y >= ends[i]) continue;
@@ -825,7 +934,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,
@@ -854,7 +963,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;
@@ -879,6 +988,231 @@ pub const RenderState = struct {
}
};
+/// The number of rows/cells we scan as a single group when looking for
+/// dirty rows or special cells. Rows and cells are small packed structs
+/// so a group is scanned with a handful of vector operations.
+const scan_group_len = 8;
+
+/// Group scan helper for the row dirty flag. A row that matches has
+/// its dirty flag unset.
+const RowDirtyMask = page.Mask(
+ page.Row,
+ &.{"dirty"},
+ scan_group_len,
+);
+
+/// Group scan helper for the cell fields that require managed memory
+/// handling. A cell that matches is a plain (possibly zero) codepoint
+/// with a default style, requiring no work beyond the raw copy. See
+/// RowBuilder.row.
+const CellSpecialMask = page.Mask(page.Cell, &.{
+ "content_tag",
+ "style_id",
+}, scan_group_len);
+
+/// Internal helper for RenderState.update that rebuilds a single row of
+/// the render state from the current page contents.
+const RowBuilder = struct {
+ alloc: Allocator,
+ cols: usize,
+ arenas: []ArenaAllocator.State,
+ raws: []page.Row,
+ cells: []std.MultiArrayList(RenderState.Cell),
+ sels: []?[2]size.CellCountInt,
+ highlights: []std.ArrayList(RenderState.Highlight),
+ dirties: []bool,
+ pending_styles: *std.ArrayList(RenderState.StyleRun),
+
+ fn row(
+ b: *const RowBuilder,
+ p: *page.Page,
+ page_row: *const page.Row,
+ vy: usize,
+ ) Allocator.Error!void {
+ // Promote our arena. State is copied by value so we need to
+ // restore it on all exit paths so we don't leak memory.
+ var arena = b.arenas[vy].promote(b.alloc);
+ defer b.arenas[vy] = arena.state;
+
+ // Reset our per-row state if we're rebuilding this row. A
+ // non-zero cell length means the row was populated by a prior
+ // update.
+ if (b.cells[vy].len > 0) {
+ _ = arena.reset(.retain_capacity);
+ b.sels[vy] = null;
+ b.highlights[vy] = .empty;
+ }
+ b.dirties[vy] = true;
+
+ // Get all our cells in the page.
+ const page_cells: []const page.Cell = page_row.cells.ptr(p.memory)[0..b.cols];
+
+ // Copy our raw row data
+ b.raws[vy] = page_row.*;
+
+ // Note: our cells MultiArrayList uses our general allocator.
+ // We do this on purpose because as rows become dirty, we do
+ // not want to reallocate space for cells (which are large). This
+ // was a source of huge slowdown.
+ //
+ // Our per-row arena is only used for temporary allocations
+ // pertaining to cells directly (e.g. graphemes, hyperlinks).
+ const cells: *std.MultiArrayList(RenderState.Cell) = &b.cells[vy];
+ if (cells.len != b.cols) try cells.resize(b.alloc, b.cols);
+
+ // We always copy our raw cell data. In the case we have no
+ // managed memory, we can skip setting any other fields.
+ //
+ // This is an important optimization. For plain-text screens
+ // this ends up being something around 300% faster based on
+ // the `screen-clone` benchmark.
+ const cells_slice = cells.slice();
+ fastmem.copy(
+ page.Cell,
+ cells_slice.items(.raw),
+ page_cells,
+ );
+ if (!page_row.managedMemory()) return;
+
+ const arena_alloc = arena.allocator();
+ const cells_grapheme = cells_slice.items(.grapheme);
+ const n = page_cells.len;
+ var x: usize = 0;
+ scan: while (x < n) {
+ // Skip runs of plain cells a group at a time. Cells that
+ // need managed handling are often rare even within rows that
+ // have managed memory (e.g. a row is "styled" if a single
+ // cell has a style) so groups are skipped with a few vector
+ // operations.
+ while (n - x >= CellSpecialMask.group_len) {
+ if (!CellSpecialMask.match(page_cells, x)) break;
+ x += CellSpecialMask.group_len;
+ }
+
+ // Scalar scan to the next special cell.
+ while (true) {
+ if (x >= n) break :scan;
+ if (!CellSpecialMask.matchScalar(page_cells[x])) break;
+ x += 1;
+ }
+
+ const page_cell = &page_cells[x];
+
+ switch (page_cell.content_tag) {
+ // Single-codepoint styled cells are by far the most
+ // common special cells, and they usually come in long
+ // runs sharing one style ID (e.g. a fully styled row
+ // usually uses a single style). Find the run and record
+ // it: this does one style lookup per run and defers the
+ // (large) per-cell fill to endUpdate, outside of any
+ // terminal locks.
+ .codepoint => {
+ @branchHint(.likely);
+ const sid = page_cell.style_id;
+ assert(sid > 0); // special + codepoint implies styled
+ const style_val: Style = p.styles.get(p.memory, sid).*;
+
+ // A cell continues the run if its masked special
+ // bits are exactly the style ID of the run (in
+ // particular the content tag must be a plain
+ // codepoint). We can check groups of cells at a
+ // time this way.
+ const pattern = CellSpecialMask.pattern(page_cell.*);
+ const start = x;
+ x += 1;
+ while (n - x >= CellSpecialMask.group_len) {
+ if (!CellSpecialMask.eql(
+ page_cells,
+ x,
+ pattern,
+ )) break;
+ x += CellSpecialMask.group_len;
+ }
+ while (x < n) : (x += 1) {
+ if (!CellSpecialMask.eqlScalar(
+ page_cells[x],
+ pattern,
+ )) break;
+ }
+
+ try b.pending_styles.append(b.alloc, .{
+ .y = @intCast(vy),
+ .start = @intCast(start),
+ .end = @intCast(x),
+ .style = style_val,
+ });
+ },
+
+ // If we have a multi-codepoint grapheme, look it up and
+ // set our content type. Note grapheme cells may also
+ // be styled. The style must be recorded as a run (rather
+ // than written directly) so that it is ordered correctly
+ // relative to possibly-stale runs from prior updates.
+ .codepoint_grapheme => {
+ if (page_cell.style_id > 0) {
+ try b.pending_styles.append(b.alloc, .{
+ .y = @intCast(vy),
+ .start = @intCast(x),
+ .end = @intCast(x + 1),
+ .style = p.styles.get(
+ p.memory,
+ page_cell.style_id,
+ ).*,
+ });
+ }
+ cells_grapheme[x] = try arena_alloc.dupe(
+ u21,
+ p.lookupGrapheme(page_cell) orelse &.{},
+ );
+ x += 1;
+ },
+
+ // Background-color-only cells. The style is derived
+ // entirely from the cell contents. Consecutive cleared
+ // cells with the same background are bit-identical, so
+ // we run-detect on full equality (e.g. a line cleared
+ // with a background color pending is one run).
+ .bg_color_rgb, .bg_color_palette => {
+ const style_val: Style = switch (page_cell.content_tag) {
+ .bg_color_rgb => .{ .bg_color = .{ .rgb = .{
+ .r = page_cell.content.color_rgb.r,
+ .g = page_cell.content.color_rgb.g,
+ .b = page_cell.content.color_rgb.b,
+ } } },
+ .bg_color_palette => .{ .bg_color = .{
+ .palette = page_cell.content.color_palette,
+ } },
+ else => unreachable,
+ };
+
+ const first_bits = CellSpecialMask.bits(page_cell.*);
+ const start = x;
+ x += 1;
+ while (n - x >= CellSpecialMask.group_len) {
+ if (!CellSpecialMask.eqlExact(
+ page_cells,
+ x,
+ first_bits,
+ )) break;
+ x += CellSpecialMask.group_len;
+ }
+ while (x < n) : (x += 1) {
+ if (CellSpecialMask.bits(page_cells[x]) != first_bits)
+ break;
+ }
+
+ try b.pending_styles.append(b.alloc, .{
+ .y = @intCast(vy),
+ .start = @intCast(start),
+ .end = @intCast(x),
+ .style = style_val,
+ });
+ },
+ }
+ }
+ }
+};
+
test "styled" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -979,6 +1313,331 @@ test "styled text" {
try testing.expectEqual(0, cells[0].get(3).raw.codepoint());
}
+/// Verifies that an incrementally updated render state has identical
+/// contents to a from-scratch rebuild. This is the load-bearing check
+/// for our dirty tracking: if any terminal operation changes row
+/// contents without setting a dirty signal that `update` honors
+/// (terminal dirty, screen dirty, page dirty, row dirty, viewport pin,
+/// or dimensions), the incremental state will contain stale rows and
+/// this comparison will fail.
+fn testCompareStates(
+ incremental: *const RenderState,
+ fresh: *const RenderState,
+) !void {
+ const testing = std.testing;
+
+ // Row metadata that is allowed to be stale in an incremental
+ // update. Dirty tracking only guarantees that VISUAL changes are
+ // flagged (see page.Row.dirty); these fields are non-visual
+ // metadata that the terminal may change without dirtying the row
+ // (e.g. Screen.cursorResetWrap clears wrap flags without a dirty
+ // mark). This staleness predates the chunked update
+ // implementation; it is present in the row-iterator implementation
+ // as well.
+ const StaleOkMask = page.Mask(page.Row, &.{
+ "wrap",
+ "wrap_continuation",
+ "semantic_prompt",
+ "dirty",
+ }, 1);
+
+ try testing.expectEqual(fresh.rows, incremental.rows);
+ try testing.expectEqual(fresh.cols, incremental.cols);
+ try testing.expectEqual(fresh.cursor.active, incremental.cursor.active);
+ try testing.expectEqual(fresh.cursor.viewport, incremental.cursor.viewport);
+ try testing.expectEqual(
+ @as(page.Cell.Backing, @bitCast(fresh.cursor.cell)),
+ @as(page.Cell.Backing, @bitCast(incremental.cursor.cell)),
+ );
+
+ const inc_data = incremental.row_data.slice();
+ const new_data = fresh.row_data.slice();
+ try testing.expectEqual(new_data.len, inc_data.len);
+ for (0..new_data.len) |y| {
+ errdefer std.log.warn("mismatch on row y={}", .{y});
+
+ // Pins must match exactly.
+ const inc_pin = inc_data.items(.pin)[y];
+ const new_pin = new_data.items(.pin)[y];
+ try testing.expectEqual(new_pin.node, inc_pin.node);
+ try testing.expectEqual(new_pin.y, inc_pin.y);
+
+ // Raw row data must match, except for non-visual metadata
+ // fields which may legitimately be stale (see StaleOkMask).
+ const inc_row = inc_data.items(.raw)[y];
+ const new_row = new_data.items(.raw)[y];
+ try testing.expectEqual(
+ StaleOkMask.strip(new_row),
+ StaleOkMask.strip(inc_row),
+ );
+
+ const inc_cells = inc_data.items(.cells)[y].slice();
+ const new_cells = new_data.items(.cells)[y].slice();
+ try testing.expectEqual(new_cells.len, inc_cells.len);
+ const managed = new_row.managedMemory();
+ for (0..new_cells.len) |x| {
+ errdefer std.log.warn("mismatch on cell x={}", .{x});
+
+ // Raw cell contents must match.
+ const inc_cell = inc_cells.items(.raw)[x];
+ const new_cell = new_cells.items(.raw)[x];
+ try testing.expectEqual(
+ @as(page.Cell.Backing, @bitCast(new_cell)),
+ @as(page.Cell.Backing, @bitCast(inc_cell)),
+ );
+
+ // The style is only defined if the cell is styled or is
+ // a bg-color cell within a row that has managed memory.
+ if (new_cell.style_id != 0 or
+ (managed and switch (new_cell.content_tag) {
+ .bg_color_rgb, .bg_color_palette => true,
+ else => false,
+ }))
+ {
+ try testing.expect(std.meta.eql(
+ new_cells.items(.style)[x],
+ inc_cells.items(.style)[x],
+ ));
+ }
+
+ // Graphemes are only defined for grapheme cells.
+ if (new_cell.content_tag == .codepoint_grapheme) {
+ try testing.expectEqualSlices(
+ u21,
+ new_cells.items(.grapheme)[x],
+ inc_cells.items(.grapheme)[x],
+ );
+ }
+ }
+ }
+}
+
+test "incremental updates match full rebuild" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // Deterministic so failures are reproducible.
+ var prng = std.Random.DefaultPrng.init(0xB0BA_CAFE);
+ const rand = prng.random();
+
+ var t = try Terminal.init(alloc, .{
+ .cols = 20,
+ .rows = 8,
+ .max_scrollback = 500,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ var inc: RenderState = .empty;
+ defer inc.deinit(alloc);
+
+ var buf: [64]u8 = undefined;
+ for (0..300) |_| {
+ // Perform a random batch of operations between updates.
+ for (0..rand.intRangeAtMost(usize, 1, 6)) |_| {
+ switch (rand.intRangeAtMost(u8, 0, 18)) {
+ // Plain text (possibly wrapping and scrolling).
+ 0, 1, 2 => for (0..rand.intRangeAtMost(usize, 1, 30)) |_| {
+ s.nextSlice(&.{rand.intRangeAtMost(u8, 'A', 'Z')});
+ },
+
+ // Newlines to build scrollback and trigger pruning.
+ 3, 4 => for (0..rand.intRangeAtMost(usize, 1, 10)) |_| {
+ s.nextSlice("x\r\n");
+ },
+
+ // Cursor movement.
+ 5 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{};{}H", .{
+ rand.intRangeAtMost(u16, 1, 8),
+ rand.intRangeAtMost(u16, 1, 20),
+ })),
+
+ // Styling: bold, truecolor bg, palette fg, reset.
+ 6 => s.nextSlice(switch (rand.intRangeAtMost(u8, 0, 3)) {
+ 0 => "\x1b[1m",
+ 1 => "\x1b[48;2;30;60;90m",
+ 2 => "\x1b[38;5;120m",
+ else => "\x1b[0m",
+ }),
+
+ // Erase ops (EL, ED variants including scrollback).
+ 7 => s.nextSlice(switch (rand.intRangeAtMost(u8, 0, 4)) {
+ 0 => "\x1b[K",
+ 1 => "\x1b[1K",
+ 2 => "\x1b[J",
+ 3 => "\x1b[2J",
+ else => "\x1b[3J",
+ }),
+
+ // Insert/delete lines (row rotations within regions).
+ 8 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}L", .{
+ rand.intRangeAtMost(u16, 1, 4),
+ })),
+ 9 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}M", .{
+ rand.intRangeAtMost(u16, 1, 4),
+ })),
+
+ // Scroll up/down (page-dirty row rotations).
+ 10 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}S", .{
+ rand.intRangeAtMost(u16, 1, 4),
+ })),
+ 11 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}T", .{
+ rand.intRangeAtMost(u16, 1, 4),
+ })),
+
+ // Set/reset scroll regions to exercise bounded scrolls.
+ 12 => {
+ const top = rand.intRangeAtMost(u16, 1, 4);
+ const bot = rand.intRangeAtMost(u16, top + 1, 8);
+ s.nextSlice(try std.fmt.bufPrint(
+ &buf,
+ "\x1b[{};{}r",
+ .{ top, bot },
+ ));
+ },
+
+ // Insert/delete/erase chars within a row.
+ 13 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}@", .{
+ rand.intRangeAtMost(u16, 1, 5),
+ })),
+ 14 => s.nextSlice(try std.fmt.bufPrint(&buf, "\x1b[{}P", .{
+ rand.intRangeAtMost(u16, 1, 5),
+ })),
+
+ // Reverse index (scroll down at top).
+ 15 => s.nextSlice("\x1bM"),
+
+ // Wide chars and multi-codepoint graphemes.
+ 16 => s.nextSlice("字👨‍👩‍👧"),
+
+ // Alternate screen switching (screen key redraw path).
+ 17 => s.nextSlice(if (rand.boolean())
+ "\x1b[?1049h"
+ else
+ "\x1b[?1049l"),
+
+ // DECALN full-screen fill.
+ 18 => s.nextSlice("\x1b#8"),
+
+ else => unreachable,
+ }
+ }
+
+ // Occasionally scroll the viewport into scrollback and back.
+ switch (rand.intRangeAtMost(u8, 0, 9)) {
+ 0 => t.scrollViewport(.{ .delta = -3 }),
+ 1 => t.scrollViewport(.{ .delta = 2 }),
+ 2 => t.scrollViewport(.bottom),
+ 3 => t.scrollViewport(.top),
+ else => {},
+ }
+
+ // Update our incremental state first: it must consume the dirty
+ // state. The fresh state always fully rebuilds (its dimensions
+ // start empty so it always redraws) and so does not depend on
+ // any dirty flags.
+ try inc.update(alloc, &t);
+
+ var fresh: RenderState = .empty;
+ defer fresh.deinit(alloc);
+ try fresh.update(alloc, &t);
+
+ try testCompareStates(&inc, &fresh);
+ }
+}
+
+test "begin and end update" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var t = try Terminal.init(alloc, .{
+ .cols = 10,
+ .rows = 3,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("\x1b[1mAB"); // Bold
+ s.nextSlice("\x1b[0;3mC"); // Italic
+
+ var state: RenderState = .empty;
+ defer state.deinit(alloc);
+ try state.beginUpdate(alloc, &t);
+
+ // We should have pending style runs on row 0: one for the bold
+ // run and one for the italic run.
+ {
+ const runs = state.pending_styles.items;
+ try testing.expectEqual(2, runs.len);
+ try testing.expectEqual(0, runs[0].y);
+ try testing.expectEqual(0, runs[0].start);
+ try testing.expectEqual(2, runs[0].end);
+ try testing.expect(runs[0].style.flags.bold);
+ try testing.expectEqual(0, runs[1].y);
+ try testing.expectEqual(2, runs[1].start);
+ try testing.expectEqual(3, runs[1].end);
+ try testing.expect(runs[1].style.flags.italic);
+ }
+
+ // End our update. This should denormalize the runs into cells
+ // and clear the pending runs.
+ state.endUpdate();
+ {
+ try testing.expectEqual(0, state.pending_styles.items.len);
+
+ const row_data = state.row_data.slice();
+ const cells = row_data.items(.cells);
+ try testing.expect(cells[0].get(0).style.flags.bold);
+ try testing.expect(cells[0].get(1).style.flags.bold);
+ try testing.expect(cells[0].get(2).style.flags.italic);
+ }
+}
+
+test "bg color cells" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var t = try Terminal.init(alloc, .{
+ .cols = 10,
+ .rows = 3,
+ });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ // Write a styled cell (so the row has managed memory) then erase
+ // the rest of the line with a palette background pending. The
+ // erase produces bg_color content cells rather than styled cells.
+ s.nextSlice("\x1b[1mA\x1b[48;5;1m\x1b[K");
+
+ var state: RenderState = .empty;
+ defer state.deinit(alloc);
+ try state.update(alloc, &t);
+
+ const row_data = state.row_data.slice();
+ const cells = row_data.items(.cells);
+ {
+ const cell = cells[0].get(0);
+ try testing.expectEqual('A', cell.raw.codepoint());
+ try testing.expect(cell.style.flags.bold);
+ }
+ for (1..10) |x| {
+ const cell = cells[0].get(x);
+ try testing.expectEqual(
+ page.Cell.ContentTag.bg_color_palette,
+ cell.raw.content_tag,
+ );
+ try testing.expectEqual(
+ Style.Color{ .palette = 1 },
+ cell.style.bg_color,
+ );
+ }
+}
+
test "grapheme" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -1343,7 +2002,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");
@@ -1405,6 +2064,73 @@ test "linkCells with invalid viewport point" {
}
}
+test "flattened highlights require matching page serial" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var t = try Terminal.init(alloc, .{
+ .cols = 10,
+ .rows = 3,
+ });
+ defer t.deinit(alloc);
+
+ // Capture the live generation while terminal-owned state is in scope so
+ // we can also verify beginUpdate copies it into the render row.
+ const live_pin = t.screens.active.pages.getTopLeft(.viewport);
+ const live_serial = live_pin.node.serial;
+
+ var state: RenderState = .empty;
+ defer state.deinit(alloc);
+ try state.update(alloc, &t);
+
+ const pin: PageList.Pin = pin: {
+ const row_data = state.row_data.slice();
+ @memset(row_data.items(.dirty), false);
+ state.dirty = .false;
+ break :pin row_data.items(.pin)[0];
+ };
+ const row_serial = state.row_data.items(.serial)[0];
+ try testing.expectEqual(live_pin.node, pin.node);
+ try testing.expectEqual(live_serial, row_serial);
+
+ // Use the exact node pointer and row captured by the render state, but a
+ // different generation. A reused node address must not make this stale
+ // flattened highlight match.
+ var hl: highlight.Flattened = .{
+ .chunks = .empty,
+ .top_x = 2,
+ .bot_x = 4,
+ };
+ defer hl.deinit(alloc);
+ try hl.chunks.append(alloc, .{
+ .node = pin.node,
+ .serial = live_serial ^ 1,
+ .start = pin.y,
+ .end = pin.y + 1,
+ });
+
+ try state.updateHighlightsFlattened(alloc, 42, &.{hl});
+ {
+ const row_data = state.row_data.slice();
+ try testing.expectEqual(0, row_data.items(.highlights)[0].items.len);
+ try testing.expect(!row_data.items(.dirty)[0]);
+ try testing.expectEqual(.false, state.dirty);
+ }
+
+ // The same chunk is accepted once its copied serial also matches.
+ hl.chunks.items(.serial)[0] = live_serial;
+ try state.updateHighlightsFlattened(alloc, 42, &.{hl});
+ {
+ const row_data = state.row_data.slice();
+ const row_highlights = row_data.items(.highlights)[0].items;
+ try testing.expectEqual(1, row_highlights.len);
+ try testing.expectEqual(42, row_highlights[0].tag);
+ try testing.expectEqual([2]size.CellCountInt{ 2, 4 }, row_highlights[0].range);
+ try testing.expect(row_data.items(.dirty)[0]);
+ try testing.expectEqual(.partial, state.dirty);
+ }
+}
+
test "dirty row resets highlights" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/vendor/libghostty-vt/src/terminal/search/Thread.zig b/vendor/libghostty-vt/src/terminal/search/Thread.zig
index fa09af5f..d7f0d562 100644
--- a/vendor/libghostty-vt/src/terminal/search/Thread.zig
+++ b/vendor/libghostty-vt/src/terminal/search/Thread.zig
@@ -121,7 +121,11 @@ pub fn deinit(self: *Thread) void {
// Nothing can possibly access the mailbox anymore, destroy it.
self.mailbox.destroy(self.alloc);
- if (self.search) |*s| s.deinit();
+ if (self.search) |*s| {
+ self.opts.mutex.lock();
+ defer self.opts.mutex.unlock();
+ s.deinit(self.opts.terminal);
+ }
}
/// The main entrypoint for the thread.
@@ -252,11 +256,15 @@ fn drainMailbox(self: *Thread) !void {
fn select(self: *Thread, sel: ScreenSearch.Select) !void {
const s = if (self.search) |*s| s else return;
- const screen_search = s.screens.getPtr(s.last_screen.key) orelse return;
self.opts.mutex.lock();
defer self.opts.mutex.unlock();
+ // A screen can be removed or replaced between refresh ticks. Reconcile
+ // while holding the terminal lock before touching any ScreenSearch pins.
+ s.feed(self.alloc, self.opts.terminal);
+ const screen_search = s.screens.getPtr(s.last_screen.key) orelse return;
+
// Make the selection. Ignore the result because we don't
// care if the selection didn't change.
_ = try screen_search.select(sel);
@@ -308,7 +316,11 @@ fn changeNeedle(self: *Thread, needle: []const u8) !void {
// If our search is unchanged, do nothing.
if (std.ascii.eqlIgnoreCase(s.viewport.needle(), needle)) return;
- s.deinit();
+ {
+ self.opts.mutex.lock();
+ defer self.opts.mutex.unlock();
+ s.deinit(self.opts.terminal);
+ }
self.search = null;
// When the search changes then we need to emit that it stopped.
@@ -497,6 +509,11 @@ const Search = struct {
/// The searchers for all the screens.
screens: std.EnumMap(ScreenSet.Key, ScreenSearch),
+ /// ScreenSet generations captured when each searcher was initialized.
+ /// Allocators may reuse a destroyed Screen address, so pointer equality
+ /// alone cannot distinguish replacement screens from stale handles.
+ screen_generations: std.EnumMap(ScreenSet.Key, usize),
+
/// All state related to screen switches, collected so that when
/// we switch screens it makes everything related stale, too.
last_screen: ScreenState,
@@ -537,16 +554,35 @@ const Search = struct {
return .{
.viewport = vp,
.screens = .init(.{}),
+ .screen_generations = .init(.{}),
.last_screen = .{ .key = .primary },
.last_complete = false,
.stale_viewport_matches = true,
};
}
- pub fn deinit(self: *Search) void {
+ pub fn deinit(self: *Search, t: *Terminal) void {
self.viewport.deinit();
var it = self.screens.iterator();
- while (it.next()) |entry| entry.value.deinit();
+ while (it.next()) |entry| {
+ if (self.screenIsValid(&t.screens, entry.key, entry.value)) {
+ entry.value.deinit();
+ } else {
+ entry.value.deinitScreenInvalid();
+ }
+ }
+ }
+
+ fn screenIsValid(
+ self: *const Search,
+ screens: *const ScreenSet,
+ key: ScreenSet.Key,
+ search: *const ScreenSearch,
+ ) bool {
+ const generation = self.screen_generations.get(key) orelse return false;
+ if (generation != screens.generation(key)) return false;
+ const actual = screens.get(key) orelse return false;
+ return actual == search.screen;
}
/// Returns true if all searches on all screens are complete.
@@ -629,18 +665,16 @@ const Search = struct {
// Remove screens we have that no longer exist or changed.
var it = self.screens.iterator();
while (it.next()) |entry| {
- const remove: bool = remove: {
- // If the screen doesn't exist at all, remove it.
- const actual = t.screens.all.get(entry.key) orelse break :remove true;
-
- // If the screen pointer changed, remove it, the screen
- // was totally reinitialized.
- break :remove actual != entry.value.screen;
- };
+ const remove = !self.screenIsValid(
+ &t.screens,
+ entry.key,
+ entry.value,
+ );
if (remove) {
- entry.value.deinit();
+ entry.value.deinitScreenInvalid();
_ = self.screens.remove(entry.key);
+ _ = self.screen_generations.remove(entry.key);
}
}
}
@@ -649,7 +683,7 @@ const Search = struct {
var it = t.screens.all.iterator();
while (it.next()) |entry| {
if (self.screens.contains(entry.key)) continue;
- self.screens.put(entry.key, ScreenSearch.init(
+ const screen_search = ScreenSearch.init(
alloc,
entry.value.*,
self.viewport.needle(),
@@ -664,7 +698,12 @@ const Search = struct {
);
continue;
},
- });
+ };
+ self.screens.put(entry.key, screen_search);
+ self.screen_generations.put(
+ entry.key,
+ t.screens.generation(entry.key),
+ );
}
}
@@ -903,3 +942,32 @@ test {
} }, t.screens.active.pages.pointFromPin(.screen, sel.end).?);
}
}
+
+test "select after active screen removal" {
+ const alloc = testing.allocator;
+ var mutex: std.Thread.Mutex = .{};
+ var t: Terminal = try .init(alloc, .{ .cols = 20, .rows = 2 });
+ defer t.deinit(alloc);
+
+ _ = try t.switchScreen(.alternate);
+
+ var search: Search = try .init(alloc, "needle");
+ search.feed(alloc, &t);
+ try testing.expectEqual(ScreenSet.Key.alternate, search.last_screen.key);
+ try testing.expect(search.screens.contains(.alternate));
+
+ var thread: Thread = undefined;
+ thread.search = search;
+ thread.opts = .{
+ .mutex = &mutex,
+ .terminal = &t,
+ };
+ defer if (thread.search) |*active| active.deinit(&t);
+
+ _ = try t.switchScreen(.primary);
+ t.screens.remove(alloc, .alternate);
+
+ try thread.select(.next);
+ try testing.expectEqual(ScreenSet.Key.primary, thread.search.?.last_screen.key);
+ try testing.expect(!thread.search.?.screens.contains(.alternate));
+}
diff --git a/vendor/libghostty-vt/src/terminal/search/active.zig b/vendor/libghostty-vt/src/terminal/search/active.zig
index 692a10e1..b99b2357 100644
--- a/vendor/libghostty-vt/src/terminal/search/active.zig
+++ b/vendor/libghostty-vt/src/terminal/search/active.zig
@@ -56,6 +56,10 @@ pub const ActiveSearch = struct {
// Clear our previous sliding window
self.window.clearAndRetainCapacity();
+ // An empty needle represents an inactive search and has no overlap
+ // or history to load.
+ if (self.window.needle.len == 0) return null;
+
// First up, add enough pages to cover the active area.
var rem: usize = list.rows;
var node_ = list.pages.last;
@@ -68,26 +72,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/vendor/libghostty-vt/src/terminal/search/pagelist.zig b/vendor/libghostty-vt/src/terminal/search/pagelist.zig
index f76ad4e4..98a24c18 100644
--- a/vendor/libghostty-vt/src/terminal/search/pagelist.zig
+++ b/vendor/libghostty-vt/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);
@@ -84,6 +85,13 @@ pub const PageListSearch = struct {
self.list.untrackPin(self.pin);
}
+ /// Release owned search buffers after the PageList itself has already
+ /// been destroyed. Its pin belonged to the PageList pool and was freed
+ /// with that list, so it must not be untracked here.
+ pub fn deinitListInvalid(self: *PageListSearch) void {
+ self.window.deinit();
+ }
+
/// Return the next match in the loaded page nodes. If this returns
/// null then the PageList search needs to be fed the next node(s).
/// Call, `feed` to do this.
@@ -121,10 +129,13 @@ 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;
+ self.pin.y = node.rows() - 1;
+ self.pin.x = node.cols() - 1;
+ self.pin.garbage = false;
if (rem == 0) break;
}
@@ -190,7 +201,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 +245,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 +283,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 +324,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 +370,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 +448,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 +485,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);
}
@@ -439,3 +520,31 @@ test "feed with pruned page" {
// Feed should still do nothing
try testing.expect(!try search.feed());
}
+
+test "feed keeps its tracked pin within a shorter page" {
+ const alloc = testing.allocator;
+ var pages: PageList = try .init(alloc, 10, 2, null);
+ defer pages.deinit();
+
+ const first = pages.pages.first.?;
+ while (first.rows() < first.capacity().rows) _ = try pages.grow();
+
+ const second = (try pages.grow()).?;
+ while (second.rows() < second.capacity().rows) _ = try pages.grow();
+
+ try pages.split(.{ .node = first, .y = 1, .x = 0 });
+ const shorter = first.next.?;
+ try testing.expect(shorter.rows() < second.rows());
+
+ var search: PageListSearch = try .init(
+ alloc,
+ "x",
+ &pages,
+ second,
+ );
+ defer search.deinit();
+
+ try testing.expect(try search.feed());
+ try testing.expectEqual(shorter, search.pin.node);
+ try testing.expect(pages.pinIsValid(search.pin.*));
+}
diff --git a/vendor/libghostty-vt/src/terminal/search/screen.zig b/vendor/libghostty-vt/src/terminal/search/screen.zig
index 3e7e316f..e0cd9911 100644
--- a/vendor/libghostty-vt/src/terminal/search/screen.zig
+++ b/vendor/libghostty-vt/src/terminal/search/screen.zig
@@ -157,10 +157,29 @@ pub const ScreenSearch = struct {
}
pub fn deinit(self: *ScreenSearch) void {
+ self.deinitInternal(true);
+ }
+
+ /// Release owned search state after the underlying Screen has already
+ /// been destroyed. Tracked pins belonged to that Screen's PageList pool
+ /// and were freed with it, so only independently owned memory is freed.
+ pub fn deinitScreenInvalid(self: *ScreenSearch) void {
+ self.deinitInternal(false);
+ }
+
+ fn deinitInternal(self: *ScreenSearch, screen_valid: bool) void {
const alloc = self.allocator();
self.active.deinit();
- if (self.history) |*h| h.deinit(self.screen);
- if (self.selected) |*m| m.deinit(self.screen);
+ if (self.history) |*h| {
+ if (screen_valid) {
+ h.deinit(self.screen);
+ } else {
+ h.searcher.deinitListInvalid();
+ }
+ }
+ if (screen_valid) {
+ if (self.selected) |*m| m.deinit(self.screen);
+ }
for (self.active_results.items) |*hl| hl.deinit(alloc);
self.active_results.deinit(alloc);
for (self.history_results.items) |*hl| hl.deinit(alloc);
@@ -182,6 +201,33 @@ pub const ScreenSearch = struct {
return self.active_results.items.len + self.history_results.items.len;
}
+ /// Reinitialize cached search state after a screen resize. Reflow can
+ /// replace every PageList node, so even flattened highlights that weren't
+ /// selected contain invalid node pointers once either dimension changes.
+ ///
+ /// Returns true when the reset occurred. The replacement search has
+ /// already loaded its active area through `init` in that case.
+ fn resetIfDimensionsChanged(self: *ScreenSearch) Allocator.Error!bool {
+ if (self.screen.pages.rows == self.rows and
+ self.screen.pages.cols == self.cols)
+ {
+ return false;
+ }
+
+ const new: ScreenSearch = try .init(
+ self.allocator(),
+ self.screen,
+ self.needle(),
+ );
+
+ self.deinit();
+ self.* = new;
+
+ assert(self.screen.pages.rows == self.rows);
+ assert(self.screen.pages.cols == self.cols);
+ return true;
+ }
+
/// Returns all matches as an owned slice (caller must free).
/// The matches are ordered from most recent to oldest (e.g. bottom
/// of the screen to top of the screen).
@@ -260,28 +306,9 @@ pub const ScreenSearch = struct {
/// Feed on a complete screen search will perform some cleanup of
/// potentially stale history results (pruned) and reclaim some memory.
pub fn feed(self: *ScreenSearch) Allocator.Error!void {
- // If the screen resizes, we have to reset our entire search. That
- // isn't ideal but we don't have a better way right now to handle
- // reflowing the search results beyond putting a tracked pin for
- // every single result.
- if (self.screen.pages.rows != self.rows or
- self.screen.pages.cols != self.cols)
- {
- // Reinit
- const new: ScreenSearch = try .init(
- self.allocator(),
- self.screen,
- self.needle(),
- );
-
- // Deinit/reinit
- self.deinit();
- self.* = new;
-
- // New result should have matching dimensions
- assert(self.screen.pages.rows == self.rows);
- assert(self.screen.pages.cols == self.cols);
- }
+ // Resize/reflow invalidates every flattened result, not just search
+ // state that needs another history feed.
+ _ = try self.resetIfDimensionsChanged();
const history: *PageListSearch = if (self.history) |*h| &h.searcher else {
// No history to feed, search is complete.
@@ -317,22 +344,88 @@ pub const ScreenSearch = struct {
}
}
+ fn historyResultIsValid(
+ self: *const ScreenSearch,
+ hl: *const FlattenedHighlight,
+ ) bool {
+ const chunks = hl.chunks.slice();
+ const nodes = chunks.items(.node);
+ const serials = chunks.items(.serial);
+ for (nodes, serials) |node, serial| {
+ if (!self.screen.pages.nodeIsValid(node, serial)) return false;
+ }
+
+ return true;
+ }
+
+ /// Clear a selected history match if its flattened page coordinates are
+ /// stale. reloadActive only inspects the selected history result, so this
+ /// avoids validating every cached result on each active-area refresh.
+ fn validateSelectedHistory(self: *ScreenSearch) void {
+ const m = if (self.selected) |*m| m else return;
+ const active_len = self.active_results.items.len;
+ if (m.idx < active_len) return;
+
+ const history_idx = m.idx - active_len;
+ if (history_idx < self.history_results.items.len and
+ self.historyResultIsValid(&self.history_results.items[history_idx]))
+ {
+ return;
+ }
+
+ m.deinit(self.screen);
+ self.selected = null;
+ }
+
fn pruneHistory(self: *ScreenSearch) void {
// Go through our history results in order (newest to oldest) to find
- // any result that contains an invalid serial. Prune up to that
- // point.
- for (0..self.history_results.items.len) |i| {
+ // any result that contains an invalid serial.
+ var i: usize = 0;
+ while (i < self.history_results.items.len) {
const hl = &self.history_results.items[i];
- const serials = hl.chunks.items(.serial);
- const lowest = serials[0];
- if (lowest < self.screen.pages.page_serial_min) {
- // Everything from here forward we assume is invalid because
- // our history results only get older.
+ const chunks = hl.chunks.slice();
+ const serials = chunks.items(.serial);
+ const first_serial = serials[0];
+ if (first_serial < self.screen.pages.page_serial_epoch) {
+ // Only a whole-list reset advances the epoch. Results are
+ // newest to oldest, so this result and the entire remaining
+ // suffix predate that reset. Drop them without scanning the
+ // live page list for every captured chunk.
const alloc = self.allocator();
+ if (self.selected) |*m| {
+ const first_pruned = self.active_results.items.len + i;
+ if (m.idx >= first_pruned) {
+ m.deinit(self.screen);
+ self.selected = null;
+ }
+ }
for (self.history_results.items[i..]) |*prune_hl| prune_hl.deinit(alloc);
self.history_results.shrinkAndFree(alloc, i);
return;
}
+
+ // Ordinary pruning, layout changes, and replacements do not
+ // advance the epoch. Validate their pointer-plus-generation pairs
+ // against the live list.
+ if (self.historyResultIsValid(hl)) {
+ i += 1;
+ continue;
+ }
+
+ // Only this result is known to be invalid. Older results may live
+ // on unrelated pages and remain usable, so remove it individually.
+ const result_idx = self.active_results.items.len + i;
+ if (self.selected) |*m| {
+ if (m.idx == result_idx) {
+ m.deinit(self.screen);
+ self.selected = null;
+ } else if (m.idx > result_idx) {
+ m.idx -= 1;
+ }
+ }
+
+ var removed = self.history_results.orderedRemove(i);
+ removed.deinit(self.allocator());
}
}
@@ -392,6 +485,14 @@ pub const ScreenSearch = struct {
///
/// The caller must hold the necessary locks to access the screen state.
pub fn reloadActive(self: *ScreenSearch) Allocator.Error!void {
+ // This check must precede all inspection of cached highlights and
+ // searchers: column reflow may have freed every node they reference.
+ if (try self.resetIfDimensionsChanged()) return;
+
+ // reloadActive only inspects the selected history result, so validate
+ // that result without scanning every cached match on this hot path.
+ self.validateSelectedHistory();
+
const tw = reloadActive_tw;
// If our selection pin became garbage it means we scrolled off
@@ -696,9 +797,14 @@ pub const ScreenSearch = struct {
/// access to the underlying screen, since we utilize tracked pins to
/// ensure our selection sticks with contents changing.
pub fn select(self: *ScreenSearch, to: Select) Allocator.Error!bool {
- // All selection requires valid pins so we prune history and
- // reload our active area immediately. This ensures all search
- // results point to valid nodes.
+ // A resize can replace any page node, including nodes retained by
+ // flattened history results. Reset before reloadActive or tracking a
+ // selected result so no pre-resize pointer is ever dereferenced.
+ _ = try self.resetIfDimensionsChanged();
+
+ // Reload validates the selected history result before inspecting its
+ // cached coordinates. Prune the remaining results afterward so every
+ // candidate is valid before selection tracks it.
try self.reloadActive();
self.pruneHistory();
@@ -798,7 +904,7 @@ pub const ScreenSearch = struct {
const active_len = self.active_results.items.len;
const history_len = self.history_results.items.len;
- const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len - 1 + history_len;
+ const next_idx = if (prev.idx != 0) prev.idx - 1 else active_len + history_len - 1;
const hl: FlattenedHighlight = if (next_idx < active_len)
self.active_results.items[active_len - 1 - next_idx]
@@ -1090,6 +1196,40 @@ test "select next" {
}
}
+test "select after resize resets stale flattened results" {
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{
+ .cols = 10,
+ .rows = 3,
+ .max_scrollback = std.math.maxInt(usize),
+ });
+ defer t.deinit(alloc);
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+ stream.nextSlice("Fizz\r\nBuzz");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expect(try search.select(.next));
+ try testing.expect(search.selectedMatch() != null);
+ try testing.expectEqual(@as(size.CellCountInt, 10), search.cols);
+
+ // Column reflow replaces the nodes referenced by the cached flattened
+ // active result. select() calls reloadActive(), so dimension invalidation
+ // must run before either path dereferences those old nodes.
+ try t.screens.active.pages.resize(.{ .cols = 5 });
+ try testing.expectEqual(@as(size.CellCountInt, 10), search.cols);
+
+ try testing.expect(try search.select(.next));
+ try testing.expectEqual(@as(size.CellCountInt, 5), search.cols);
+
+ const selected = search.selectedMatch().?.untracked();
+ try testing.expectEqual(@as(size.CellCountInt, 5), selected.start.node.cols());
+ try testing.expectEqual(@as(size.CellCountInt, 5), selected.end.node.cols());
+}
+
test "select in active changes contents completely" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 5 });
@@ -1380,6 +1520,276 @@ test "select prev with history" {
}
}
+test "select prev wraps when all matches are in history" {
+ // Regression test: when every match is in scrollback (the active area
+ // has none, so active_len == 0), selecting prev from index 0 must wrap
+ // to the last result without underflowing `active_len - 1`.
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{
+ .cols = 10,
+ .rows = 2,
+ .max_scrollback = std.math.maxInt(usize),
+ });
+ defer t.deinit(alloc);
+ const list: *PageList = &t.screens.active.pages;
+
+ var s = t.vtStream();
+ defer s.deinit();
+
+ // Put the only match in scrollback, then scroll the active area to all
+ // blank lines so it contains no match (active_len == 0, history_len == 1).
+ s.nextSlice("Fizz\r\n");
+ while (list.totalPages() < 3) s.nextSlice("\r\n");
+ for (0..list.rows) |_| s.nextSlice("\r\n");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(0, search.active_results.items.len);
+
+ // Select the first match (idx 0), then wrap backwards. This must not
+ // panic and must keep a valid selection.
+ _ = try search.select(.next);
+ _ = try search.select(.prev);
+ try testing.expect(search.selectedMatch() != null);
+}
+
+test "select after all matches disappear drops the selection" {
+ // The wrap arithmetic in selectPrev (active_len + history_len - 1) would
+ // underflow if a selection were ever live while both result lists are
+ // empty. This guards the invariant that makes that unreachable: when a
+ // reload/prune empties the results, the selection is dropped, so the next
+ // select() hits the "no matches" guard instead of the wrap arithmetic.
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 2 });
+ defer t.deinit(alloc);
+
+ var s = t.vtStream();
+ defer s.deinit();
+ s.nextSlice("Fizz");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "Fizz");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(1, search.active_results.items.len);
+
+ // Take a selection, then overwrite the only match so a reload finds none
+ // (active and history both empty).
+ _ = try search.select(.next);
+ try testing.expect(search.selectedMatch() != null);
+ s.nextSlice("\x1b[1;1H ");
+
+ // Must not underflow; the selection is dropped and nothing is selected.
+ _ = try search.select(.prev);
+ try testing.expect(search.selectedMatch() == null);
+ try testing.expectEqual(0, search.active_results.items.len);
+ try testing.expectEqual(0, search.history_results.items.len);
+}
+
+test "select after partial history erase drops a pruned selection" {
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{
+ .cols = 10,
+ .rows = 2,
+ .max_scrollback = std.math.maxInt(usize),
+ });
+ defer t.deinit(alloc);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ stream.nextSlice("error\r\n");
+ const first = list.pages.first.?;
+ while (list.totalPages() < 3) stream.nextSlice("\r\n");
+ const first_rows = first.rows();
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(0, search.active_results.items.len);
+ try testing.expectEqual(1, search.history_results.items.len);
+ try testing.expect(try search.select(.next));
+
+ list.eraseHistory(.{ .history = .{ .y = first_rows - 1 } });
+ try testing.expect(!search.selected.?.highlight.start.garbage);
+
+ try testing.expect(!try search.select(.next));
+ try testing.expect(search.selected == null);
+}
+
+test "select after history compaction ignores replaced results" {
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{
+ .cols = 10,
+ .rows = 2,
+ .max_scrollback = std.math.maxInt(usize),
+ });
+ defer t.deinit(alloc);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ stream.nextSlice("error\r\n");
+ const first = list.pages.first.?;
+ while (list.totalPages() < 3) stream.nextSlice("\r\n");
+ try list.split(.{
+ .node = first,
+ .y = first.rows() / 2,
+ .x = 0,
+ });
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ const replacement = (try list.compact(first)).?;
+ try testing.expect(replacement != first);
+
+ try testing.expect(!try search.select(.next));
+ try testing.expect(search.selected == null);
+}
+
+test "select after partial history page erase ignores shifted results" {
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{
+ .cols = 10,
+ .rows = 2,
+ .max_scrollback = std.math.maxInt(usize),
+ });
+ defer t.deinit(alloc);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ const first = list.pages.first.?;
+ while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n");
+ stream.nextSlice("error");
+ for (0..list.rows + 1) |_| stream.nextSlice("\r\n");
+ try testing.expect(first != list.pages.last.?);
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(0, search.active_results.items.len);
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ const old_rows = first.rows();
+ list.eraseHistory(.{ .history = .{ .y = 0 } });
+ try testing.expectEqual(old_rows - 1, first.rows());
+
+ try testing.expect(!try search.select(.next));
+ try testing.expect(search.selected == null);
+}
+
+test "reload defers pruning unselected history results" {
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{
+ .cols = 10,
+ .rows = 2,
+ .max_scrollback = std.math.maxInt(usize),
+ });
+ defer t.deinit(alloc);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ const first = list.pages.first.?;
+ while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n");
+ stream.nextSlice("error");
+ for (0..list.rows + 1) |_| stream.nextSlice("\r\n");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ list.eraseHistory(.{ .history = .{ .y = 0 } });
+
+ // Routine active refreshes don't inspect unselected history results.
+ try search.reloadActive();
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ // Selection prunes every candidate before attempting to track it.
+ try testing.expect(!try search.select(.next));
+ try testing.expectEqual(0, search.history_results.items.len);
+}
+
+test "reload after partial history page erase drops shifted selection first" {
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{
+ .cols = 10,
+ .rows = 2,
+ .max_scrollback = std.math.maxInt(usize),
+ });
+ defer t.deinit(alloc);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ const first = list.pages.first.?;
+ while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n");
+ stream.nextSlice("error");
+ for (0..list.rows + 1) |_| stream.nextSlice("\r\n");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expect(try search.select(.next));
+ try testing.expect(search.selected != null);
+
+ list.eraseHistory(.{ .history = .{ .y = 0 } });
+
+ // Advance the active boundary into another node and add matches so
+ // reloadActive updates the history result offsets. It must discard the
+ // stale selected result before comparing any of its captured coordinates.
+ const active_node = list.getTopLeft(.active).node;
+ while (list.getTopLeft(.active).node == active_node) {
+ stream.nextSlice("error\r\n");
+ }
+
+ try search.reloadActive();
+ try testing.expect(search.selected == null);
+}
+
+test "select after history page split ignores moved results" {
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{
+ .cols = 10,
+ .rows = 2,
+ .max_scrollback = std.math.maxInt(usize),
+ });
+ defer t.deinit(alloc);
+ const list: *PageList = &t.screens.active.pages;
+
+ var stream = t.vtStream();
+ defer stream.deinit();
+
+ const first = list.pages.first.?;
+ while (first.rows() < first.capacity().rows) stream.nextSlice("\r\n");
+ stream.nextSlice("error");
+ for (0..list.rows + 1) |_| stream.nextSlice("\r\n");
+
+ var search: ScreenSearch = try .init(alloc, t.screens.active, "error");
+ defer search.deinit();
+ try search.searchAll();
+ try testing.expectEqual(1, search.history_results.items.len);
+
+ try list.split(.{
+ .node = first,
+ .y = first.rows() / 2,
+ .x = 0,
+ });
+
+ try testing.expect(!try search.select(.next));
+ try testing.expect(search.selected == null);
+}
+
test "screen search no scrollback has no history" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{
diff --git a/vendor/libghostty-vt/src/terminal/search/sliding_window.zig b/vendor/libghostty-vt/src/terminal/search/sliding_window.zig
index 93a606fd..2f858ec8 100644
--- a/vendor/libghostty-vt/src/terminal/search/sliding_window.zig
+++ b/vendor/libghostty-vt/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,
@@ -164,6 +176,11 @@ pub const SlidingWindow = struct {
/// or `append()`. If the caller wants to retain the flattened highlight
/// then they should clone it.
pub fn next(self: *SlidingWindow) ?FlattenedHighlight {
+ // An empty needle represents an inactive search. Searching for it
+ // would otherwise produce a zero-length match, which highlight()
+ // cannot represent because its end offset is inclusive.
+ if (self.needle.len == 0) return null;
+
const slices = slices: {
// If we have less data then the needle then we can't possibly match
const data_len = self.data.len();
@@ -309,6 +326,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 +398,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 +433,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 +443,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,
@@ -465,12 +489,14 @@ pub const SlidingWindow = struct {
.reverse => {
const slice = self.chunk_buf.slice();
const nodes = slice.items(.node);
+ const serials = slice.items(.serial);
const starts = slice.items(.start);
const ends = slice.items(.end);
if (self.chunk_buf.len > 1) {
// Reverse all our chunks. This should be pretty obvious why.
std.mem.reverse(*PageList.List.Node, nodes);
+ std.mem.reverse(u64, serials);
std.mem.reverse(size.CellCountInt, starts);
std.mem.reverse(size.CellCountInt, ends);
@@ -491,7 +517,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 +552,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 +608,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 +627,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 +643,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 +669,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!
@@ -642,6 +711,25 @@ test "SlidingWindow empty on init" {
try testing.expectEqual(0, w.meta.len());
}
+test "SlidingWindow empty needle has no matches" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ var w: SlidingWindow = try .init(alloc, .forward, "");
+ defer w.deinit();
+
+ var s = try Screen.init(alloc, .{
+ .cols = 80,
+ .rows = 24,
+ .max_scrollback = 0,
+ });
+ defer s.deinit();
+ try s.testWriteString("hello");
+
+ _ = try w.append(s.pages.pages.first.?);
+ try testing.expectEqual(null, w.next());
+}
+
test "SlidingWindow single append" {
const testing = std.testing;
const alloc = testing.allocator;
@@ -813,7 +901,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 +956,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 +1011,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 +1056,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 +1089,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 +1119,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 +1151,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 +1252,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 +1378,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 +1433,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");
@@ -1361,6 +1449,15 @@ test "SlidingWindow two pages match across boundary reversed" {
// Search should find a match
{
const h = w.next().?;
+ const chunks = h.chunks.slice();
+ const nodes = chunks.items(.node);
+ const serials = chunks.items(.serial);
+ try testing.expectEqual(2, chunks.len);
+ try testing.expectEqual(node, nodes[0]);
+ try testing.expectEqual(node.serial, serials[0]);
+ try testing.expectEqual(node.next.?, nodes[1]);
+ try testing.expectEqual(node.next.?.serial, serials[1]);
+
const sel = h.untracked();
try testing.expectEqual(point.Point{ .active = .{
.x = 76,
@@ -1391,7 +1488,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 +1520,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 +1622,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 +1762,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/vendor/libghostty-vt/src/terminal/search/viewport.zig b/vendor/libghostty-vt/src/terminal/search/viewport.zig
index 35dd9331..09d202be 100644
--- a/vendor/libghostty-vt/src/terminal/search/viewport.zig
+++ b/vendor/libghostty-vt/src/terminal/search/viewport.zig
@@ -24,7 +24,7 @@ const Terminal = @import("../Terminal.zig");
/// lives in the same page.
pub const ViewportSearch = struct {
window: SlidingWindow,
- fingerprint: ?Fingerprint,
+ fingerprint: Fingerprint = .{},
/// If this is null, then active dirty tracking is disabled and if the
/// viewport overlaps the active area we always re-search. If this is
@@ -43,21 +43,19 @@ pub const ViewportSearch = struct {
errdefer window.deinit();
return .{
.window = window,
- .fingerprint = null,
.active_dirty = null,
};
}
pub fn deinit(self: *ViewportSearch) void {
- if (self.fingerprint) |*fp| fp.deinit(self.window.alloc);
+ self.fingerprint.deinit(self.window.alloc);
self.window.deinit();
}
/// Reset our fingerprint and results so that the next update will
/// always re-search.
pub fn reset(self: *ViewportSearch) void {
- if (self.fingerprint) |*fp| fp.deinit(self.window.alloc);
- self.fingerprint = null;
+ self.fingerprint.clearAndRetainCapacity();
self.window.clearAndRetainCapacity();
}
@@ -80,48 +78,37 @@ pub const ViewportSearch = struct {
self: *ViewportSearch,
list: *PageList,
) Allocator.Error!bool {
- // See if our viewport changed
- var fingerprint: Fingerprint = try .init(self.window.alloc, list);
- if (self.fingerprint) |*old| {
- if (old.eql(fingerprint)) match: {
- // Determine if we need to check if we overlap the active
- // area. If we have dirty tracking on we also set it to
- // false here.
- const check_active: bool = active: {
- const dirty = self.active_dirty orelse break :active true;
- if (!dirty) break :active false;
- self.active_dirty = false;
- break :active true;
- };
-
- if (check_active) {
- // If our fingerprint contains the active area, then we always
- // re-search since the active area is mutable.
- const active_tl = list.getTopLeft(.active);
- const active_br = list.getBottomRight(.active).?;
-
- // If our viewport contains the start or end of the active area,
- // we are in the active area. We purposely do this first
- // because our viewport is always larger than the active area.
- for (old.nodes) |node| {
- if (node == active_tl.node) break :match;
- if (node == active_br.node) break :match;
- }
+ // See if our viewport changed. We retain the fingerprint buffer so
+ // unchanged viewports don't allocate and changed viewports usually
+ // reuse the previous capacity.
+ const fingerprint = &self.fingerprint;
+ if (!try fingerprint.update(self.window.alloc, list)) match: {
+ // Determine if we need to check if we overlap the active area.
+ // If we have dirty tracking on we also set it to false here.
+ const check_active: bool = active: {
+ const dirty = self.active_dirty orelse break :active true;
+ if (!dirty) break :active false;
+ self.active_dirty = false;
+ break :active true;
+ };
+
+ if (check_active) {
+ // If our fingerprint contains the active area, then we always
+ // re-search since the active area is mutable.
+ const active_tl = list.getTopLeft(.active);
+ const active_br = list.getBottomRight(.active).?;
+
+ // If our viewport contains the start or end of the active area,
+ // we are in the active area. We purposely do this first because
+ // our viewport is always larger than the active area.
+ for (fingerprint.entries.items) |entry| {
+ if (entry.node == active_tl.node) break :match;
+ if (entry.node == active_br.node) break :match;
}
-
- // No change
- fingerprint.deinit(self.window.alloc);
- return false;
}
- old.deinit(self.window.alloc);
- self.fingerprint = null;
- }
- assert(self.fingerprint == null);
- self.fingerprint = fingerprint;
- errdefer {
- fingerprint.deinit(self.window.alloc);
- self.fingerprint = null;
+ // No change
+ return false;
}
// If our active area was set as dirty, we always unset it here
@@ -134,40 +121,40 @@ pub const ViewportSearch = struct {
// Add enough overlap to cover needle.len - 1 bytes (if it
// exists) so we can cover the overlap. We only do this for the
// soft-wrapped prior pages.
- var node_ = fingerprint.nodes[0].prev;
+ const overlap_len = self.window.needle.len -| 1;
+ const entries = fingerprint.entries.items;
+ var node_ = entries[0].node.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);
- if (added >= self.window.needle.len - 1) break;
+ const appended = try self.window.appendIfWrapped(node) orelse break;
+ added += appended.content_len;
+ if (added >= overlap_len) break;
}
// We can use our fingerprint nodes to initialize our sliding
// window, since we already traversed the viewport once.
- for (fingerprint.nodes) |node| {
- _ = try self.window.append(node);
+ var end_append: SlidingWindow.AppendResult = undefined;
+ for (entries) |entry| {
+ end_append = try self.window.append(entry.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;
+ const end: *PageList.List.Node = entries[entries.len - 1].node;
+ 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);
- if (added >= self.window.needle.len - 1) break;
+ const appended = try self.window.append(node);
+ added += appended.content_len;
+ if (added >= overlap_len) 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;
}
}
@@ -182,36 +169,59 @@ pub const ViewportSearch = struct {
/// Viewport fingerprint so we can detect when the viewport moves.
const Fingerprint = struct {
- /// The nodes that make up the viewport. We need to flatten this
- /// to a single list because we can't safely traverse the cached values
- /// because the page nodes may be invalid. All that is safe is comparing
- /// the actual pointer values.
- nodes: []const *PageList.List.Node,
-
- pub fn init(alloc: Allocator, pages: *PageList) Allocator.Error!Fingerprint {
- var list: std.ArrayList(*PageList.List.Node) = .empty;
- defer list.deinit(alloc);
-
- // Get our viewport area. Bottom right of a viewport can never
- // fail.
- const tl = pages.getTopLeft(.viewport);
- const br = pages.getBottomRight(.viewport).?;
-
- var it = tl.pageIterator(.right_down, br);
- while (it.next()) |chunk| try list.append(alloc, chunk.node);
- return .{ .nodes = try list.toOwnedSlice(alloc) };
- }
+ /// The node addresses and generations that make up the viewport. We
+ /// can't safely dereference cached node pointers because the nodes may
+ /// be invalid, so equality only compares these captured values.
+ entries: std.ArrayListUnmanaged(Entry) = .empty,
+
+ const Entry = struct {
+ node: *PageList.List.Node,
+ serial: u64,
+ };
pub fn deinit(self: *Fingerprint, alloc: Allocator) void {
- alloc.free(self.nodes);
+ self.entries.deinit(alloc);
+ }
+
+ pub fn clearAndRetainCapacity(self: *Fingerprint) void {
+ self.entries.clearRetainingCapacity();
}
- pub fn eql(self: Fingerprint, other: Fingerprint) bool {
- if (self.nodes.len != other.nodes.len) return false;
- for (self.nodes, other.nodes) |a, b| {
- if (a != b) return false;
+ /// Update this fingerprint to match the current viewport.
+ ///
+ /// Returns true if the viewport changed. The viewport can have at
+ /// most one entry per visible row, so we reserve that upper bound
+ /// before mutating the retained buffer. This lets us rebuild in one
+ /// pass while still preserving the old fingerprint if allocation
+ /// fails.
+ pub fn update(self: *Fingerprint, alloc: Allocator, pages: *PageList) Allocator.Error!bool {
+ try self.entries.ensureTotalCapacity(alloc, pages.rows);
+
+ const old_len = self.entries.items.len;
+ var changed = false;
+
+ var i: usize = 0;
+ var it = pages.pageIterator(.right_down, .{ .viewport = .{} }, null);
+ while (it.next()) |chunk| : (i += 1) {
+ const entry: Entry = .{
+ .node = chunk.node,
+ .serial = chunk.node.serial,
+ };
+ if (!changed) {
+ changed = i >= old_len or
+ self.entries.items[i].node != entry.node or
+ self.entries.items[i].serial != entry.serial;
+ }
+
+ if (i < self.entries.items.len) {
+ self.entries.items[i] = entry;
+ } else {
+ self.entries.appendAssumeCapacity(entry);
+ }
}
- return true;
+
+ self.entries.shrinkRetainingCapacity(i);
+ return changed or i != old_len;
}
};
};
@@ -259,6 +269,55 @@ test "simple search" {
try testing.expect(search.next() == null);
}
+test "fingerprint detects node generation changes" {
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(alloc);
+
+ var search: ViewportSearch = try .init(alloc, "needle");
+ defer search.deinit();
+ search.active_dirty = false;
+
+ try testing.expect(try search.update(&t.screens.active.pages));
+ try testing.expect(!try search.update(&t.screens.active.pages));
+
+ const old_entry = search.fingerprint.entries.items[0];
+ const node = t.screens.active.pages.pages.first.?;
+ try testing.expectEqual(node, old_entry.node);
+ const original_serial = node.serial;
+ defer node.serial = original_serial;
+ node.serial += 1;
+
+ // The viewport still contains the same pointer, but its generation has
+ // changed, so the cached window must be rebuilt.
+ try testing.expect(try search.update(&t.screens.active.pages));
+ const new_entry = search.fingerprint.entries.items[0];
+ try testing.expectEqual(old_entry.node, new_entry.node);
+ try testing.expect(old_entry.serial != new_entry.serial);
+}
+
+test "fingerprint only allocates on changes" {
+ const alloc = testing.allocator;
+ var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(alloc);
+
+ var failing = testing.FailingAllocator.init(alloc, .{});
+ const failing_alloc = failing.allocator();
+
+ var search: ViewportSearch = try .init(failing_alloc, "needle");
+ defer search.deinit();
+ search.active_dirty = false;
+
+ try testing.expect(try search.update(&t.screens.active.pages));
+
+ // Fail the next allocation. An unchanged viewport with active dirty
+ // tracking disabled should reuse the existing fingerprint and return
+ // unchanged before rebuilding the search window, so this update should
+ // succeed without reporting a viewport change.
+ failing.fail_index = failing.alloc_index;
+ try testing.expect(!try search.update(&t.screens.active.pages));
+}
+
test "clear screen and search" {
const alloc = testing.allocator;
var t: Terminal = try .init(alloc, .{ .cols = 10, .rows = 10 });
@@ -348,7 +407,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);
diff --git a/vendor/libghostty-vt/src/terminal/stream.zig b/vendor/libghostty-vt/src/terminal/stream.zig
index 9771334f..efb06ecd 100644
--- a/vendor/libghostty-vt/src/terminal/stream.zig
+++ b/vendor/libghostty-vt/src/terminal/stream.zig
@@ -33,6 +33,7 @@ const debug = false;
/// function for handling.
pub const Action = union(Key) {
print: Print,
+ print_slice: PrintSlice,
print_repeat: usize,
bell,
backspace,
@@ -110,6 +111,7 @@ pub const Action = union(Key) {
apc_start,
apc_end,
apc_put: u8,
+ apc_put_slice: ApcPutSlice,
end_hyperlink,
active_status_display: ansi.StatusDisplay,
decaln,
@@ -130,6 +132,7 @@ pub const Action = union(Key) {
lib.target,
&.{
"print",
+ "print_slice",
"print_repeat",
"bell",
"backspace",
@@ -207,6 +210,7 @@ pub const Action = union(Key) {
"apc_start",
"apc_end",
"apc_put",
+ "apc_put_slice",
"end_hyperlink",
"active_status_display",
"decaln",
@@ -252,6 +256,40 @@ pub const Action = union(Key) {
}
};
+ /// A run of printable codepoints. This is emitted instead of
+ /// individual print actions when the stream can decode multiple
+ /// printable codepoints at once, so handlers can process them in
+ /// batch with per-run rather than per-codepoint overhead (see
+ /// Terminal.printSlice). A naive handler can simply loop and
+ /// handle each codepoint like a print action.
+ ///
+ /// The slice is only valid for the duration of the handler call.
+ pub const PrintSlice = struct {
+ cps: []const u32,
+
+ pub const C = extern struct {
+ cps: [*]const u32,
+ len: usize,
+ };
+
+ pub fn cval(self: PrintSlice) PrintSlice.C {
+ return .{ .cps = self.cps.ptr, .len = self.cps.len };
+ }
+ };
+
+ pub const ApcPutSlice = struct {
+ bytes: []const u8,
+
+ pub const C = extern struct {
+ bytes: [*]const u8,
+ len: usize,
+ };
+
+ pub fn cval(self: ApcPutSlice) ApcPutSlice.C {
+ return .{ .bytes = self.bytes.ptr, .len = self.bytes.len };
+ }
+ };
+
pub const InvokeCharset = lib.Struct(lib.target, struct {
bank: charsets.ActiveSlot,
charset: charsets.Slots,
@@ -411,6 +449,11 @@ pub const Action = union(Key) {
/// about in its pursuit of implementing a terminal emulator or other
/// functionality.
///
+/// Note that printable text is delivered via `print_slice` actions
+/// (runs of codepoints) whenever the stream can decode multiple
+/// codepoints at once, and via `print` actions otherwise. Handlers
+/// that care about text must handle both.
+///
/// The Handler type must also have a `deinit` function.
///
/// The "comptime" key is on purpose (vs. a standard Zig tagged union)
@@ -521,12 +564,25 @@ pub fn Stream(comptime H: type) type {
// up to that point are just UTF-8.
while (self.parser.state == .ground and offset < input.len) {
const res = simd.vt.utf8DecodeUntilControlSeq(input[offset..], cp_buf);
- for (cp_buf[0..res.decoded]) |cp| {
+ const cps = cp_buf[0..res.decoded];
+
+ // Hand runs of printable codepoints to the handler as
+ // print_slice actions so it can process them with
+ // per-run rather than per-codepoint overhead.
+ var i: usize = 0;
+ while (i < cps.len) {
+ const cp = cps[i];
if (cp <= 0xF) {
+ @branchHint(.unlikely);
self.execute(@intCast(cp));
- } else {
- self.print(@intCast(cp));
+ i += 1;
+ continue;
}
+
+ var end = i + 1;
+ while (end < cps.len and cps[end] > 0xF) end += 1;
+ self.handler.vt(.print_slice, .{ .cps = cps[i..end] });
+ i = end;
}
// Consume the bytes we just processed.
offset += res.consumed;
@@ -570,12 +626,202 @@ pub fn Stream(comptime H: type) type {
var offset: usize = 0;
while (self.parser.state != .ground) {
if (offset >= input.len) return input.len;
+
+ // Fast path for CSI entry: "ESC [" is by far the most
+ // common escape sequence prefix, so handle the '[' and
+ // the byte that follows it here rather than paying a
+ // nextNonUtf8 call for each.
+ if (self.parser.state == .escape and input[offset] == '[') {
+ self.parser.state = .csi_entry;
+ offset += 1;
+ continue;
+ }
+
+ if (comptime !@hasDecl(T, "vtRaw")) {
+ if (self.parser.state == .csi_entry) {
+ if (self.csiEntryByte(input[offset])) {
+ offset += 1;
+ continue;
+ }
+ }
+
+ // Bulk-consume CSI parameter bytes. This can't be
+ // used for handlers with a vtRaw hook because it
+ // dispatches the CSI directly (see nextNonUtf8).
+ if (self.parser.state == .csi_param) {
+ offset += self.consumeCsiParams(input[offset..]);
+ if (offset >= input.len) return input.len;
+ // If we're still in csi_param then the next byte
+ // isn't a parameter byte; let nextNonUtf8 below
+ // handle it. Otherwise re-check our state.
+ if (self.parser.state != .csi_param) continue;
+ }
+
+ // Bulk-consume APC string bytes into a single slice.
+ // APC payloads (e.g. Kitty graphics) can be megabytes
+ // of base64 data, so per-byte dispatch is far too slow.
+ // This can't be used for handlers with a vtRaw hook
+ // because it dispatches the slice directly.
+ if (self.parser.state == .sos_pm_apc_string) {
+ offset += self.consumeApcString(input[offset..]);
+ if (offset >= input.len) return input.len;
+ // The next byte exits the string state; let
+ // nextNonUtf8 below handle it.
+ }
+ }
+
self.nextNonUtf8(input[offset]);
offset += 1;
}
return offset;
}
+ /// Fast path for a byte in the csi_entry state, the state right
+ /// after "ESC [". Virtually every CSI sequence spends exactly
+ /// one byte in this state, on either a digit, a private marker,
+ /// or a final byte. Returns true if the byte was fully handled;
+ /// false means the caller must process it through the general
+ /// state machine.
+ ///
+ /// Must not be used by handlers with a vtRaw hook because the
+ /// final byte case dispatches the CSI directly.
+ inline fn csiEntryByte(self: *Self, c: u8) bool {
+ comptime assert(!@hasDecl(T, "vtRaw"));
+ assert(self.parser.state == .csi_entry);
+ switch (c) {
+ // First parameter digit.
+ '0'...'9' => {
+ self.parser.state = .csi_param;
+ // param_acc is zero (cleared on escape entry)
+ // so accumulating is just the digit value.
+ self.parser.param_acc = c - '0';
+ self.parser.param_acc_idx = 1;
+ },
+ // An empty first parameter.
+ ';' => {
+ self.parser.state = .csi_param;
+ self.parser.params[0] = 0;
+ self.parser.params_idx = 1;
+ },
+ // Private marker (e.g. '?' in "ESC [ ? 2004 h").
+ 0x3C...0x3F => {
+ self.parser.state = .csi_param;
+ self.parser.collect(c);
+ },
+ // A final byte: a parameterless CSI.
+ 0x40...0x7E => self.csiDispatchFinal(c),
+ // Defer to the state machine for anything else
+ // (C0 controls, intermediates, colon).
+ else => return false,
+ }
+ return true;
+ }
+
+ /// Bulk-consume CSI parameter bytes (digits and separators)
+ /// and, if reached, the final byte (dispatching the CSI).
+ /// Returns the number of bytes consumed. Stops at the first
+ /// byte that isn't handled here, leaving the parser in the
+ /// csi_param state so the caller can process that byte.
+ fn consumeCsiParams(self: *Self, input: []const u8) usize {
+ const p = &self.parser;
+ assert(p.state == .csi_param);
+
+ // Accumulate parser state in locals for the hot loop.
+ var acc = p.param_acc;
+ var acc_idx = p.param_acc_idx;
+ var idx = p.params_idx;
+
+ var offset: usize = 0;
+ while (offset < input.len) {
+ const c = input[offset];
+ switch (c) {
+ // A parameter digit.
+ '0'...'9' => {
+ if (idx < Parser.MAX_PARAMS) {
+ acc *|= 10;
+ acc +|= c - '0';
+ acc_idx |= 1;
+ }
+ offset += 1;
+ },
+
+ // A parameter separator.
+ ':', ';' => {
+ if (idx < Parser.MAX_PARAMS) {
+ p.params[idx] = acc;
+ if (c == ':') p.params_sep.set(idx);
+ idx += 1;
+ acc = 0;
+ acc_idx = 0;
+ }
+ offset += 1;
+ },
+
+ // A final byte: dispatch the CSI.
+ 0x40...0x7E => {
+ p.param_acc = acc;
+ p.param_acc_idx = acc_idx;
+ p.params_idx = idx;
+ self.csiDispatchFinal(c);
+ return offset + 1;
+ },
+
+ // Anything else (C0 controls, intermediates, etc.)
+ // is handled by the caller.
+ else => break,
+ }
+ }
+
+ p.param_acc = acc;
+ p.param_acc_idx = acc_idx;
+ p.params_idx = idx;
+ return offset;
+ }
+
+ /// Bulk-consume APC string bytes and dispatch them as a single
+ /// apc_put_slice action. Returns the number of bytes consumed.
+ /// Stops at the first byte that is not an apc_put byte in the
+ /// parse table, leaving it for the caller to process through
+ /// the state machine. CAN, SUB, ESC, and most C1 bytes exit
+ /// or abort the string state; 0xA0-0xFF are ignored by the
+ /// table (not payload), so they can't be bulk-consumed either.
+ ///
+ /// Must not be used by handlers with a vtRaw hook because it
+ /// dispatches the slice directly.
+ fn consumeApcString(self: *Self, input: []const u8) usize {
+ comptime assert(!@hasDecl(T, "vtRaw"));
+ assert(self.parser.state == .sos_pm_apc_string);
+
+ var end: usize = 0;
+ if (comptime std.simd.suggestVectorLength(u8)) |vector_len| {
+ const ByteVector = @Vector(vector_len, u8);
+ while (end + vector_len <= input.len) {
+ const bytes: ByteVector = input[end..][0..vector_len].*;
+ const invalid = (bytes == @as(ByteVector, @splat(0x18))) |
+ (bytes == @as(ByteVector, @splat(0x1A))) |
+ (bytes == @as(ByteVector, @splat(0x1B))) |
+ (bytes >= @as(ByteVector, @splat(0x80)));
+ if (@reduce(.Or, invalid)) break;
+ end += vector_len;
+ }
+ }
+ while (end < input.len) {
+ switch (input[end]) {
+ // Not apc_put bytes: CAN/SUB/ESC and most C1 exit
+ // or abort the state; 0xA0-0xFF are ignored by it.
+ 0x18, 0x1A, 0x1B, 0x80...0xFF => break,
+ // Everything else is an apc_put byte.
+ else => end += 1,
+ }
+ }
+
+ if (end > 0) self.handler.vt(
+ .apc_put_slice,
+ .{ .bytes = input[0..end] },
+ );
+ return end;
+ }
+
/// Like nextSlice but takes one byte and is necessarily a scalar
/// operation that can't use SIMD. Prefer nextSlice if you can and
/// try to get multiple bytes at once.
@@ -654,6 +900,13 @@ pub fn Stream(comptime H: type) type {
self.parser.state = .csi_entry;
return;
}
+
+ // The fast paths below dispatch actions directly rather than
+ // going through Parser.next, so they'd bypass a handler's
+ // vtRaw hook. Handlers with vtRaw (e.g. the inspector) use
+ // the general path for anything that produces an action.
+ const has_vt_raw = comptime @hasDecl(T, "vtRaw");
+
// Fast path for CSI params.
if (self.parser.state == .csi_param) csi_param: {
// csi_param is the most common parser state
@@ -690,6 +943,10 @@ pub fn Stream(comptime H: type) type {
self.parser.param_acc = 0;
self.parser.param_acc_idx = 0;
},
+ // A final byte: dispatch the CSI directly.
+ 0x40...0x7E => if (comptime !has_vt_raw) {
+ self.csiDispatchFinal(c);
+ } else break :csi_param,
// Explicitly ignored:
0x7F => {},
// Defer to the state machine to
@@ -699,6 +956,13 @@ pub fn Stream(comptime H: type) type {
return;
}
+ // Fast path for CSI entry, the state right after "ESC [".
+ if (comptime !has_vt_raw) {
+ if (self.parser.state == .csi_entry) {
+ if (self.csiEntryByte(c)) return;
+ }
+ }
+
// We explicitly inline this call here for performance reasons.
//
// We do this rather than mark Parser.next as inline because doing
@@ -744,6 +1008,48 @@ pub fn Stream(comptime H: type) type {
}
}
+ /// Finalize and dispatch a CSI directly from parser state for
+ /// the fast paths in nextNonUtf8, without going through
+ /// Parser.next. This must match the behavior of the parser's
+ /// csi_dispatch action.
+ fn csiDispatchFinal(self: *Self, c: u8) void {
+ const p = &self.parser;
+ p.state = .ground;
+
+ // Ignore sequences with too many parameters, matching the
+ // parser's behavior of dropping the dispatch entirely.
+ if (p.params_idx >= Parser.MAX_PARAMS) {
+ @branchHint(.unlikely);
+ return;
+ }
+
+ // Finalize the last parameter if we have one.
+ if (p.param_acc_idx > 0) {
+ p.params[p.params_idx] = p.param_acc;
+ p.params_idx += 1;
+ }
+
+ const action: Parser.Action.CSI = .{
+ .intermediates = p.intermediates[0..p.intermediates_idx],
+ .params = p.params[0..p.params_idx],
+ .params_sep = p.params_sep,
+ .final = c,
+ };
+
+ // We only allow colon or mixed separators for the 'm' command.
+ if (c != 'm' and p.params_sep.count() > 0) {
+ @branchHint(.cold);
+ log.warn(
+ "CSI colon or mixed separators only allowed for 'm' command, got: {f}",
+ .{action},
+ );
+ return;
+ }
+
+ if (comptime debug) log.info("action: {f}", .{Parser.Action{ .csi_dispatch = action }});
+ self.csiDispatch(action);
+ }
+
inline fn print(self: *Self, c: u21) void {
self.handler.vt(.print, .{ .cp = c });
}
@@ -776,7 +1082,7 @@ pub fn Stream(comptime H: type) type {
.SO => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G1, .locking = false }),
.SI => self.handler.vt(.invoke_charset, .{ .bank = .GL, .charset = .G0, .locking = false }),
- else => log.warn("invalid C0 character, ignoring: 0x{x}", .{c}),
+ else => logUnsupportedOnce("invalid C0 character, ignoring: 0x{x}", .{c}, c),
}
}
@@ -1294,7 +1600,11 @@ pub fn Stream(comptime H: type) type {
if (req) |r| {
self.handler.vt(.device_attributes, r);
} else {
- log.warn("invalid device attributes command: {f}", .{input});
+ logUnsupportedOnce(
+ "invalid device attributes command: {f}",
+ .{input},
+ if (input.params.len > 0) input.params[0] else 0,
+ );
return;
}
},
@@ -1380,7 +1690,7 @@ pub fn Stream(comptime H: type) type {
if (modes.modeFromInt(mode_int, ansi_mode)) |mode| {
self.handler.vt(.set_mode, .{ .mode = mode });
} else {
- log.warn("unimplemented mode: {}", .{mode_int});
+ logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int);
}
}
},
@@ -1401,7 +1711,7 @@ pub fn Stream(comptime H: type) type {
if (modes.modeFromInt(mode_int, ansi_mode)) |mode| {
self.handler.vt(.reset_mode, .{ .mode = mode });
} else {
- log.warn("unimplemented mode: {}", .{mode_int});
+ logUnsupportedOnce("unimplemented mode: {}", .{mode_int}, mode_int);
}
}
},
@@ -1470,9 +1780,10 @@ pub fn Stream(comptime H: type) type {
self.handler.vt(.modify_key_format, format);
},
- else => log.warn(
+ else => logUnsupportedOnce(
"unknown CSI m with intermediate: {}",
.{input.intermediates[0]},
+ input.intermediates[0],
),
},
@@ -1804,13 +2115,15 @@ pub fn Stream(comptime H: type) type {
23 => self.handler.vt(.title_pop, index),
else => @compileError("unreachable"),
}
- } else log.warn(
+ } else logUnsupportedOnce(
"ignoring CSI 22/23 t with extra parameters: {f}",
.{input},
+ input.params[0],
),
- else => log.warn(
+ else => logUnsupportedOnce(
"ignoring CSI t with unimplemented parameter: {f}",
.{input},
+ input.params[0],
),
}
} else log.err(
@@ -1985,7 +2298,11 @@ pub fn Stream(comptime H: type) type {
.change_window_icon => |icon| {
@branchHint(.likely);
- log.info("OSC 1 (change icon) received and ignored icon={s}", .{icon});
+ logUnsupportedOnce(
+ "OSC 1 (change icon) received and ignored icon={s}",
+ .{icon},
+ 0,
+ );
},
.clipboard_contents => |clip| {
@@ -2058,6 +2375,7 @@ pub fn Stream(comptime H: type) type {
.conemu_run_process,
.kitty_text_sizing,
.kitty_clipboard_protocol,
+ .kitty_dnd_protocol,
.context_signal,
=> {
log.debug("unimplemented OSC callback: {}", .{cmd});
@@ -2176,7 +2494,11 @@ pub fn Stream(comptime H: type) type {
else => {}, // fall through
}
- log.warn("unimplemented ESC action: {f}", .{action});
+ logUnsupportedOnce(
+ "unimplemented ESC action: {f}",
+ .{action},
+ action.final,
+ );
},
// IND - Index
@@ -2370,12 +2692,72 @@ pub fn Stream(comptime H: type) type {
@branchHint(.likely);
},
- else => log.warn("unimplemented ESC action: {f}", .{action}),
+ else => logUnsupportedOnce(
+ "unimplemented ESC action: {f}",
+ .{action},
+ action.final,
+ ),
}
}
};
}
+/// Logs an unsupported-input message at most once per distinct key
+/// per process.
+///
+/// These messages are emitted in response to input that the terminal
+/// application controls, so a misbehaving (or merely chatty) program
+/// can trigger the same message millions of times, e.g. by toggling
+/// an unimplemented mode on every frame. Each log call has a real
+/// throughput cost (formatting plus a blocking write per message)
+/// while adding no diagnostic value beyond the first occurrence.
+///
+/// The keys seen so far are tracked in a small fixed table (64 bytes)
+/// instantiated per (format, argument type) tuple, i.e. roughly per
+/// call site. Real streams only ever produce a handful of distinct
+/// unsupported values per site, so if the table ever fills, messages
+/// for further new values are suppressed as well: by that point the
+/// log already shows this class of problem and unbounded distinct
+/// values would flood it anyway.
+fn logUnsupportedOnce(
+ comptime format: []const u8,
+ args: anytype,
+ key: u16,
+) void {
+ // u32 slots so every u16 key is representable alongside an empty
+ // sentinel and so 32-bit targets (e.g. wasm32) have native
+ // atomics.
+ const empty = std.math.maxInt(u32);
+ const Static = struct {
+ var seen: [16]u32 = @splat(empty);
+ };
+
+ // The atomics make concurrent streams safe: slots are only ever
+ // claimed, never changed, so the scan can stop at the first empty
+ // slot. The worst case race is a benign duplicate message.
+ for (&Static.seen) |*slot| {
+ const cur = @atomicLoad(u32, slot, .acquire);
+ if (cur == key) return; // already logged
+ if (cur != empty) continue; // other key, keep scanning
+
+ // Empty slot: claim it for this key and log below.
+ const actual = @cmpxchgStrong(
+ u32,
+ slot,
+ empty,
+ key,
+ .acq_rel,
+ .acquire,
+ ) orelse break;
+
+ // Lost the race: suppress if it was to the same key, keep
+ // scanning otherwise.
+ if (actual == key) return;
+ } else return; // table full: suppress new values too
+
+ log.warn(format, args);
+}
+
test Action {
// Forces the C type to be reified when the target is C, ensuring
// all our types are C ABI compatible.
@@ -2414,6 +2796,7 @@ test "simd: print invalid utf-8" {
) void {
switch (action) {
.print => self.c = value.cp,
+ .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]),
else => {},
}
}
@@ -2435,6 +2818,7 @@ test "simd: complete incomplete utf-8" {
) void {
switch (action) {
.print => self.c = value.cp,
+ .print_slice => self.c = @intCast(value.cps[value.cps.len - 1]),
else => {},
}
}
@@ -3447,3 +3831,130 @@ test "stream: tab clear with overflowing param" {
// CSI with a huge numeric param that saturates to 65535, followed by 'g'.
s.nextSlice("\x1b[388888888888888888888888888888888888g\x1b[0m");
}
+
+/// A test handler that accumulates APC bytes regardless of whether they
+/// arrive per-byte (apc_put) or in bulk (apc_put_slice).
+const ApcTestHandler = struct {
+ buf: [256]u8 = undefined,
+ len: usize = 0,
+ slices: usize = 0,
+ puts: usize = 0,
+ started: usize = 0,
+ ended: usize = 0,
+
+ pub fn vt(
+ self: *@This(),
+ comptime action: Action.Tag,
+ value: Action.Value(action),
+ ) void {
+ switch (action) {
+ .apc_start => self.started += 1,
+ .apc_end => self.ended += 1,
+ .apc_put => {
+ self.buf[self.len] = value;
+ self.len += 1;
+ self.puts += 1;
+ },
+ .apc_put_slice => {
+ @memcpy(self.buf[self.len..][0..value.bytes.len], value.bytes);
+ self.len += value.bytes.len;
+ self.slices += 1;
+ },
+ else => {},
+ }
+ }
+};
+
+test "stream: apc bulk slice" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ s.nextSlice("\x1b_Gf=24,s=10,v=20;aGVsbG8=\x1b\\");
+
+ try testing.expectEqual(@as(usize, 1), s.handler.started);
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings(
+ "Gf=24,s=10,v=20;aGVsbG8=",
+ s.handler.buf[0..s.handler.len],
+ );
+
+ // With SIMD enabled the body must arrive as a single slice.
+ if (comptime build_options.simd and !debug) {
+ try testing.expectEqual(@as(usize, 1), s.handler.slices);
+ try testing.expectEqual(@as(usize, 0), s.handler.puts);
+ }
+}
+
+test "stream: apc bulk slice split across inputs" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ s.nextSlice("\x1b_Gf=24,s=10");
+ s.nextSlice(",v=20;aGVs");
+ s.nextSlice("bG8=\x1b\\");
+
+ try testing.expectEqual(@as(usize, 1), s.handler.started);
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings(
+ "Gf=24,s=10,v=20;aGVsbG8=",
+ s.handler.buf[0..s.handler.len],
+ );
+}
+
+test "stream: apc bulk slice keeps C0 bytes as data" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ // BEL does not terminate an APC string; it is payload data.
+ s.nextSlice("\x1b_Gx\x07y\x1b\\");
+
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings("Gx\x07y", s.handler.buf[0..s.handler.len]);
+}
+
+test "stream: apc aborted by CAN" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ // CAN (0x18) aborts the APC string via the anywhere => ground
+ // transition. Exiting the sos_pm_apc_string state emits apc_end,
+ // and the trailing bytes are printed, not treated as APC data.
+ s.nextSlice("\x1b_Gabcdefghijklmnopqrstuvwxyz0123456789\x18def");
+
+ try testing.expectEqual(@as(usize, 1), s.handler.started);
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings(
+ "Gabcdefghijklmnopqrstuvwxyz0123456789",
+ s.handler.buf[0..s.handler.len],
+ );
+}
+
+test "stream: apc scalar path matches" {
+ var s: Stream(ApcTestHandler) = .init(.{});
+ for ("\x1b_Gf=24;aGVsbG8=\x1b\\") |c| s.next(c);
+
+ try testing.expectEqual(@as(usize, 1), s.handler.started);
+ try testing.expectEqual(@as(usize, 1), s.handler.ended);
+ try testing.expectEqualStrings(
+ "Gf=24;aGVsbG8=",
+ s.handler.buf[0..s.handler.len],
+ );
+}
+
+test "stream: apc vector boundaries match scalar path" {
+ const positions = [_]usize{ 15, 16, 17, 31, 32, 33, 63, 64, 65 };
+ const controls = [_]u8{ 0x18, 0x1A, 0x1B, 0x80, 0xFF };
+
+ for (positions) |position| for (controls) |control| {
+ var input: [96]u8 = undefined;
+ input[0..3].* = "\x1b_G".*;
+ @memset(input[3 .. 3 + position], 'a');
+ input[3 + position] = control;
+ input[4 + position] = '\\';
+ const bytes = input[0 .. 5 + position];
+
+ var bulk: Stream(ApcTestHandler) = .init(.{});
+ bulk.nextSlice(bytes);
+ var scalar: Stream(ApcTestHandler) = .init(.{});
+ for (bytes) |byte| scalar.next(byte);
+
+ try testing.expectEqual(scalar.handler.started, bulk.handler.started);
+ try testing.expectEqual(scalar.handler.ended, bulk.handler.ended);
+ try testing.expectEqualStrings(
+ scalar.handler.buf[0..scalar.handler.len],
+ bulk.handler.buf[0..bulk.handler.len],
+ );
+ };
+}
diff --git a/vendor/libghostty-vt/src/terminal/stream_terminal.zig b/vendor/libghostty-vt/src/terminal/stream_terminal.zig
index f68f088b..91cfb21c 100644
--- a/vendor/libghostty-vt/src/terminal/stream_terminal.zig
+++ b/vendor/libghostty-vt/src/terminal/stream_terminal.zig
@@ -2,13 +2,16 @@ const std = @import("std");
const build_options = @import("terminal_options");
const testing = std.testing;
const apc = @import("apc.zig");
+const clipboard = @import("clipboard.zig");
const csi = @import("csi.zig");
const device_attributes = @import("device_attributes.zig");
const device_status = @import("device_status.zig");
const stream = @import("stream.zig");
const Action = stream.Action;
const Screen = @import("Screen.zig");
+const color = @import("color.zig");
const modes = @import("modes.zig");
+const osc = @import("osc.zig");
const osc_color = @import("osc/parsers/color.zig");
const kitty_color = @import("kitty/color.zig");
const size_report = @import("size_report.zig");
@@ -43,6 +46,11 @@ pub const Handler = struct {
/// the kitty graphics protocol.
apc_handler: apc.Handler = .{},
+ /// Default cursor style used by DECSCUSR reset (CSI 0 q).
+ default_cursor: bool = true,
+ default_cursor_style: Screen.CursorStyle = .block,
+ default_cursor_blink: bool = false,
+
pub const Effects = struct {
/// Called when the terminal needs to write data back to the pty,
/// e.g. in response to a DECRQM query. The data is only valid
@@ -78,6 +86,25 @@ pub const Handler = struct {
/// handler.terminal.getTitle().
title_changed: ?*const fn (*Handler) void,
+ /// Called when the terminal pwd changes via escape sequences
+ /// (e.g. OSC 7). The new pwd can be queried via
+ /// handler.terminal.getPwd().
+ pwd_changed: ?*const fn (*Handler) void,
+
+ /// Called when the running program writes to a clipboard. The write
+ /// has a normalized destination and one or more decoded MIME
+ /// representations. All request, MIME, and data memory is borrowed
+ /// and only valid for the duration of the callback.
+ ///
+ /// A write with no contents clears the destination. A content entry
+ /// with empty data is a distinct empty representation.
+ ///
+ /// Clipboard read requests (OSC 52 with a "?" payload) are never
+ /// forwarded: answering one would let any program running in the
+ /// terminal silently read the user's clipboard, and a VT state
+ /// library has no way to mediate that with user consent.
+ clipboard_write: ?*const fn (*Handler, clipboard.Write) clipboard.WriteResult,
+
/// Called in response to an XTVERSION query. Returns the version
/// string to report (e.g. "ghostty 1.2.3"). The returned memory
/// must be valid for the lifetime of the call. The maximum length
@@ -89,11 +116,13 @@ pub const Handler = struct {
/// effects beyond that.
pub const readonly: Effects = .{
.bell = null,
+ .clipboard_write = null,
.color_scheme = null,
.device_attributes = null,
.enquiry = null,
.size = null,
.title_changed = null,
+ .pwd_changed = null,
.write_pty = null,
.xtversion = null,
};
@@ -126,6 +155,7 @@ pub const Handler = struct {
) !void {
switch (action) {
.print => try self.terminal.print(value.cp),
+ .print_slice => try self.terminal.printSlice(value.cps),
.print_repeat => try self.terminal.printRepeat(value),
.backspace => self.terminal.backspace(),
.carriage_return => self.terminal.carriageReturn(),
@@ -152,12 +182,19 @@ pub const Handler = struct {
self.terminal.screens.active.cursor.x + 1,
),
.cursor_style => {
+ self.default_cursor = false;
+
const blink = switch (value) {
- .default, .steady_block, .steady_bar, .steady_underline => false,
+ .default => self.default_cursor_blink,
+ .steady_block, .steady_bar, .steady_underline => false,
.blinking_block, .blinking_bar, .blinking_underline => true,
};
const style: Screen.CursorStyle = switch (value) {
- .default, .blinking_block, .steady_block => .block,
+ .default => style: {
+ self.default_cursor = true;
+ break :style self.default_cursor_style;
+ },
+ .blinking_block, .steady_block => .block,
.blinking_bar, .steady_bar => .bar,
.blinking_underline, .steady_underline => .underline,
};
@@ -228,17 +265,23 @@ pub const Handler = struct {
},
.active_status_display => self.terminal.status_display = value,
.decaln => try self.terminal.decaln(),
- .full_reset => self.terminal.fullReset(),
+ .full_reset => {
+ self.terminal.fullReset();
+ self.default_cursor = true;
+ self.terminal.modes.set(.cursor_blinking, self.default_cursor_blink);
+ self.terminal.screens.active.cursor.cursor_style = self.default_cursor_style;
+ },
.start_hyperlink => try self.terminal.screens.active.startHyperlink(value.uri, value.id),
.end_hyperlink => self.terminal.screens.active.endHyperlink(),
.semantic_prompt => try self.terminal.semanticPrompt(value),
.mouse_shape => self.terminal.mouse_shape = value,
- .color_operation => try self.colorOperation(value.op, &value.requests),
+ .color_operation => try self.colorOperation(&value.requests, value.terminator),
.kitty_color_report => try self.kittyColorOperation(value),
// APC
.apc_start => self.apc_handler.start(),
.apc_put => self.apc_handler.feed(self.terminal.gpa(), value),
+ .apc_put_slice => self.apc_handler.feedSlice(self.terminal.gpa(), value.bytes),
.apc_end => self.apcEnd(),
// Effect-based handlers
@@ -251,7 +294,9 @@ pub const Handler = struct {
.request_mode_unknown => self.requestModeUnknown(value.mode, value.ansi),
.size_report => self.reportSize(value),
.window_title => self.windowTitle(value.title),
+ .report_pwd => self.reportPwd(value.url),
.xtversion => self.reportXtversion(),
+ .clipboard_contents => try self.clipboardContents(value.kind, value.data),
// No supported DCS commands have any terminal-modifying effects,
// but they may in the future. For now we just ignore it.
@@ -261,10 +306,8 @@ pub const Handler = struct {
=> {},
// Have no terminal-modifying effect
- .report_pwd,
.show_desktop_notification,
.progress_report,
- .clipboard_contents,
.title_push,
.title_pop,
=> {},
@@ -281,6 +324,44 @@ pub const Handler = struct {
func(self);
}
+ fn clipboardContents(self: *Handler, kind: u8, data: []const u8) !void {
+ const func = self.effects.clipboard_write orelse return;
+
+ // Read requests are deliberately not forwarded; see the effect docs.
+ if (data.len == 1 and data[0] == '?') return;
+
+ const location: clipboard.Location = switch (kind) {
+ 's' => .selection,
+ 'p' => .primary,
+ else => .standard,
+ };
+
+ // OSC 52 uses an empty payload to clear the selected clipboard.
+ if (data.len == 0) {
+ _ = func(self, .{
+ .location = location,
+ .contents = &.{},
+ });
+ return;
+ }
+
+ const decoder = std.base64.standard.Decoder;
+ const decoded_len = try decoder.calcSizeForSlice(data);
+ const alloc = self.terminal.gpa();
+ const decoded = try alloc.alloc(u8, decoded_len);
+ defer alloc.free(decoded);
+ try decoder.decode(decoded, data);
+
+ const contents = [_]clipboard.Content{.{
+ .mime = "text/plain",
+ .data = decoded,
+ }};
+ _ = func(self, .{
+ .location = location,
+ .contents = &contents,
+ });
+ }
+
fn reportDeviceAttributes(self: *Handler, req: device_attributes.Req) void {
const func = self.effects.device_attributes orelse return;
const attrs = func(self);
@@ -325,10 +406,11 @@ pub const Handler = struct {
.color_scheme => {
const func = self.effects.color_scheme orelse return;
const scheme = func(self) orelse return;
- self.writePty(switch (scheme) {
- .dark => "\x1B[?997;1n",
- .light => "\x1B[?997;2n",
- });
+ var buf: [device_status.max_color_scheme_report_encode_size + 1]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(buf[0..device_status.max_color_scheme_report_encode_size]);
+ device_status.encodeColorSchemeReport(&writer, scheme) catch return;
+ buf[writer.end] = 0;
+ self.writePty(buf[0..writer.end :0]);
},
}
}
@@ -420,6 +502,29 @@ pub const Handler = struct {
func(self);
}
+ fn reportPwd(self: *Handler, url_raw: []const u8) void {
+ // Prevent DoS attacks by limiting url length. Headroom for
+ // Linux PATH_MAX (4096) plus URI scheme/host and percent-encoding.
+ const max_url_len = 4096;
+ const url = if (url_raw.len > max_url_len) url: {
+ log.warn("pwd url length {d} exceeds max length {d}, truncating", .{
+ url_raw.len,
+ max_url_len,
+ });
+ break :url url_raw[0..max_url_len];
+ } else url_raw;
+
+ // We store the raw payload unparsed. Embedders read it via
+ // getPwd() and are responsible for decoding any URI scheme.
+ self.terminal.setPwd(url) catch |err| {
+ log.warn("error setting pwd err={}", .{err});
+ return;
+ };
+
+ const func = self.effects.pwd_changed orelse return;
+ func(self);
+ }
+
fn requestMode(self: *Handler, mode: modes.Mode) void {
const report = self.terminal.modes.getReport(.fromMode(mode));
self.sendModeReport(report);
@@ -550,12 +655,17 @@ pub const Handler = struct {
fn colorOperation(
self: *Handler,
- op: osc_color.Operation,
requests: *const osc_color.List,
+ terminator: osc.Terminator,
) !void {
- _ = op;
if (requests.count() == 0) return;
+ var stack = std.heap.stackFallback(1024, self.terminal.gpa());
+ const alloc = stack.get();
+ var response: std.Io.Writer.Allocating = .init(alloc);
+ defer response.deinit();
+ const writer = &response.writer;
+
var it = requests.constIterator(0);
while (it.next()) |req| {
switch (req.*) {
@@ -613,17 +723,67 @@ pub const Handler = struct {
mask.* = .initEmpty();
},
- .query,
- .reset_special,
- => {},
+ .query => |target| {
+ if (self.effects.write_pty == null) continue;
+ const c = self.terminal.colorForXterm(target) orelse continue;
+ try writeXtermColorReport(writer, target, c, terminator);
+ },
+
+ .reset_special => {},
}
}
+
+ if (response.written().len > 0) {
+ const resp = try response.toOwnedSliceSentinel(0);
+ defer alloc.free(resp);
+ self.writePty(resp);
+ }
+ }
+
+ fn writeXtermColorReport(
+ writer: *std.Io.Writer,
+ target: osc_color.Target,
+ c: color.RGB,
+ terminator: osc.Terminator,
+ ) !void {
+ switch (target) {
+ .palette => |i| {
+ try writer.print("\x1b]4;{d};", .{i});
+ try c.encodeRgb16(writer);
+ try writer.writeAll(terminator.string());
+ },
+ .dynamic => |dynamic| switch (dynamic) {
+ .foreground,
+ .background,
+ .cursor,
+ => {
+ try writer.print("\x1b]{d};", .{@intFromEnum(dynamic)});
+ try c.encodeRgb16(writer);
+ try writer.writeAll(terminator.string());
+ },
+ .pointer_foreground,
+ .pointer_background,
+ .tektronix_foreground,
+ .tektronix_background,
+ .highlight_background,
+ .tektronix_cursor,
+ .highlight_foreground,
+ => {},
+ },
+ .special => {},
+ }
}
fn kittyColorOperation(
self: *Handler,
request: kitty_color.OSC,
) !void {
+ var stack = std.heap.stackFallback(1024, self.terminal.gpa());
+ const alloc = stack.get();
+ var response: std.Io.Writer.Allocating = .init(alloc);
+ defer response.deinit();
+ const writer = &response.writer;
+
for (request.list.items) |item| {
switch (item) {
.set => |v| switch (v.key) {
@@ -650,9 +810,28 @@ pub const Handler = struct {
else => {},
},
},
- .query => {},
+ .query => |key| {
+ if (self.effects.write_pty == null) continue;
+ const c = self.terminal.colorForKitty(key) orelse {
+ if (!key.hasTerminalQueryColor()) continue;
+ if (response.written().len == 0) try writer.writeAll("\x1b]21");
+ try writer.print(";{f}=", .{key});
+ continue;
+ };
+
+ if (response.written().len == 0) try writer.writeAll("\x1b]21");
+ try writer.print(";{f}=", .{key});
+ try c.encodeRgb8(writer);
+ },
}
}
+
+ if (response.written().len > 0) {
+ try writer.writeAll(request.terminator.string());
+ const resp = try response.toOwnedSliceSentinel(0);
+ defer alloc.free(resp);
+ self.writePty(resp);
+ }
}
fn apcEnd(self: *Handler) void {
@@ -679,6 +858,25 @@ pub const Handler = struct {
if (final.len > 3) self.writePty(final[0 .. final.len - 1 :0]);
}
},
+
+ .glyph => |*glyph_req| {
+ const resp = self.terminal.glyphProtocol(alloc, glyph_req);
+ if (resp) |r| resp_block: {
+ // Don't waste time encoding if we can't write responses
+ // anyways.
+ if (self.effects.write_pty == null) break :resp_block;
+
+ // Glyph responses are short and bounded by the protocol
+ // fields we emit, so this matches the Kitty response
+ // buffer size above with ample headroom.
+ var buf: [apc.glyph.Response.max_wire_bytes]u8 = undefined;
+ var writer: std.Io.Writer = .fixed(&buf);
+ r.formatWire(&writer) catch return;
+ writer.writeByte(0) catch return;
+ const final = writer.buffered();
+ self.writePty(final[0 .. final.len - 1 :0]);
+ }
+ },
}
}
};
@@ -900,6 +1098,8 @@ test "full reset" {
s.nextSlice("\x1B[10;20H");
s.nextSlice("\x1B[5;20r"); // Set scroll region
s.nextSlice("\x1B[?7l"); // Disable wraparound
+ s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\");
+ try testing.expect(t.glyph_glossary.contains(0xE0A0));
// Full reset
s.nextSlice("\x1Bc");
@@ -910,6 +1110,35 @@ test "full reset" {
try testing.expectEqual(@as(usize, 0), t.scrolling_region.top);
try testing.expectEqual(@as(usize, 23), t.scrolling_region.bottom);
try testing.expect(t.modes.get(.wraparound));
+ try testing.expect(!t.glyph_glossary.contains(0xE0A0));
+}
+
+test "glyph protocol APC with write_pty callback" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ var last_response: ?[:0]const u8 = null;
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ if (last_response) |old| testing.allocator.free(old);
+ last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
+ }
+ };
+ S.last_response = null;
+ defer if (S.last_response) |old| testing.allocator.free(old);
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ s.nextSlice("\x1B_25a1;s\x1B\\");
+ try testing.expectEqualStrings("\x1B_25a1;s;fmt=glyf\x1B\\", S.last_response.?);
+
+ s.nextSlice("\x1B_25a1;r;cp=e0a0;AAAAAAAAAAAAAA==\x1B\\");
+ try testing.expectEqualStrings("\x1B_25a1;r;cp=e0a0;status=0\x1B\\", S.last_response.?);
+ try testing.expect(t.glyph_glossary.contains(0xE0A0));
}
test "ignores query actions" {
@@ -923,6 +1152,8 @@ test "ignores query actions" {
s.nextSlice("\x1B[c"); // Device attributes
s.nextSlice("\x1B[5n"); // Device status report
s.nextSlice("\x1B[6n"); // Cursor position report
+ s.nextSlice("\x1B]4;0;?\x1B\\"); // OSC color query
+ s.nextSlice("\x1B]21;foreground=?\x1B\\"); // Kitty color query
// Terminal should still be functional
s.nextSlice("Test");
@@ -1039,6 +1270,63 @@ test "OSC 12 set and reset cursor color" {
// After reset, cursor might be null (using default)
}
+test "OSC color query responses" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ var last_response: ?[:0]const u8 = null;
+
+ fn reset() void {
+ if (last_response) |old| testing.allocator.free(old);
+ last_response = null;
+ }
+
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ reset();
+ last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
+ }
+ };
+ S.last_response = null;
+ defer S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ s.nextSlice("\x1b]10;?\x1b\\");
+ try testing.expect(S.last_response == null);
+
+ s.nextSlice("\x1b]11;?\x1b\\");
+ try testing.expect(S.last_response == null);
+
+ s.nextSlice("\x1b]4;2;rgb:12/34/56;2;?\x1b\\");
+ try testing.expectEqualStrings(
+ "\x1b]4;2;rgb:1212/3434/5656\x1b\\",
+ S.last_response.?,
+ );
+
+ s.nextSlice("\x1b]10;rgb:01/02/03\x1b\\");
+ s.nextSlice("\x1b]11;rgb:04/05/06\x1b\\");
+ s.nextSlice("\x1b]12;rgb:07/08/09\x1b\\");
+ s.nextSlice("\x1b]10;?;?;?\x1b\\");
+ try testing.expectEqualStrings(
+ "\x1b]10;rgb:0101/0202/0303\x1b\\" ++
+ "\x1b]11;rgb:0404/0505/0606\x1b\\" ++
+ "\x1b]12;rgb:0707/0808/0909\x1b\\",
+ S.last_response.?,
+ );
+
+ s.nextSlice("\x1b]112\x1b\\");
+ s.nextSlice("\x1b]12;?\x07");
+ try testing.expectEqualStrings(
+ "\x1b]12;rgb:0101/0202/0303\x07",
+ S.last_response.?,
+ );
+}
+
test "kitty color protocol set palette" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
@@ -1133,6 +1421,46 @@ test "kitty color protocol reset foreground" {
try testing.expect(t.colors.foreground.get() == null);
}
+test "kitty color protocol query responses" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ var last_response: ?[:0]const u8 = null;
+
+ fn reset() void {
+ if (last_response) |old| testing.allocator.free(old);
+ last_response = null;
+ }
+
+ fn writePty(_: *Handler, data: [:0]const u8) void {
+ reset();
+ last_response = testing.allocator.dupeZ(u8, data) catch @panic("OOM");
+ }
+ };
+ S.last_response = null;
+ defer S.reset();
+
+ var handler: Handler = .init(&t);
+ handler.effects.write_pty = &S.writePty;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ s.nextSlice("\x1b]21;background=?\x1b\\");
+ try testing.expectEqualStrings(
+ "\x1b]21;background=\x1b\\",
+ S.last_response.?,
+ );
+
+ s.nextSlice("\x1b]21;foreground=rgb:12/34/56;2=rgb:aa/bb/cc\x1b\\");
+ s.nextSlice("\x1b]21;foreground=?;background=?;2=?\x1b\\");
+ try testing.expectEqualStrings(
+ "\x1b]21;foreground=rgb:12/34/56;background=;2=rgb:aa/bb/cc\x1b\\",
+ S.last_response.?,
+ );
+}
+
test "palette dirty flag set on color change" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 10, .rows = 10 });
defer t.deinit(testing.allocator);
@@ -1321,6 +1649,155 @@ test "bell effect callback" {
}
}
+test "clipboard_write effect callback" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ // A null callback (the default readonly effects) silently ignores writes.
+ {
+ var s: Stream = .initAlloc(testing.allocator, .init(&t));
+ defer s.deinit();
+
+ s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
+
+ // Terminal should still be functional after the ignored sequence
+ s.nextSlice("AfterClipboard");
+ const str = try t.plainString(testing.allocator);
+ defer testing.allocator.free(str);
+ try testing.expectEqualStrings("AfterClipboard", str);
+ }
+
+ t.fullReset();
+
+ const S = struct {
+ var count: usize = 0;
+ var result: clipboard.WriteResult = .success;
+ var last_location: clipboard.Location = .standard;
+ var last_contents_len: usize = 0;
+ var last_mime: ?[]u8 = null;
+ var last_data: ?[]u8 = null;
+
+ fn clearCapture() void {
+ if (last_mime) |value| testing.allocator.free(value);
+ if (last_data) |value| testing.allocator.free(value);
+ last_mime = null;
+ last_data = null;
+ last_contents_len = 0;
+ }
+
+ fn clipboardWrite(_: *Handler, write: clipboard.Write) clipboard.WriteResult {
+ clearCapture();
+ count += 1;
+ last_location = write.location;
+ last_contents_len = write.contents.len;
+ if (write.contents.len > 0) {
+ last_mime = testing.allocator.dupe(u8, write.contents[0].mime) catch
+ @panic("failed to capture clipboard MIME type");
+ last_data = testing.allocator.dupe(u8, write.contents[0].data) catch
+ @panic("failed to capture clipboard data");
+ }
+ return result;
+ }
+ };
+ S.count = 0;
+ S.result = .denied;
+ S.clearCapture();
+ defer S.clearCapture();
+
+ var handler: Handler = .init(&t);
+ handler.effects.clipboard_write = &S.clipboardWrite;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ // Selectors are normalized and payloads are decoded before the callback.
+ const cases = [_]struct {
+ sequence: []const u8,
+ location: clipboard.Location,
+ data: []const u8,
+ }{
+ .{ .sequence = "\x1B]52;c;aGVsbG8=\x1B\\", .location = .standard, .data = "hello" },
+ .{ .sequence = "\x1B]52;s;d29ybGQ=\x07", .location = .selection, .data = "world" },
+ .{ .sequence = "\x1B]52;p;cHJpbWFyeQ==\x1B\\", .location = .primary, .data = "primary" },
+ .{ .sequence = "\x1B]52;0;Y3V0\x1B\\", .location = .standard, .data = "cut" },
+ .{ .sequence = "\x1B]52;x;ZmFsbGJhY2s=\x1B\\", .location = .standard, .data = "fallback" },
+ .{ .sequence = "\x1B]52;c;YQBi\x1B\\", .location = .standard, .data = "a\x00b" },
+ };
+
+ for (cases, 1..) |case, expected_count| {
+ s.nextSlice(case.sequence);
+ try testing.expectEqual(expected_count, S.count);
+ try testing.expectEqual(case.location, S.last_location);
+ try testing.expectEqual(@as(usize, 1), S.last_contents_len);
+ try testing.expectEqualStrings("text/plain", S.last_mime.?);
+ try testing.expectEqualSlices(u8, case.data, S.last_data.?);
+ }
+
+ // Empty data is a clear, represented by an empty contents slice.
+ s.nextSlice("\x1B]52;s;\x1B\\");
+ try testing.expectEqual(@as(usize, cases.len + 1), S.count);
+ try testing.expectEqual(clipboard.Location.selection, S.last_location);
+ try testing.expectEqual(@as(usize, 0), S.last_contents_len);
+ try testing.expect(S.last_mime == null);
+ try testing.expect(S.last_data == null);
+
+ // Reads and malformed base64 are ignored.
+ s.nextSlice("\x1B]52;c;?\x1B\\");
+ s.nextSlice("\x1B]52;c;***\x1B\\");
+ try testing.expectEqual(@as(usize, cases.len + 1), S.count);
+
+ // OSC 1337 Copy shares the normalized clipboard write path.
+ s.nextSlice("\x1B]1337;Copy=:aVRlcm0y\x1B\\");
+ try testing.expectEqual(@as(usize, cases.len + 2), S.count);
+ try testing.expectEqual(clipboard.Location.standard, S.last_location);
+ try testing.expectEqualStrings("text/plain", S.last_mime.?);
+ try testing.expectEqualStrings("iTerm2", S.last_data.?);
+
+ // Parsing across write boundaries still invokes exactly one atomic write.
+ s.nextSlice("\x1B]52;p;ZnJh");
+ s.nextSlice("Z21lbnRlZA==\x1B");
+ s.nextSlice("\\");
+ try testing.expectEqual(@as(usize, cases.len + 3), S.count);
+ try testing.expectEqual(clipboard.Location.primary, S.last_location);
+ try testing.expectEqualStrings("text/plain", S.last_mime.?);
+ try testing.expectEqualStrings("fragmented", S.last_data.?);
+
+ // Callback results are intentionally ignored for protocols without a
+ // write acknowledgement. The denied result above did not stop later writes.
+ try testing.expectEqual(clipboard.WriteResult.denied, S.result);
+}
+
+test "clipboard_write allocation failure is ignored" {
+ var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
+ defer t.deinit(testing.allocator);
+
+ const S = struct {
+ var count: usize = 0;
+
+ fn clipboardWrite(_: *Handler, _: clipboard.Write) clipboard.WriteResult {
+ count += 1;
+ return .success;
+ }
+ };
+ S.count = 0;
+
+ var handler: Handler = .init(&t);
+ handler.effects.clipboard_write = &S.clipboardWrite;
+
+ var s: Stream = .initAlloc(testing.allocator, handler);
+ defer s.deinit();
+
+ // Only the decoded scratch data uses the terminal allocator here. Swap in
+ // an allocator that always fails, then restore it before terminal teardown.
+ {
+ const alloc = t.screens.active.alloc;
+ t.screens.active.alloc = testing.failing_allocator;
+ defer t.screens.active.alloc = alloc;
+ s.nextSlice("\x1B]52;c;aGVsbG8=\x1B\\");
+ }
+ try testing.expectEqual(@as(usize, 0), S.count);
+}
+
test "request mode DECRQM with write_pty callback" {
var t: Terminal = try .init(testing.allocator, .{ .cols = 80, .rows = 24 });
defer t.deinit(testing.allocator);
diff --git a/vendor/libghostty-vt/src/terminal/style.zig b/vendor/libghostty-vt/src/terminal/style.zig
index ae9488af..720789bb 100644
--- a/vendor/libghostty-vt/src/terminal/style.zig
+++ b/vendor/libghostty-vt/src/terminal/style.zig
@@ -966,6 +966,36 @@ test "Set capacities" {
_ = Set.Layout.init(16384);
}
+test "Set zero capacity" {
+ const testing = std.testing;
+ const alloc = testing.allocator;
+
+ // A zero-capacity set is a valid special case (see Layout.init).
+ // It occurs in practice for pages with exact capacities where no
+ // cell is styled (see Page.exactRowCapacity).
+ const layout: Set.Layout = .init(0);
+ try testing.expectEqual(0, layout.total_size);
+
+ // We allocate a nonzero buffer filled with 0xFF to simulate the
+ // set being embedded in a larger structure (e.g. a Page) where
+ // other data follows it. Lookups must not probe the zero-size
+ // table: table[0] would read this adjacent memory and treat it
+ // as an item ID, leading to far out-of-bounds item reads.
+ const buf = try alloc.alignedAlloc(u8, Set.base_align, 64);
+ defer alloc.free(buf);
+ @memset(buf, 0xFF);
+
+ var set = Set.init(.init(buf), layout, .{});
+
+ const style: Style = .{ .flags = .{ .bold = true } };
+ try testing.expectEqual(null, set.lookup(buf, style));
+ try testing.expectError(error.OutOfMemory, set.add(buf, style));
+ try testing.expectEqual(0, set.count());
+
+ // The adjacent memory must be untouched.
+ for (buf) |b| try testing.expectEqual(0xFF, b);
+}
+
test "Style HTML formatting basic bold" {
const testing = std.testing;
const alloc = testing.allocator;
diff --git a/vendor/libghostty-vt/src/terminal/x11_color.zig b/vendor/libghostty-vt/src/terminal/x11_color.zig
index 97f3a7ce..7239a47d 100644
--- a/vendor/libghostty-vt/src/terminal/x11_color.zig
+++ b/vendor/libghostty-vt/src/terminal/x11_color.zig
@@ -2,6 +2,20 @@ const std = @import("std");
const assert = @import("../quirks.zig").inlineAssert;
const RGB = @import("color.zig").RGB;
+/// A single X11 color entry.
+pub const Entry = struct {
+ /// Color name. Null-terminated so it can be exposed through the C API
+ /// without runtime allocation.
+ name: [:0]const u8,
+ color: RGB,
+};
+
+/// All X11 colors in rgb.txt file order.
+///
+/// This is kept separate from `map` because the C API needs stable file order
+/// and null-terminated names. `ColorMap` keys do not provide that contract.
+pub const entries: []const Entry = entriesArray();
+
/// The map of all available X11 colors.
pub const map = colorMap();
@@ -10,15 +24,10 @@ pub const ColorMap = std.StaticStringMapWithEql(
std.static_string_map.eqlAsciiIgnoreCase,
);
-fn colorMap() ColorMap {
- @setEvalBranchQuota(500_000);
-
- const KV = struct { []const u8, RGB };
-
- // The length of our data is the number of lines in the rgb file.
+fn entriesArray() []const Entry {
+ @setEvalBranchQuota(1_000_000);
const len = std.mem.count(u8, data, "\n");
- var kvs: [len]KV = undefined;
-
+ var result: [len]Entry = undefined;
// Parse the line. This is not very robust parsing, because we expect
// a very exact format for rgb.txt. However, this is all done at comptime
// so if our data is bad, we should hopefully get an error here or one
@@ -37,11 +46,29 @@ fn colorMap() ColorMap {
const g = try std.fmt.parseInt(u8, std.mem.trim(u8, line[4..7], " "), 10);
const b = try std.fmt.parseInt(u8, std.mem.trim(u8, line[8..11], " "), 10);
const name = std.mem.trim(u8, line[12..], " \t");
- kvs[i] = .{ name, .{ .r = r, .g = g, .b = b } };
+ var name_z: [name.len:0]u8 = undefined;
+ @memcpy(name_z[0..name.len], name);
+ name_z[name.len] = 0;
+ const final_name = name_z;
+ result[i] = .{
+ .name = final_name[0..name.len :0],
+ .color = .{ .r = r, .g = g, .b = b },
+ };
i += 1;
}
assert(i == len);
+ const final = result;
+ return &final;
+}
+
+fn colorMap() ColorMap {
+ @setEvalBranchQuota(1_000_000);
+
+ const KV = struct { []const u8, RGB };
+ var kvs: [entries.len]KV = undefined;
+ for (entries, 0..) |entry, i| kvs[i] = .{ entry.name, entry.color };
+
return .initComptime(kvs);
}
@@ -67,3 +94,14 @@ test {
try testing.expectEqual(RGB{ .r = 0, .g = 250, .b = 154 }, map.get("mediumspringgreen"));
try testing.expectEqual(RGB{ .r = 34, .g = 139, .b = 34 }, map.get("forestgreen"));
}
+
+test "entries" {
+ const testing = std.testing;
+
+ try testing.expect(entries.len > 700);
+ for (entries) |entry| {
+ try testing.expectEqual(entry.color, map.get(entry.name).?);
+ try testing.expectEqual(@as(u8, 0), entry.name.ptr[entry.name.len]);
+ }
+ try testing.expectEqualStrings("snow", entries[0].name);
+}