Files
ghostty-rs/docs/rewrite/notes/ghostty-terminal-pagelist.md
T
ddidderr 6cf16ed2e6 feat(terminal): port page memory model (partial PageList)
First cut of the terminal core memory model from ZIG/src/terminal:
size, point, style, hyperlink, ref_counted_set, page and PageList.

Design (see docs/rewrite/ARCHITECTURE.md decisions 2-4):
- Cell/Row are 8-byte u64 wrappers with Zig's exact bit offsets.
- Page owns row/cell Vecs plus style set, grapheme and hyperlink maps;
  Page::layout reproduces Ghostty's byte-size formula on 64-bit Linux so
  scrollback-limit pruning matches.
- PageList keeps nodes in a generation-checked slab with a free list and
  list-owned tracked pins addressed by handles.

This is explicitly partial (docs/rewrite/notes/ghostty-terminal-
pagelist.md): 151/202 PageList tests ported; reflow rebuilds a snapshot
instead of Zig's incremental ReflowCursor; point<->pin conversion walks
from the list head (no active/viewport caches); RefCountedSet doesn't
reproduce probe-dependent ID reuse. A follow-up pass finishes parity.

Test Plan:
- cargo test -p ghostty-terminal (239 tests)
2026-10-11 22:12:31 +02:00

18 KiB

ghostty-terminal page memory model — partial port

This is not a completed Ghostty PageList parity port. The core structures and many operations are implemented, but the remaining PageList cases below must be ported and used to drive corrections before Screen/Terminal relies on exact Zig reflow, allocation, and viewport behavior. In particular, passing the current crate tests does not establish equivalence of page boundaries under reflow.

No version bump or commit was made. Only ghostty-vt is required; no unicode, datastruct, or config dependency was introduced. Reference files were read only.

Module and test inventory

Zig source Rust module Source cases represented Extra Rust tests Unported / skipped
size.zig size 1 / 5 0 4 pointer-only tests skipped
point.zig point 0 / 0 0 0
style.zig style 37 / 37 0 0
hyperlink.zig hyperlink 0 / 0 0 0
ref_counted_set.zig ref_counted_set 0 / 0 2 0
page.zig page 46 / 46 0 0
PageList.zig page_list 151 / 202 2 50 unported, 1 skipped

Total: 239 passing Rust tests, consisting of 235 source-named regression cases and four added regressions. Some PageList cases use smaller equivalent fixtures and consolidate their assertions; matching a test name is not a claim that all incidental assertions or allocation paths in that Zig case were copied. Style formatter fixtures retain their reference expected strings. All 17 semantic-highlight cases retain the reference grid setup and expected endpoints.

Intentional skips:

  • Offset ptr u8, Offset ptr structural, getOffset bytes, getOffset structs: raw-address/base-pointer machinery was replaced with owned storage and logical cell indices. Offset<T> is a typed u32 logical offset; it does not dereference page-relative pointers.
  • PageList init error: Zig tripwire/allocator failure injection has no Rust counterpart here. Fallible grid reservations return errors, but allocation failure coverage is incomplete.
  • bitmap_allocator.zig (21 tests) and hash_map.zig (22 tests) are not ported as modules, as requested. Their logical accounting is implemented in page.rs.

