Files
ghostty-rs/docs/rewrite/notes/ghostty-terminal-pagelist.md
T
ddidderr ed7c644438 feat(terminal): bring PageList to full parity with Ghostty
Second pass over the page memory model (done by Codex/gpt-6-astra in an
isolated worktree, reviewed and merged here):
- Resize/reflow now port Zig's incremental ReflowCursor algorithm with
  page backfill and the same capacity growth/split sequence, instead of
  rebuilding a snapshot, so page boundaries, pruning and tracked-pin
  remapping match Ghostty.
- Point<->pin conversion for the active area starts at the list tail and
  the viewport offset is cached, so hot-path operations no longer walk
  the whole scrollback from the head.
- RefCountedSet reproduces Zig's observable ID reuse and error points.
- All 202 PageList tests are ported (plus 13 extra regressions).

With this, the five Screen/Terminal tests previously ignored as blocked
on PageList behaviour pass and are re-enabled.

Test Plan:
- cargo test -p ghostty-terminal (913 passed, 0 ignored)
- cargo clippy -p ghostty-terminal --all-targets -- -D warnings
2026-10-11 22:41:14 +02:00

15 KiB

ghostty-terminal page memory model

The PageList parity pass is complete. All 50 previously missing PageList tests are ported. Earlier consolidated tests now use the reference dimensions, capacity-derived page boundaries, operations, and assertions. Shared fixture and assertion helpers retain the complete source checks, including managed values, page links, dirty/wrap flags, viewport offsets, and tracked locations.

No public signatures were removed or changed. No version bump or commit was made. Only the assigned source files and these notes were changed. The Zig reference remained read-only; no dependency or production module was added.

API changes

No breaking public API changes. Additions to RefCountedSet:

  • init_with_hasher(cap, fn(&T) -> u64) supplies deterministic probe ordering.
  • clear() resets entries while retaining allocated storage and hash context.

The existing PageList, Page, Point, Pin, TrackedPin, Style, and Hyperlink APIs remain available to the concurrent Screen port. New copy/reflow helpers are private or crate-private.

Module and test inventory

Zig source Rust module Source cases ported Extra Rust tests Zig-only skips Rust total
size.zig size 1 / 5 0 4 1
point.zig point 0 / 0 0 0 0
style.zig style 37 / 37 0 0 37
hyperlink.zig hyperlink 0 / 0 2 0 2
ref_counted_set.zig ref_counted_set 0 / 0 7 0 7
page.zig page 46 / 46 0 0 46
PageList.zig page_list 201 / 202 4 1 205

Total: 298 passing Rust tests, including 285 source cases and 13 additional regressions. No functional source test remains unported. Style formatter fixtures retain the source strings; all 17 semantic-highlight tests retain their grids and endpoints. Tests with repeated cases use loops without dropping checks.

The guide explicitly permits these Zig-only skips:

  • Offset ptr u8, Offset ptr structural, getOffset bytes, getOffset structs: raw base-address dereferencing is replaced by safe owned storage and logical offsets. There are no page-relative Rust pointers to test.
  • PageList init error: the Zig tripwire allocator/failure-injection harness has no counterpart in this safe Rust ownership model. Logical capacity errors are covered; process allocator failure injection is not ported.

bitmap_allocator.zig and hash_map.zig are not new modules. Their bounded storage/layout responsibilities remain in page.rs as required by the scope.

