docs/wrap-architecture-migration.md
Living handoff for the work on PR #2850, branch
claude/single-line-file-perf-nzyqw6. Read this first when resuming.
Editing a 500 KB file with no newlines pins a CPU core. Of a 23.4 s capture,
90% is the main thread's event loop and 84% of that is Editor::render. Four
independent O(line-length) paths run per frame:
| share | path |
|---|---|
| 59% | build_view_data → ViewLineIterator: the renderer builds every row from byte 0 of the logical line and discards the ones above the viewport |
| 16.9% | visual_row_index::ensure_built → count_visual_rows_for_text: the exact scrollbar re-wraps the whole line on every keystroke |
| 1.4% | Viewport::scroll_down_visual + apply_visual_scroll_limit: each reads and to_strings the line, twice per wheel event |
| 2.3% | position_to_lsp_position: get_line(0) copies the whole line, then UTF-16-counts the prefix, on every hover-timer tick |
Root cause. A byte offset inside a logical line is not a coordinate
anywhere. Scroll position, the row index, both caches, and the LSP converter are
keyed on whole logical lines, so every per-line operation is O(line) — invisible
at 80 characters, catastrophic at 500,000. Both caches that should absorb it
(LineWrapCache, Viewport::wrap_row_cache) fold buffer.version() into their
keys and therefore die on every keystroke.
The character budget merged in #2849 bounds the first screen of a long line and nothing after it. It is a workaround this work replaces.
Four pieces. The Python model in tests/wrap_model/ is the executable
specification for all of them — when in doubt about intended behaviour, read the
model, not this document.
WrapMachine (view/wrap_machine.rs) — the one place row boundaries are
decided, for all three rules: Word (soft wrap), Grid (terminal
scroll-back, fresh#2649), Chop (wrap-off MAX_SAFE_LINE_WIDTH safety
break). Two drivers read a run: the renderer takes WrapOutput::tokens
(with Breaks spliced in), the index takes WrapOutput::rows.RowCarry — the complete resume state at a row boundary
(line_indent, on_continuation, ansi_in_escape, chars_in_row). Its
completeness is what licenses mid-line rendering and incremental repair.WrapIndex (view/wrap_index.rs) — row starts per logical line plus a
Fenwick tree of per-line counts. Answers row_of_byte, byte_of_row,
total_rows. Keyed on geometry; repaired on edit via damage_bytes,
not invalidated.ViewAnchor — the viewport as a single byte plus a signed row offset,
replacing (top_byte, top_view_line_offset).RowCarry completeness — resuming at a row reproduces the rest of the run.
Pinned by wrap_machine::tests::resume_at_any_boundary_reproduces_the_run and
test_wrap_machine.py::test_resume_at_any_boundary_reproduces_the_run.
Resume is defined over the source stream, never over wrapped output
(wrapping is not idempotent).repair ≡ rebuild — wrap_index::tests::repair_equals_rebuild.cursor_sig: 0 convention.A row that opens with injected content the carry cannot reconstruct — a soft
break's newline, an inline hint that wrapped onto it, a conceal's replacement —
cannot be addressed by byte alone, because whether that content belongs to
this row or the previous one is a decision the wrap made and a byte offset does
not record it. Hence LineWrap::resumable. A hanging indent is reconstructible
(it is in the carry), so indented continuations stay resumable.
Both the repair path and the renderer must walk back to the nearest resumable
row (WrapIndex::resumable_row_at_or_before) and skip forward. On a plain long
line — the case this exists for — every row is resumable and the walk-back is
zero.
Repair additionally requires:
old.row_starts[k] >= rel_end_old);
otherwise a coincidental alignment inside the edited region splices a bogus
tail and rows are silently lost;| phase | commit | state |
|---|---|---|
| Model + tests | f8ca42e | done — 993 tests, mypy strict, ruff clean |
1 — WrapMachine | 413eb19 | done — transforms are drivers |
2 — WrapIndex | e015328 | done |
2 — state wiring, per-geometry WrapIndexSet | 4e733e1 | done |
| 2b — scrollbar + scroll math onto the index | a5bd195 | done — visual_row_index.rs deleted |
3a — mid-line iteration (from_mid_line) | f2d6b6e | done |
3 — row-space ensure_visible | dfb9f67 | done |
| 3b — renderer starts at the anchor row | 2fc8b06 | done — the 59% |
| 3 — row-space wheel scrolling | 8433fc1 | done — the 1.4% |
| — LSP position reads only the prefix | 55924c1 | done — the 2.3% |
| 3d — collapse the last standalone wrap walks | 4f5ebca | done |
3c — ViewAnchor replaces the coordinate pair | — | optional cleanup |
4 — delete line_wrap_cache + writeback | — | optional cleanup |
| share | path | how |
|---|---|---|
| 59% | renderer built every row from byte 0 | anchored build (2fc8b06) |
| 16.9% | scrollbar re-wrapped the line per keystroke | wrap index, repaired not invalidated (a5bd195) |
| 1.4% | wheel scroll read the line twice per event | row arithmetic (8433fc1) |
| 2.3% | hover timer copied the line per tick | prefix read (55924c1) |
4f5ebca)Phases 1–3 made WrapMachine the rule but left five walks that still derived
row boundaries themselves. All five are now drivers over the machine, so nothing
outside it decides where a row ends:
| was | now |
|---|---|
wrap_str_to_width (112 lines, virtual lines) | rows projected into byte ranges |
viewport::compute_wrap_row_count_for_text | deleted — duplicate of count_visual_rows_for_text |
viewport::wrap_segment_source_bytes | reads RowInfo.source_byte |
line_wrap_cache::for_each_grid_row_start | deleted — grid_segment_source_bytes drives the Grid rule |
layout_for_plain_text{,_grid} | one body, parameterized by WrapRule |
The remaining grapheme walk in view/ is ViewLineIterator — a different
stage (tokens → ViewLine), not wrapping.
Two stages the migration had made redundant also went:
calculate_viewport_end walked logical lines every frame; under soft wrap the
drawn rows answer the decoration window and its result was discarded
(fresh#2843). It now runs only when the rows can't answer.Viewport::wrap_row_cache was a LineWrapCache storing counts as
Vec<ViewLine> of that length — thousands of empty ViewLines per miss on
a long line, with a byte-budget evictor to contain them. Now RowCountCache:
same key, u32 value, entry cap. placeholder_layout_for_row_count is gone.The agreement test wrap_str_to_width_matches_apply_wrapping_transform was kept
but re-aimed: with one implementation it can no longer catch drift, so it now
pins the projection — that the byte ranges tile the input exactly as the
machine's chunks do, dropping nothing at a row boundary.
Not collapsed: count_visual_rows_for_text_with_soft_breaks /
count_segment_rows_with_indent still segment a line at soft breaks and count
each segment with a prepended indent, rather than running apply_soft_breaks
over a real token stream. Doing it properly needs the tokenizer, hence a
&Buffer, which this caller doesn't have — it takes &str. Left for phase 4,
when materialize_rows gives every reader a buffer.
What remains is cleanup, not performance. 3c replaces (top_byte, top_view_line_offset) with a single anchor — 147 uses across 12 files, most in
viewport.rs. Its value is deleting snap_to_logical_line_start and the
fresh#1574 patch pair outright rather than bypassing them, which 2fc8b06 does
by construction (the anchored build skips every phase that reads the offset as
an index). Phase 4 deletes LineWrapCache once materialize_rows replaces its
readers.
The index derives a line's rows from that line's own bytes and the geometry, and from nothing else. Four things change the drawn rows without changing either, so with any of them live the index still answers and the answer is not the screen:
| why the rows move | guard | |
|---|---|---|
| collapsed folds | rows are hidden | folds.is_empty() in compute_buffer_layout |
| soft breaks | markdown_compose wraps each paragraph to its own narrower width | EditorState::wrap_index_models_layout |
| conceals | hidden spans let rows hold more | same |
| inline virtual text | spliced in before wrapping | same |
Virtual lines are fine — whole extra rows, counted through the callback
ensure_built takes.
Every failure was in the same direction: too few rows, so scrolling clamps
before the end of the buffer. Both scroll authorities — the wheel path's
wrap_scroll_geometry and the render path's anchor gate — check this before
using the index. VisualRowIndex had soft-break awareness (it ran
count_visual_rows_for_text_with_soft_breaks per line); the replacement does
not, and until it does, compose mode uses the byte-walking path it always used.
ensure_visible_in_rows and ensure_visible_in_layout both place the viewport
against the scroll margin, so exactly one may run per frame. The flag that
decides is rows_settled — did the row-space pass run — and not, as it first
was, "was an anchor resolved". Those come apart precisely when the row pass
lands on a logical line start, which is where a scroll up naturally stops:
top_view_line_offset is 0, no anchor is needed, and reading that as "the row
pass didn't run" let the layout pass apply the margin a second time. The cursor
ends up outside the margin it was just centred in, and every later press finds
nothing to do (fresh#1574's up-arrow invariant).
The row pass also has to reproduce fine_tune_scroll_up: while
scrolled_up_in_wrap is set the cursor is pinned at the margin, not merely
kept out of it. Without that, the coarser pre-render pass leaves the cursor a
row further down than the margin asks, neither margin test fires, and scrolling
stalls mid-buffer for the same reason.
EditorState holds a WrapIndexSet, not one index. Row structure depends
on the buffer's content and on geometry, and those sit on opposite sides of
the ownership split: content and decorations are per buffer, geometry is per
rendered view. The set therefore lives with the buffer — damage is a buffer
event — and is keyed by geometry so two splits at different widths keep separate
row structures. One edit repairs every entry. Capped at four geometries, LRU.
Do not collapse this back into a single index: a frame alternating between two
splits would rebuild from scratch each time. each_geometry_keeps_its_own_rows_and_both_repair
and geometry_set_is_bounded guard it.
Baseline to keep green: cargo test -p fresh-editor --lib (3173 passing),
cargo clippy -p fresh-editor --all-targets clean, cargo fmt --all.
Model: cd tests/wrap_model && .venv/bin/python -m pytest (993).
Goal: kill the 16.9% scrollbar path and the 1.4% scroll path.
EditorState gains wrap_index: WrapIndex alongside (initially) the existing
visual_row_index.WrapIndex::damage_bytes with an
EditDamage built from pre-edit line coordinates (line_before,
line_start_before, line_end_before) — the caller has the pre-edit buffer,
so it supplies them for free.damage_all.scrollbar_line_counts (view/ui/split_rendering/scrollbar.rs) reads
total_rows() / row_of_byte() instead of calling
visual_row_index::ensure_built. The MAX_WRAP_SCROLLBAR_LINES /
MAX_WRAP_SCROLLBAR_BYTES guards become a build-cost gate rather than an
accuracy downgrade.Viewport::scroll_{up,down}_visual, apply_visual_scroll_limit,
find_max_visual_scroll_position, clamp_top_byte_wrapped reduce to row
arithmetic.Deletes on completion. Struck through where 2b or 3d landed them:
view/visual_row_index.rscount_visual_rows_via_pipelinefor_each_grid_row_startcompute_wrap_row_count_for_textplaceholder_layout_for_row_countcount_visual_rows_for_text{,_grid}, grid_segment_source_bytes,
wrap_segment_source_bytes — kept, but each is now a ~15-line driver over
WrapMachine rather than an implementation. Deleting them means deleting
their callers, which is phase 4.count_visual_rows_for_text_with_soft_breaks,
count_segment_rows_with_indent, Viewport::count_visual_rows_for_line,
Viewport::wrap_row_cache — still standing; all four are on the
pre-first-render fallback path, so they go when phase 4 removes it.LineIterator::from_mid_line / Buffer::line_iterator_from_mid_line start at
exactly the given byte with no backward scan. The caller certifies the byte came
from WrapIndex::byte_of_row, which is what makes skipping the scan sound.
Tested: it starts where told, and reads only the tail.
The bounded slice, and the one that captures the 59%. Keep the viewport's
(top_byte, top_view_line_offset) pair for now; change only where the build
begins.
In build_view_data, when soft wrap is on and top_view_line_offset > 0:
anchor_row = index.line_first_row(line_of(top_byte)) + top_view_line_offset
(start_row, skip) = index.resumable_row_at_or_before(anchor_row)
addr = index.byte_of_row(start_row)
tokens = build_base_tokens(from addr.byte, mid_line = true, row budget)
rows = WrapMachine::resume(rule, addr.carry) ...
ViewData gains first_drawn: usize — the rows built before the window, i.e.
skip + (anchor_row - start_row). Every consumer that currently indexes lines
by top_view_line_offset must read first_drawn instead.
The four call sites in render_buffer.rs, and the crux. Three are simple
substitutions:
:350 let first_drawn = viewport.top_view_line_offset.min(...) — the
decoration byte window (fresh#2843).:372 / :382 calculated_offset — the drawn slice handed to the painter.:291 calculate_view_anchor(&view_data.lines, viewport.top_byte) — becomes
trivial, since the first built row is the anchor.The crux is the fourth: ensure_visible_in_layout (:248) and
scroll_to_end_of_view (:216) both search view_data.lines by absolute index
and write back top_view_line_offset. If lines no longer starts at the
logical line's first row, their arithmetic silently changes meaning — and
top_view_line_offset write-back is exactly what fresh#1574 was patching
(snap_to_logical_line_start, scrolled_up_in_wrap, fine_tune_scroll_up).
Do not attempt 3b without first converting those two to row space:
ensure_visible should compare index.row_of_byte(cursor) against the
viewport's top row and set the scroll directly, never by searching built rows.
That conversion is what makes the fresh#1574 patch pair deletable rather than
merely relocated — and it is why 3c (the full ViewAnchor) is the natural
follow-on rather than an optional extra: once scroll decisions are made in row
space, the two-coordinate pair has no remaining reader.
Suggested order for 3b: convert ensure_visible_in_layout and
scroll_to_end_of_view to row space first, with the existing
line-start-anchored build still in place and all e2e width-sweep tests green.
Only then move the build's starting point. Two commits, each independently
verifiable.
Replace (top_byte, top_view_line_offset) with ViewAnchor { byte, row_offset }
(row_offset signed: negative when the viewport starts on an injected row, which
owns no byte). See tests/wrap_model/wrap_model/viewport.py and editor.py for
the exact target shape.
LineIterator::from_mid_line — no backward scan; the caller certifies the byte
came from byte_of_row.build_base_tokens starts mid-line and stops on a row budget; the
char_budget parameter and base_char_budget (with its cursor clause) are
deleted — nothing is built to be discarded.WrapMachine::resume(carry) at the anchor; first row marked AfterBreak so
the gutter prints no line number against a continuation.ensure_visible runs in row space before anything is built, collapsing
compute_buffer_layout's up-to-three build_view_data calls to one.snap_to_logical_line_start, scrolled_up_in_wrap,
fine_tune_scroll_up, check_wrapped_visibility, scroll_to_cursor_wrapped,
the drawn-window slice in render_buffer.rs, calculate_view_anchor.Watch: _stable_anchor in the model handles a cursor scope starting above the
anchor within the same logical line — bounded walk-back, capped at 2 viewports.
materialize_rows(state, row_range, cursors) -> Vec<ViewLine> replaces
LineWrapCache entirely — including cursor_sig, LineWrapKey, and the ~130
line renderer writeback in view_data.rs. Movement and mouse call sites switch
to it. pipeline_inputs_version survives only as the damage_all trigger.
Four are fixed in Phase 1:
chunk_grapheme_count > 0 guard alone), so a
CJK glyph with one column left overflowed the row and was clipped.BinaryByte branch lacked the "row
has content" guard the grid path has, so a <XX> escape on a pane narrower
than 4 columns broke at column 0 and stranded an empty leading row.Deliberately not fixed: three tab stops (visual_layout::TAB_WIDTH = 8
decides wrap positions, a hardcoded 4 measures hanging indent, tab_size = 4
draws). Unifying them changes visible rendering for every file and does not
belong in a refactor. The model has TabPolicy + LEGACY_TAB_QUIRK ready for
when it is done deliberately.
Latent, removed as a class by the index: the count-only mirrors wrapped a single
synthetic Text token, so the space-overflow back-up (#1363) — which only fires
on Space tokens — never ran in them, and their counts could already differ
from what the renderer drew.
Buffer::get_line_number returns usize, not Option. delete takes a
Range, and neither delete nor insert returns a Result.build_line_tokens (in base_tokens.rs) reads a whole logical line through
the real tokenizer; it asks for len / MAX_LINE_BYTES + 1 pieces because a
long line is yielded in MAX_LINE_BYTES chunks and each counts against the
read budget.rm -rf tests/wrap_model/.hypothesis before concluding it
is flaky.