Implemented structures and behavior

  • Cell and Row are transparent u64 wrappers, Copy + Clone, exactly eight bytes. Cell preserves the Zig bit offsets: tag 0, payload 2, style ID 26, wide 42, protected 44, hyperlink 45, semantic content 46. Row stores a cell index in its low 32 bits and uses the same flag positions as Zig. Row cell indices are not byte offsets; rotating rows moves metadata and preserves auxiliary-map keys.
  • Page owns row/cell Vecs, a deduplicated style set, grapheme data and allocation maps, a hyperlink map/set, and logical string/grapheme chunk allocators.
  • Page::layout preserves the reference 64-bit Linux layout calculation, including the unusual bitmap allocator formula aligned_cap * chunk_size, bitmap rounding to 64 chunks, map headers even at zero capacity, set 13/16 load factor and reserved ID zero, and 4096-byte final rounding. A standalone Zig ABI probe confirmed Style item size/alignment 36/4, hyperlink item 48/8, and offset-slice 16/8. The layout does not report actual Rust heap overhead.
  • Logical allocation tracks contiguous chunks, including temporary allocation of a larger grapheme before freeing its old chunks. Grapheme and hyperlink maps enforce separate entry limits. Dead hyperlink entries retain their string chunks until their set slot is reclaimed.
  • Page provides styles, graphemes, hyperlinks, copy/clone, partial-row copying, moves, swaps, clearing, exact row capacity, and debug/test verification. Unmanaged partial-row copies use a slice copy without per-cell maps or allocation. Default styles bypass set lookup.
  • PageList uses Vec<Option<Node>>, a free-index list, generation-bearing NodeId, and a separate reusable standard-page pool. It accounts substandard pages as standard pool allocations. Growing prunes whole history pages while protecting the active rows. Reused node indices receive new generations.
  • PageList has list-owned tracked handles, viewport scrolling, byte/row totals, splitting, capacity growth, compaction, erasure, reset, history clearing, cloning, row/column resize, reflow, prompt iteration/highlighting, and forward or reverse page/row/cell iterators. Iterators also implement DoubleEndedIterator where appropriate. PromptIterator follows continuation handling from Zig.

Important deviations / remaining work

  1. Reflow and wider no-reflow resize rebuild a snapshot of rows into new pages. They do not yet implement Zig's incremental ReflowCursor and page backfill algorithm. New page auxiliary capacities are sized from encountered content, instead of following the exact sequence of capacity doubling and splitting. Page boundaries, intermediate allocations, pruning, and maximum-size behavior can differ. This is the most important unfinished work.
  2. Snapshot reflow is linear in all cells and allocates temporary row vectors, copies managed values, and scans tracked locations while copying. It is not ready for the intended terminal hot-path performance requirements. Point conversion and viewport offsets traverse the list; the Zig active/viewport caches have not been reproduced.
  3. Cursor preservation during combined row/column resize is incomplete. Basic tracked cursor positions, blank-cell clamping, trailing blank trimming, and several wide/spacer cases are tested, but cross-page cursors, old/new wrap counts, viewport remapping under reflow, and all combinations need the remaining reference cases.
  4. The HashMap-backed RefCountedSet preserves zero reservation, bounded live slots, sequential IDs, trailing dead-slot trimming, add_with_id, checked reference counts, and OutOfMemory/NeedsRehash errors. It does not reproduce Robin Hood probe-dependent dead-ID reuse or the 32-probe failure threshold. Thus exact reused IDs and the point at which NeedsRehash occurs can differ. Hash values are Rust implementation details; Zig Style/Wyhash output is not reproduced. Style implements Hash/Eq for the Rust deduplication map.
  5. Direct Page clone uses Rust Clone. Some auxiliary temporary allocations and owned-value clones still use infallible standard collection operations. Actual process allocator exhaustion is not completely surfaced as Result; logical capacity exhaustion is. Allocation-failure atomicity has not been fully audited. There is no unsafe code or todo!/unimplemented! path.
  6. The row-range eraser supports arbitrary start ranges, while Zig's tested eraseRows use is primarily prefix/history/active erasure. Exact destination pins for arbitrary whole-page removal in the middle need further parity work.
  7. style::BoldColor is a small local policy enum because terminal must not depend on config. TODO(integration): convert the configuration type at the Screen boundary. Rgb/Palette/Underline are reused from ghostty-vt.
  8. Logical layout is for 64-bit Linux with a 4096-byte minimum page size. Other ABIs are not implemented. Pool reuse reuses Pages with matching capacities; it does not share a mmap allocator between independent PageLists.

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 and get_bottom_right return None for an empty region such as history before the first scroll.

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. Conservative managed-row flags may be true even after their last value is cleared. 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 and ensures 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. Consult the limitations above before relying on complex resize behavior.

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.