Implementation

  • Cell and Row remain transparent eight-byte u64 wrappers with the reference field positions. Row cell references are logical indices, so rotating row metadata preserves auxiliary keys without moving cell contents.
  • Page retains the reference logical layout and allocation capacities: 64-bit Linux, 4096-byte page rounding, bitmap chunk rounding, map headers, 13/16 set load factor, reserved ID zero, and managed-storage item sizes. Logical byte accounting intentionally does not measure Rust collection overhead.
  • Graphemes use a page-owned codepoint arena and preallocated maps. Appending across a chunk boundary allocates the new logical block before releasing the old one. Copying/reflowing a grapheme allocates no per-cell Vec. Hyperlink duplicates borrow source values, cloning owned strings only for a new persistent destination entry. String allocations retain Zig's chunk limits and dead-entry lifetimes, including deletion during a failed insertion.
  • RefCountedSet uses a Vec-backed Robin Hood table with the reference probe ordering, reference-count tie break, backwards-shift deletion, trailing dead-ID trimming, encountered dead-ID reuse, preferred IDs, 32-probe ceiling, and NeedsRehash/OutOfMemory decision order. The observable living-count behavior of a dead preferred-ID upsert is retained and tested. Page style hashes use Zig's packed 128-bit fold/integer hash; hyperlink hashes use its Wyhash/tag/ slice-length encoding. Wyhash block-boundary and hyperlink encoding vectors were checked against a standalone Zig probe.
  • PageList uses generation-bearing node and tracked-pin handles. Standard-sized pages are recycled, oversized pages are released, and byte/row accounting follows the reference allocation/pruning rules. Reset releases excess pooled history. Split, compact, and capacity growth allocate one destination page and clone directly into it.
  • Reflow follows resizeCols/ReflowCursor: column/row resize ordering, inherited capacities, deferred blank rows, cursor wrap-count preservation, cell-by-cell tracked-pin remapping, wrap and continuation metadata, wide/spacer handling, graphemes, styles, hyperlinks, and Kitty placeholder metadata. Capacity grows in the reference sequence; an exhausted page splits off the current final row and retries. Consumed source pages are released immediately. There are no temporary row snapshots or per-cell temporary heap allocations in reflow.
  • Widening without reflow uses the reference capacity fast path and spacer-head fallback, backfills previous pages, remaps backfilled pins, and creates the required remaining pages. Shrinking clears truncated managed cells and clamps pins. Background-only trailing rows follow the reference trimming behavior.
  • Active point lookup starts at the tail, Viewport lookup starts at its anchor, and pin-to-point walks only from the requested region's origin. Pin movement and ordering traverse adjacent nodes. Pinned viewport offsets are cached and updated/invalidated during scrolling, pruning, erasure, and resize. Grow never computes an uncached viewport offset. Debug integrity checks validate cached offsets against the actual list.
  • Row erasure rotates row metadata, copies only cross-page boundary rows, and remaps pins in place. Active range erasure regrows the removed rows even when history already supplies enough total rows. Clone preserves source capacities, chunk boundaries, managed data, dirty flags, and optional pin remapping.

The safe Rust representation replaces mmap/base-pointer machinery with owned allocations. Allocation addresses are therefore not compared in the nonstandard page reuse test; node generations and live page/pool membership verify the same lifetime property. No unsafe code or reachable todo!/unimplemented! path was introduced. style::BoldColor remains the local policy type mandated by the terminal/config dependency boundary; Screen converts configuration at integration.

API overview for Screen / Terminal agents

Coordinate and lifetime model

Point::{Active,Viewport,Screen,History}(Coordinate { x: u16, y: u32 }) selects the coordinate frame. Point::new(Tag, Coordinate) is useful for dynamic frames. pin(point) and point_from_pin(tag, pin) return Option for bounds errors. get_top_left(History) is the screen origin even without history, as in Zig; get_bottom_right(History) returns None when history is empty. Conversion starts at the selected frame and may continue through subsequent rows in the list, as in Zig; it does not impose an extra frame-height bound. Active lookup traverses backward from the tail; Viewport lookup starts at its active, top, or tracked anchor. viewport_row_offset(&self) caches pinned offsets with an atomic value, preserving the existing immutable API and Send/Sync traits.

Pin { node: NodeId, y: u16, x: u16, garbage: bool } is Copy and contains no references. Pass the owning list explicitly to its access and movement methods: p.row_and_cell(&list), p.style(&list), p.grapheme(&list), p.cells(&list, CellSubset::All), p.up(&list, n), p.right_wrap(&list, n), etc. CellSubset::Left and Right both include the pin column. Vertical overflow methods return Overflow::{Offset,Overflow { end, remaining }}. diagonal uses signed deltas and clamps columns. before/is_between use row-major order.

Untracked pins must be reacquired after content-moving mutations. For a cursor, selection endpoint, or saved location, call track_pin(pin) -> Result<TrackedPin>. Read its current Copy value using tracked_pin(handle); change its position with set_tracked_pin(handle, pin). Release with untrack_pin(handle). Handles are specific to the owning list. Recycled handles have a new generation, so releasing an old handle does not release its successor. Pruning/reset mark external pins as garbage; ordinary erasure remaps them without setting garbage. The list owns one internal tracked viewport handle, included in count_tracked_pins().

Reading and writing cells

PageList::get_cell(point) returns a Copy Cell, and get_cell_mut(point) returns a direct mutable reference. node(id) / node_mut(id) expose the page. Page::get_row(y), get_cells(y), get_cells_mut(y), and cell_index(x,y) use logical row coordinates and preserve O(1) row access even after row rotation. Auxiliary lookup methods take a cell index returned by cell_index, not a column or pointer. The vectors include unused capacity; iteration should respect page.size, not the vector lengths.

