Soft line-wrapping (`:set wrap`)
Design fragment. Sequencing lives in the slice plan ../operations/slice-plans/soft-wrap.md; this file owns the what and why.
Status
Not implemented. Wrap is a registered option (lattice-config::core_options::Wrap, default false) that flows into OptionCache::wrap_lines, but nothing consumes it: the editor body renders one display row per source line and clips at the right edge (there is no horizontal-scroll / leftcol either). Popups (help / hover) wrap via manually_wrap_lines + wrap_aware_cursor_offset in lattice-ui-tui, but that path is popup-only.
Problem
With wrap on, one source line must occupy N ≥ 1 display rows. Today the entire stack assumes 1 source line == 1 display row: CellRow carries a single source_line, CellMatrix.visible_line_count equals the post-fold source-line count, the host scroll model counts doc lines, and both renderers walk rows 1:1. Wrapping breaks that invariant everywhere a display row is produced or counted.
This is the same class of divergence as excerpt-header virtual rows (see multibuffer-views.md §M.V): a display row that is not a document line. The scroll model already isolated the seam — Editor::bottom_anchored_scroll sums Σ_line (1 + virtual_rows_at(line)), and the 1-per-line term is explicitly the place wrap-segment count joins.
Decision — wrap geometry is computed off-thread and published
The cells worker (lattice-host::cells_worker) computes wrapping and publishes the resulting display geometry in the CellMatrix. Renderers and the host scroll model read that geometry; none of them re-derive it.
Rejected alternative: wrapping at paint time in each renderer. It traps wrap geometry on the render thread where the host scroll model can't see it (re-opening the display-row-vs-doc-line scroll bug under wrap), forks the algorithm across TUI and GPUI (violating cross-renderer parity), and would have to re-derive display.whitespace.* decoration to find correct break columns.
Paramount-goal alignment
- #1 (sub-frame latency). Wrap segmentation runs on the existing off-thread cell builder, once per edit / width-change, not per frame. The render thread iterates pre-computed rows. Segmentation of monospace cells is a column-chunk (break every
widthcolumns), cheap even off-thread; a bench gates the wrap-on build cost. - #2 (extensibility) / everything-is-a-buffer. Wrap geometry is a single published fact every consumer reads uniformly — no
BufferKindbranch, no per-renderer wrap logic. A new renderer or the scroll model readsvisible_row_count/rows_for_source_linethe same way. - #4 (asynchronicity). The width-change rebuild is debounced through the worker's existing recompute path; nothing blocks the UI.
display.whitespace.* is respected for free
The cell builder applies whitespace decoration (tab → →, trailing → ·, …) per cell before the matrix is published (cells_worker::build_chunk_rows). Because wrapping slices the already-decorated cells, wrap break points fall on displayed columns automatically — a tab rendered as a multi-column glyph wraps at its displayed width, not its source byte width. Paint-time wrapping would have had to re-apply whitespace rules to find break columns; Option A makes the requirement a non-issue.
Data model — A2 (publish wrap_width, slice at paint)
Locked 2026-06-03 after confirming each Cell is exactly one display column (CellRow::col_count == cells.len(), whitespace already decorated into cells). Wrap breaks therefore fall at trivial column boundaries k·width — there is no per-renderer wrapping decision, only arithmetic slicing of an already-built, already-highlighted cell array.
CellMatrixkeeps oneCellRowper source line. The source-line ↔ matrix-row 1:1 invariant — whichrow_at_source_line, the incremental-rebuild line-delta path (S2.4.b),pick_chunk_size, fold elision, and virtual-row anchoring all rest on — is preserved untouched. The matrix gains a single published fieldwrap_width: u32(0⇒ wrap off / not laid out) recording the column width the pane was built at.- Display geometry is derived, not stored. Per source line,
segment_count(line) = max(1, ⌈col_count / wrap_width⌉)whenwrap_width > 0, else1. The host scroll model reads this (row_at_source_line(line).col_count+wrap_width); both renderers slicecells[k·width .. (k+1)·width]into display rows at paint. Slicing is a sub-borrow of the sameArc<[Cell]>— O(viewport columns), pure arithmetic, no shaping/wrapping computation on the render thread. - Syntax highlighting + whitespace are preserved for free. Colour (
Cell.fg) and style flags are resolved per cell at build time; slicing partitions the coloured cells without touching them, so a wrapped segment carries exactly the colours/styles of the unwrapped row at the same columns. Wrapping cannot desync highlighting from text — there is no re-highlight-per-display-row step. - Width enters via
PaneCellsInputs.viewport_width(W.1, landed). The host does not need to know width — it readswrap_widthoff the published matrix.
Rejected: A1 (builder emits N CellRows per source line)
Earlier draft of this doc. It bloats visible_line_count into post-wrap space and breaks the 1:1 source-line ↔ matrix-row invariant the whole stack depends on, forcing re-derivation across row_at_source_line, the incremental rebuild, chunk sizing, fold elision, and virtual-row anchoring. Large, fragile blast radius for the easier-to-picture model — heuristic #1 (long-term fit beats easy implementation) forbids it. A2 keeps the source structure canonical and derives display geometry from one published fact (wrap_width).
Scroll model
bottom_anchored_scroll replaces its constant 1-per-line term with the published per-line segment count (Σ_line (wrap_segments(line) + virtual_rows_at(line))). With wrap off, wrap_segments(line) == 1 and behaviour is identical to today. scrolloff margins compose unchanged (they already route through bottom_anchored_scroll for the bottom margin; the top margin stays doc-line, since scrolloff is document context).
Grammar surface
j / k stay logical-line motions (vim default with wrap). Add gj / gk as display-line motions (next/prev wrap segment), and wrap-aware g0 / g$ (start/end of display row). These are new lattice-grammar motions; logical-line motions are unchanged. Display-line motions need the published segment geometry to resolve a target, consumed via the active buffer's matrix.
Virtual-row interaction
A VirtualRow anchors to a source_line. Under wrap, Above anchors emit before the source line's first segment and Below anchors after its last segment — the interleaver keys off source_line, which is stable across segments, so no change to the VirtualRowMatrix anchoring model. virtual_rows_in_line_range (used by the scroll seam) is unaffected: it counts virtual rows in a source-line range, orthogonal to wrap segments, and the scroll seam adds the two terms.
Renderers (TUI + GPUI, lockstep)
- The gutter renders once per source line: line number / fold marker / diagnostic / diff sign on
segment_idx == 0; continuation rows show a continuation marker (↪, with a BMP fallback per the icon-palette degradation rule) and a blank number column. - The cursor maps to
(source_line, segment_idx); both peers already have a doc-row→display-row remap (doc_to_shaped_row_localin GPUI, the compose walk in TUI) that extends to segments. - TUI can reuse the popup
wrap_aware_cursor_offsetlogic for the intra-line cursor column.
Foldability
Folds operate on source lines and compose above wrapping: a closed fold hides all segments of its lines. No interaction beyond "fold filters source lines before segmentation".
Performance posture
Wrap-on matrix build is benched (cells_worker already has bench infra) to confirm segmentation stays off the hot path; a width-change debounced rebuild reuses the edit-rebuild path. Render-thread cost stays O(viewport display rows), unchanged in order from today.