Remaining PageList source tests

The following 50 cases are unported, rather than justified skips. They include important production behavior and should drive the next implementation pass.

  • PageList grow allows exceeding max size for active area
  • PageList grow prune required with a single page
  • PageList scroll delta row back without space preserves active
  • PageList: jump zero prompts
  • Screen: jump back one prompt
  • Screen: jump forward prompt skips multiline continuation
  • PageList eraseRows invalidates viewport offset cache
  • PageList eraseRow invalidates viewport offset cache
  • PageList eraseRowBounded invalidates viewport offset cache
  • PageList eraseRowBounded multi-page invalidates viewport offset cache
  • PageList eraseRowBounded full page shift invalidates viewport offset cache
  • PageList eraseRowBounded exhausts pages invalidates viewport offset cache
  • PageList promptIterator right_down continuation at start
  • PageList promptIterator right_down with prompt before continuation
  • PageList promptIterator right_down limit inclusive
  • PageList promptIterator left_up limit inclusive
  • PageList erase resets viewport to active if moves within active
  • PageList erase resets viewport if inside erased page but not active
  • PageList erase resets viewport to active if top is inside active
  • PageList resize (no reflow) less rows cursor on bottom
  • PageList resize (no reflow) less rows cursor in scrollback
  • PageList resize (no reflow) more rows contains viewport
  • PageList resize (no reflow) more cols with spacer head
  • PageList resize (no reflow) grow cols fast path with spacer head
  • PageList resize (no reflow) more cols forces less rows per page
  • PageList resize more rows and cols doesn't fit in single std page
  • PageList resize (no reflow) more cols forces smaller cap
  • PageList resize (no reflow) more rows adds blank rows if cursor at bottom
  • PageList resize reflow invalidates viewport offset cache
  • PageList resize reflow more cols creates multiple pages
  • PageList resize reflow more cols wrap across page boundary
  • PageList resize reflow more cols wrap across page boundary cursor in second page
  • PageList resize reflow less cols wrap across page boundary cursor in second page
  • PageList resize reflow more cols cursor in wrapped row
  • PageList resize reflow more cols cursor in not wrapped row
  • PageList resize reflow more cols cursor in wrapped row that isn't unwrapped
  • PageList resize reflow exceeds hyperlink memory forcing capacity increase
  • PageList resize reflow exceeds grapheme memory forcing capacity increase
  • PageList resize reflow exceeds style memory forcing capacity increase
  • PageList resize reflow more cols unwrap wide spacer head
  • PageList resize reflow more cols unwrap wide spacer head across two rows
  • PageList resize reflow less cols cursor goes to scrollback
  • PageList resize reflow less cols copy kitty placeholder
  • PageList resize reflow more cols clears kitty placeholder
  • PageList resize reflow wrap moves kitty placeholder
  • PageList resize reflow grapheme map capacity exceeded
  • PageList resize grow cols with unwrap fixes viewport pin
  • PageList grow reuses non-standard page without leak
  • PageList grow non-standard page prune protection
  • PageList resize (no reflow) more cols remaps pins in backfill path

Quality gate

Final gate (2026-10-11):

  • cargo fmt -p ghostty-terminal: passed; only the documented nightly-option warnings from workspace rustfmt configuration.
  • cargo build -p ghostty-terminal: passed.
  • cargo test -p ghostty-terminal --quiet: 239 passed, 0 failed, 0 ignored.
  • cargo clippy -p ghostty-terminal --all-targets -- -D warnings: passed.
  • git diff --check -- crates/ghostty-terminal docs/rewrite/notes/ghostty-terminal-pagelist.md: passed for tracked changes.

The early missing ghostty-vt::stream dependency file was resolved by concurrent workspace work; no change to that crate was needed here. The terminal Cargo.toml already had local changes at the beginning of this task and was not edited here.