formatter: blank cells carry only across a joined wrap, not after a line break

LA
5d ago
+303-5

Summary

With unwrap, PageFormatter carried a row's trailing blank cells into the next row when that row was wrap_continuation, even after it had emitted a line break before that row because the prior row did not wrap. A row can keep wrap_continuation after the row before it stops wrapping, so the carried blanks were printed after the break and the row came out shifted right, by up to the whole width.

Found by cmux's VT resize fuzzer (finding E2): an attach replay (cmux-tui vt_replay_bytes, which formats with unwrap) drew a row 43 or 163 columns to the right after a 20x5 to 164x14 resize. Probe of the shrunk case: row 10 wrap=false, row 11 wrap_continuation=true; the formatter emitted \r\n, then 163 spaces, then row 11's text.

Fix: read whether a line break goes out before the row (broke_before, pending blank rows) before they are flushed, and reset blank_cells then too. The existing trailing-state tests (rows 0, a continuation row) keep their results.

Not fixed here: the terminal state that leaves the stale continuation flag; the formatter no longer depends on the flags agreeing.

Tests

  • 19ab1cbcb (test only): "Page plain wrap continuation after a row that does not wrap keeps its column" expects "abc\nu" (it gives "abc\n" + 77 spaces + "u" without the fix).
  • 406763ee6: the fix.
  • cmux side: vt_replay_keeps_a_stale_wrap_continuation_row_in_its_column (cmux branch cp/fix-e2-formatter-blank-carry) fails on the current pin (fleet step 480ed523); its green run needs a published GhosttyNextKit for the pin (main only).

Not run: the Zig unit test itself (no Zig test lane on the fleet for this fork; the cmux Rust test exercises the same formatter through libghostty-vt).

Branched from 9ce9b7d08 (cmux feat's pin) so the cmux pointer change is this fix alone; it merges onto main as is.


Summary by cubic

Fixes soft-wrap handling so stale wrap flags no longer shift rows or join rows that no longer share a line, across the unwrapping PageFormatter and scroll operations.

  • With unwrap, trailing blank cells now reset when a line break goes out before the row, so a row keeping wrap_continuation after the prior row stops wrapping is not shifted right (cmux VT fuzz finding E2).
  • Scroll region scrolls, insertLines, and deleteLines with a full-width region now break soft wraps across the region edges, since only one side of each edge moves; insertLines and deleteLines also break the edge at the screen's top row into scrollback.
  • A linefeed or scrollUp at a bottom margin now breaks the cursor row's soft wrap into the row below before the scroll moves it.
  • cursorScrollAbove with a bottom margin and a print wrap at the region bottom keep their own wrap while breaking the stale one below.
  • breakWrapAbove clears both wrap flags of the broken pair and the spacer head a wide character can leave across the wrap.
  • The CI lane now runs the new formatter wrap-continuation and scroll-region wrap-edge tests.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed a terminal display issue where blank rows could appear incorrectly after wrapped text. Blank rows now render with the appropriate line break, including when the following row continues a wrapped line.
    • Improved line wrapping when scrolling, inserting or deleting lines, and reaching screen or scroll-region boundaries, preventing unrelated rows from being incorrectly joined.