Construct plain text with Cell::init(codepoint). Set flags with set_wide, set_protected, set_semantic_content, etc. set_codepoint changes only the payload; use set_content_tag when changing between text and background colors. For managed cells, clear the old data with page operations before assigning a new raw Cell. Direct writes must maintain row flags and dirty tracking.

  • set_style(x,y,Style) handles style add/release and marks the row styled. Default style is ID 0; page.style(cell) resolves it without map access.
  • append_grapheme(x,y,cp) and set_graphemes(x,y,&extra) store only codepoints beyond the cell's first codepoint. Set the first nonzero codepoint beforehand.
  • insert_hyperlink(Hyperlink) returns an already-retained ID. Attach it with set_hyperlink(x,y,id), which consumes that reference without incrementing it. For another cell, call page.hyperlink_set.r#use(id) first. If attachment fails, release the retained ID. clear_hyperlink(index) releases the cell reference.
  • clear_cells(y,start,end,protect) uses half-open columns. protect=true is a Rust convenience that skips protected cells. Move and swap helpers transfer auxiliary ownership and avoid changing reference counts for moved values.

row.set_dirty(true) / pin.mark_dirty(&mut list) support direct writes; list.clear_dirty() resets page and row flags. is_dirty checks both levels. clear_cells updates managed-row flags using the reference full/partial clear rules. Individual clear helpers may leave conservative flags set; use the explicit update_row_*_flag methods when a precise flag is needed.

Errors and capacity recovery

page::Error includes separate style set, grapheme allocator/map, hyperlink set/map, and string exhaustion variants. ref_counted_set::AddError distinguishes OutOfMemory from NeedsRehash, plus invalid IDs/counts for safe API misuse handling. RefCountedSet::add_with_id(value,id) returns None when the requested ID was used, otherwise Some(actual_id). get and lookup do not retain values.

increase_capacity(node, Some(IncreaseCapacity::{Styles,GraphemeBytes, HyperlinkBytes,StringBytes})) replaces a page with increased limits and remaps tracked pins; None rebuilds the same capacity to reclaim dead IDs. adjust_capacity is an alias. OutOfSpace means doubling/page-size limits prevent further growth; split(pin) can divide at a row boundary. Splitting row zero is otherwise a no-op, and a single-row page returns OutOfSpace. compact(node) returns Some(replacement) only when an oversized page can shrink. Always use the returned NodeId, or resolve a tracked pin again after replacement.

List mutations and iteration

grow() adds one row, returning Some(new_node) if another page was needed. scroll(Scroll::{Active,Top,Row,DeltaRow,DeltaPrompt,Pin}) only changes viewport. scrollbar() reports row counts and hides physical history at max_size zero. scroll_clear() moves the nonempty active rows into history.

erase_rows(top, optional_bottom) removes inclusive rows. Active erasure regrows every removed row, preserving history; other frames retain at least active height. erase_row(point) shifts the remainder of the screen while retaining page sizes. erase_row_bounded(point,limit) shifts at most limit following rows. erase_cells_bounded(start_pin,end_pin,left,right) is an extra rectangular helper. reset() clears content and invalidates tracked contents; clear_history() preserves the active area.

resize(Resize { cols, rows, reflow, cursor }) takes optional new dimensions and an optional active Cursor { x,y }. Reflow defaults to true. Cursor is input context; track the actual cursor pin separately to read back its new position. Reflow writes incrementally with a ReflowCursor. Widening without reflow first backfills the preceding page, then splits into pages using adjusted source capacities. Both paths remap tracked handles as rows/cells move.

clone_range(top,bottom) clones inclusive whole rows, ignoring x, pads to the active height, and returns (PageList, HashMap<old_handle,new_handle>). try_clone() clones the whole screen without retaining external tracked pins. Clone viewport defaults to Active; dirty information is retained by rows. Page itself implements Clone for an independent full copy; its inherent clone_from(source,start,end) copies a half-open row range into existing capacity. clone_partial_row_from accepts source/destination row and column plus length; clone_cells_within handles same-page copies without conflicting borrows.

page_iterator, row_iterator, cell_iterator take Direction, start Point, and optional inclusive end Point. End None uses the frame's bottom-right. Reverse direction traverses the same bounds backwards. PageIterator yields Chunk { node,start,end } with half-open page-local rows. Iterators borrow the list immutably; collect Copy pins/chunks or end iteration before mutating it. prompt_iterator finds semantic prompt starts; highlight_semantic_content returns an optional inclusive (start_pin,end_pin) for a prompt's Prompt, Input, or Output region.

Page::verify_integrity and PageList::verify_integrity are available in debug and test builds, as are assert_integrity helpers. Nestable pause methods support batched low-level updates. Verification is explicit; raw mutable access can break invariants until the caller completes its update.

Quality gate

Final gate (2026-10-11):

  • cargo build -p ghostty-terminal: passed.
  • cargo test -p ghostty-terminal: 298 passed, 0 failed, 0 ignored; doc tests passed.
  • cargo clippy -p ghostty-terminal --all-targets -- -D warnings: passed.
  • cargo fmt -p ghostty-terminal: passed (only workspace nightly-option warnings).
  • git diff --check: passed.

Remaining functional limitations or missing PageList cases: none. The five Zig-only test skips and ownership/ABI representation choices are documented above.