Skip to content

fix(core/text-buffer-view): fill partially wrapped lines across chunk boundaries - #1330

Open
3aKHP wants to merge 1 commit into
anomalyco:mainfrom
3aKHP:vesicle/fix-1288-wrap-reflow
Open

fix(core/text-buffer-view): fill partially wrapped lines across chunk boundaries#1330
3aKHP wants to merge 1 commit into
anomalyco:mainfrom
3aKHP:vesicle/fix-1288-wrap-reflow

Conversation

@3aKHP

@3aKHP 3aKHP commented Aug 4, 2026

Copy link
Copy Markdown

Fixes #1288

Summary

With wrapMode="word", inserting into the middle of an already soft-wrapped logical line anchors the first wrap boundary at the edit offset. The affected row stays truncated — e.g. initialValue = "a" * 100 at wrap width 56, insert "X" at col 20: the virtual line count becomes 3 with row 1 ending at col 21, where a fresh setText of the same 101 characters yields 2 full-width rows. Reproduction is in #1288.

Root cause

This is not stale caching — all wrapping is recomputed from scratch per update by calculateVirtualLinesGeneric. It is deterministic chunk-segmentation divergence:

  • Rope edits split chunks at the edit offset: after the repro edit the line is chunks ["a"*20]["X"]["a"*80] instead of one chunk.
  • In the word-mode walker (text-buffer-view.zig), when a chunk arrives at a partially filled line and no wrap break has been recorded on that line (remaining_in_chunk > remaining_on_line, no last_wrap_that_fits, last_wrap_chunk_count == 0), the final else branch called commitVirtualLine() unconditionally — treating the chunk boundary as a wrap point — then filled the next line at full width.
  • A single-chunk buffer with identical text takes the line_position == 0 hard-fill branch instead and wraps correctly. Hence "only broken after editing".

Fix

One hunk in the else branch: a chunk boundary is not a wrap point. Hard-fill the remaining space on the current line from the chunk via utf8.findWrapPosByWidth(remaining_bytes, remaining_on_line, ...), mirroring the line_position == 0 path. Commit only when:

  • the line is already full (remaining_on_line == 0), or
  • the next grapheme does not fit the remaining space (columns_used == 0, e.g. 1 column left and a width-2 CJK grapheme next) — then the short row commits and the next iteration hard-fills a fresh line. The force-one-grapheme guard is kept only for the full-line case, exactly as before; applying it mid-line would have split wide graphemes across rows and changed single-chunk behavior too (single-chunk walks also reach this branch after the first hard-filled row).

All other branches — fit, last_wrap_that_fits, line_position == 0, and the last_wrap_chunk_count > 0 backtrack (#1078 machinery) — are byte-identical. This branch only runs when no wrap break was recorded on the current line, so word-wrap backtracking is unaffected.

Intended behavior change: an unbreakable run arriving at a partially filled line no longer pushes to the next row. Example: chunks ["hello"]["x"*100] at wrap 56 (e.g. insert "hello" at offset 0 of an x-run) — old: rows [5, 56, 44]; new: [56, 49], identical to a fresh setText of the same content. Edge behavior preserved: a wide grapheme that doesn't fit the remaining space still wraps whole (["a"*54]["世"*20] at wrap 55 → [54, 40], matching single-chunk behavior before and after; pinned by test).

Tests

11 new tests in packages/core/src/zig/tests/word-wrap-editing_test.zig (file already registered in test.zig). The central oracle is expectSegmentationMatchesFreshSet: read the edited buffer's full text, setText it into a fresh buffer + view with the same wrap mode/width, and assert identical virtual-line segmentation (count, source_line, source_col_offset, width_cols) — segmentation must depend only on text content, never on edit history.

  • exact issue repro: insert into soft-wrapped unbreakable line reflows (expected 2, found 3 pre-fix)
  • insert / delete / paste-like multi-char insert at several offsets (line start, mid-first-row, row boundary, mid-second-row), oracle-checked
  • word boundaries still backtrack after a mid-row edit (spaces; explicit widths + oracle)
  • unbreakable CJK line edited mid-row (asserts even row widths = no grapheme split)
  • mixed widths with combining marks, oracle at 4 offsets
  • five sequential single-char inserts, oracle after each
  • undo → redo restores fresh-set segmentation
  • wrap width change after edit (56→40→56), oracle-checked
  • wide grapheme that does not fit the remaining space is not split (guard for the new branch)

Each was run individually and fails pre-fix except the two behavior guards. zig build test: 1877 passed, 6 skipped, 0 failed. zig fmt clean. wrapMode="char"/"none" untouched — the change is entirely inside the word-mode arm and their existing tests pass unchanged.

Performance

No asymptotic change: per loop iteration the work is identical — one findWrapPosByWidth scan over the same bytes, only the width argument differs, and the tracked byte_offset invariant (the O(n²) guard) holds. A commit is removed from the common path; the new commit-and-continue is bounded by the number of virtual lines. zig build bench ("TextBuffer Wrapping", word wrap avg, before → after): multi-line w40 27.69→18.60 ms, w80 21.52→15.00 ms, w120 17.44→13.19 ms; single-line w40 28.29→25.18 ms, w80 27.74→26.00 ms, w120 26.34→27.22 ms — no regression (differences are machine noise in our favor).

… boundaries

Rope edits split chunks at the edit offset. In the word-mode virtual
line walker, when an unbreakable chunk arrived at a partially filled
line and no wrap break had been recorded on that line, the final else
branch committed the virtual line unconditionally — treating the chunk
boundary as a wrap point. After a mid-line edit of an already wrapped
line this anchored the first wrap boundary at the edit offset and left
the row permanently truncated (e.g. 100 x 'a' at wrap width 56, insert
one char at col 20: 3 virtual lines with row 1 ending at col 21
instead of 2 full-width rows).

A chunk boundary is not a wrap point. The branch now hard-fills the
remaining space on the current line from the chunk via
findWrapPosByWidth (mirroring the line_position == 0 path), committing
only when the line is already full or when the next grapheme does not
fit the remaining space. Segmentation after edits is now identical to
a fresh setText of the same content; word-wrap backtracking via
last_wrap_* (anomalyco#1078 machinery) is untouched since this branch only runs
when no break was recorded on the current line.

Fixes anomalyco#1288
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.

Textarea: inserting into a soft-wrapped line anchors the wrap boundary at the edit offset

1 participant