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)
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) andhash_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::layoutpreserves the reference 64-bit Linux layout calculation, including the unusual bitmap allocator formulaaligned_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
- 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.
- 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.
- 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.
- 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. - 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.
- 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.
style::BoldColoris 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.- 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)andset_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 withset_hyperlink(x,y,id), which consumes that reference without incrementing it. For another cell, callpage.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=trueis 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 areaPageList grow prune required with a single pagePageList scroll delta row back without space preserves activePageList: jump zero promptsScreen: jump back one promptScreen: jump forward prompt skips multiline continuationPageList eraseRows invalidates viewport offset cachePageList eraseRow invalidates viewport offset cachePageList eraseRowBounded invalidates viewport offset cachePageList eraseRowBounded multi-page invalidates viewport offset cachePageList eraseRowBounded full page shift invalidates viewport offset cachePageList eraseRowBounded exhausts pages invalidates viewport offset cachePageList promptIterator right_down continuation at startPageList promptIterator right_down with prompt before continuationPageList promptIterator right_down limit inclusivePageList promptIterator left_up limit inclusivePageList erase resets viewport to active if moves within activePageList erase resets viewport if inside erased page but not activePageList erase resets viewport to active if top is inside activePageList resize (no reflow) less rows cursor on bottomPageList resize (no reflow) less rows cursor in scrollbackPageList resize (no reflow) more rows contains viewportPageList resize (no reflow) more cols with spacer headPageList resize (no reflow) grow cols fast path with spacer headPageList resize (no reflow) more cols forces less rows per pagePageList resize more rows and cols doesn't fit in single std pagePageList resize (no reflow) more cols forces smaller capPageList resize (no reflow) more rows adds blank rows if cursor at bottomPageList resize reflow invalidates viewport offset cachePageList resize reflow more cols creates multiple pagesPageList resize reflow more cols wrap across page boundaryPageList resize reflow more cols wrap across page boundary cursor in second pagePageList resize reflow less cols wrap across page boundary cursor in second pagePageList resize reflow more cols cursor in wrapped rowPageList resize reflow more cols cursor in not wrapped rowPageList resize reflow more cols cursor in wrapped row that isn't unwrappedPageList resize reflow exceeds hyperlink memory forcing capacity increasePageList resize reflow exceeds grapheme memory forcing capacity increasePageList resize reflow exceeds style memory forcing capacity increasePageList resize reflow more cols unwrap wide spacer headPageList resize reflow more cols unwrap wide spacer head across two rowsPageList resize reflow less cols cursor goes to scrollbackPageList resize reflow less cols copy kitty placeholderPageList resize reflow more cols clears kitty placeholderPageList resize reflow wrap moves kitty placeholderPageList resize reflow grapheme map capacity exceededPageList resize grow cols with unwrap fixes viewport pinPageList grow reuses non-standard page without leakPageList grow non-standard page prune protectionPageList 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.