Skip to content

perf: linear grapheme boundaries and chunked line scans for wrapped-line navigation - #2784

Merged
sinelaw merged 1 commit into
masterfrom
claude/wrapped-line-nav-perf-wqwlh7
Jul 27, 2026
Merged

perf: linear grapheme boundaries and chunked line scans for wrapped-line navigation#2784
sinelaw merged 1 commit into
masterfrom
claude/wrapped-line-nav-perf-wqwlh7

Conversation

@sinelaw

@sinelaw sinelaw commented Jul 24, 2026

Copy link
Copy Markdown
Owner

Moving the cursor up/down through a very long soft-wrapped line (for example a single multi-hundred-KB line of repeated x characters) 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

  • Grapheme boundary helpers made linear. next_grapheme_boundary, prev_grapheme_boundary, grapheme_at, and snap_to_grapheme_boundary iterated grapheme_indices from 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 on GraphemeCursor, which resolves boundaries locally. prev_grapheme_boundary now also returns the containing cluster start for a position inside a multi-byte code point instead of overshooting one cluster left.
  • Chunked line-boundary scans. indent_folding::find_line_start_byte, find_line_end_byte, and find_next_line_start_byte read the buffer one byte at a time via slice_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_streaming bumps buffer.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):

Line length Before After
200 KB ~340 ms ~140 ms
50 KB ~168 ms ~37 ms
10 KB ~120 ms ~10 ms
short lines ~2.8 ms ~2.8 ms

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.rs pins 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

@sinelaw
sinelaw force-pushed the claude/wrapped-line-nav-perf-wqwlh7 branch from 3ea4625 to ccfd862 Compare July 24, 2026 11:05
…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
sinelaw force-pushed the claude/wrapped-line-nav-perf-wqwlh7 branch from c546cf5 to a45b35d Compare July 27, 2026 07:14
@sinelaw sinelaw changed the title Add cached-window fast path for wrapped-line navigation Fix quadratic scans slowing wrapped-line navigation Jul 27, 2026
@sinelaw sinelaw changed the title Fix quadratic scans slowing wrapped-line navigation perf: linear grapheme boundaries and chunked line scans for wrapped-line navigation Jul 27, 2026
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.
@sinelaw
sinelaw merged commit af123a3 into master Jul 27, 2026
8 checks passed
@sinelaw
sinelaw deleted the claude/wrapped-line-nav-perf-wqwlh7 branch July 27, 2026 14:58
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants