perf: linear grapheme boundaries and chunked line scans for wrapped-line navigation - #2784
Merged
Merged
Conversation
sinelaw
force-pushed
the
claude/wrapped-line-nav-perf-wqwlh7
branch
from
July 24, 2026 11:05
3ea4625 to
ccfd862
Compare
…on bump Navigating a very long soft-wrapped line cost hundreds of milliseconds per keypress. This lands the algorithmic fixes (a follow-up PR adds a render-side cache on top): - Grapheme boundary helpers (next/prev_grapheme_boundary, grapheme_at, snap_to_grapheme_boundary) iterated grapheme_indices from the start of the string on every call, making callers' walks quadratic — the reference-highlight word scan alone cost ~600 ms/keypress inside a giant word. Rewritten on GraphemeCursor, which resolves boundaries locally; prev_grapheme_boundary now also returns the containing cluster's start for a position inside a multi-byte code point instead of overshooting one cluster left. - indent_folding line-boundary scans (find_line_start_byte, find_line_end_byte, find_next_line_start_byte) read the buffer one byte at a time via slice_bytes; they now scan in 4 KiB blocks. These run per rendered frame for the cursor line. - extend_streaming appends content without bumping buffer.version(), leaving version-keyed layout caches (visual-row index) reachable with pre-append state. It now bumps the version (without marking the buffer dirty — content still matches the on-disk stream). Measured per keypress at 80x24 (release; the test harness renders twice per press): 200 KB single wrapped line ~340 ms -> ~140 ms, 50 KB ~168 ms -> ~37 ms, 10 KB ~120 ms -> ~10 ms; short lines unchanged (~2.8 ms). The follow-up cache PR takes the long-line cases to ~5-30 ms. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01J3uiv3CFZEAFARmTkqxpaQ
sinelaw
force-pushed
the
claude/wrapped-line-nav-perf-wqwlh7
branch
from
July 27, 2026 07:14
c546cf5 to
a45b35d
Compare
sinelaw
pushed a commit
that referenced
this pull request
Jul 27, 2026
The dock's cards were three rows tall and spent most of that space repeating themselves. The name row carried a project tag next to a name usually derived from that same project; the middle row put the branch and the git summary into a *single* right-aligned group, so the branch floated in the middle of the card instead of starting at its left edge; and the third row was the PR badge, blank on every workspace without a PR. Five screen rows per workspace, for about two rows of information. A card is now two rows, each one "what it is" on the left and "how it's doing" flush right, so the status of every workspace lines up in one column down the dock: ╭──────────────────────────────╮ │ * fresh-27 ↑2 +14 │ │ ▸ fix/wrapped-nav PR #2784 │ ╰──────────────────────────────╯ The second row never repeats the first: the branch takes it only when it differs from the workspace name (a worktree's branch usually *is* the name), otherwise the project does, and when that would echo the name too — a plain folder opened as its own workspace — the row stays empty. `(detached)` is now reserved for a real repo with no branch instead of labelling every non-git folder. A being-created workspace keeps its whole status message on row two, with the retry key moved up to row one's right slot. The alignment itself is host-side: a card row can now carry `align: "between"` plus the byte offset where its two groups meet, and `render_tree_card` — which alone knows the card's real width, responsive or dragged — inserts the gap, always keeping at least one column so the groups can't run together on a narrow dock.
sinelaw
pushed a commit
that referenced
this pull request
Jul 27, 2026
The dock's cards were three rows tall and spent most of that space repeating themselves. The name row carried a project tag next to a name usually derived from that same project; the middle row put the branch and the git summary into a *single* right-aligned group, so the branch floated in the middle of the card instead of starting at its left edge; and the third row was the PR badge, blank on every workspace without a PR. Five screen rows per workspace, for about two rows of information. A card is now two rows, each one "what it is" on the left and "how it's doing" flush right, so the status of every workspace lines up in one column down the dock: ╭──────────────────────────────╮ │ * fresh-27 ↑2 +14 │ │ ▸ fix/wrapped-nav PR #2784 │ ╰──────────────────────────────╯ The second row never repeats the first: the branch takes it only when it differs from the workspace name (a worktree's branch usually *is* the name), otherwise the project does, and when that would echo the name too — a plain folder opened as its own workspace — the row stays empty. `(detached)` is now reserved for a real repo with no branch instead of labelling every non-git folder. A being-created workspace keeps its whole status message on row two, with the retry key moved up to row one's right slot. The alignment itself is host-side: a card row can now carry `align: "between"` plus the byte offset where its two groups meet, and `render_tree_card` — which alone knows the card's real width, responsive or dragged — inserts the gap, always keeping at least one column so the groups can't run together on a narrow dock.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Moving the cursor up/down through a very long soft-wrapped line (for example a single multi-hundred-KB line of repeated
xcharacters) cost 120 to 340 ms per keypress in release builds. This PR lands the algorithmic fixes. A follow-up PR (#2813, stacked on this one) adds a render-side cache on top.What
next_grapheme_boundary,prev_grapheme_boundary,grapheme_at, andsnap_to_grapheme_boundaryiteratedgrapheme_indicesfrom the start of the string on every call, making caller loops quadratic. The reference-highlight word scan alone cost about 600 ms per keypress inside a giant word. Rewritten onGraphemeCursor, which resolves boundaries locally.prev_grapheme_boundarynow also returns the containing cluster start for a position inside a multi-byte code point instead of overshooting one cluster left.indent_folding::find_line_start_byte,find_line_end_byte, andfind_next_line_start_byteread the buffer one byte at a time viaslice_bytes(a full call round-trip per byte) and run per rendered frame for the cursor line. They now scan in 4 KiB blocks.extend_streamingbumpsbuffer.version(). Streamed appends changed content without bumping the version, leaving version-keyed layout caches (visual-row index) reachable with pre-append state. The buffer is deliberately not marked dirty, since content still matches the on-disk stream.Performance
Per keypress at 80x24 (release build; the test harness renders twice per press):
The remaining per-frame cost is the full pipeline re-wrapping the visible window on every frame. #2813 removes it with a cached-window fast path, taking the 200 KB case to about 25 ms.
Reproduce
Create a file whose only line is 200,000 repeated characters (a minified JS one-liner also works), open it in fresh (line wrap is on by default), and hold Down or Up: each keypress visibly lags before this PR.
Tests
streaming_and_grapheme_regression_tests.rspins the streaming version bump and the mid-code-point grapheme case; the existing grapheme unit tests cover the rewrite. Full lib suite (3075), scroll/wrap e2e and semantic suites pass.🤖 Generated with Claude Code
https://claude.ai/code/session_01J3uiv3CFZEAFARmTkqxpaQ