// lesson: layout-word-wrap

Layout: Greedy Word Wrap

A document line and a screen line are different things, and the moment you admit that, half the editor's remaining architecture falls into place. A logical line is what the line index tracks: bytes between newlines, possibly thousands of characters. A visual row is what fits across the window. The mapping between them is the layout engine, and in a text editor its core algorithm is greedy word wrap: pack characters into a row until the next one won't fit, then break β€” preferably at a space.

Everything downstream speaks in the layout's output. Hit testing (lesson 10) turns a click's y into a row index; the caret is drawn at a row + x-offset; scrolling (lesson 12) is measured in rows; damage is a band of rows. So the output type is worth fixing carefully. A row is just a byte range within its line:

struct Row {
    size_t begin;   // byte offsets into the logical line's text
    size_t end;     // half-open, as always
};

No copied text β€” offsets into text owned by the document, the same string_view discipline as everywhere else. Note what a Row doesn't contain: no pixel positions. Given a row, x-positions come from the same metrics used to wrap (caret_xs from lesson 5) β€” computed on demand, never stored to go stale.

The greedy algorithm, precisely

"Greedy" wrapping (as opposed to the global-optimizing Knuth–Plass algorithm TeX uses for paragraphs β€” beautiful, but no editor wants line breaks changing above the cursor as you type) is a single forward scan. The subtleties are worth spelling out, because each is a bug I promise you'd otherwise ship:

  • Track the last break opportunity. As you scan, remember the position just after the most recent space in the current row. On overflow, break there if you have one β€” the space stays at the end of the upper row, where it invisibly "hangs" β€” otherwise you're inside one unbroken word longer than the window: hard-break right where you are, mid-word. (Try narrowing any editor around a long URL: mid-word breaks are correct behavior, not a cop-out.)
  • Overflow means strictly greater. A row that exactly fills max_width is a fit, not an overflow. Get this backwards and every perfectly-full row wraps one character early.
  • Always make progress. If the window is narrower than a single character, each character still gets a row of its own. The overflow check applies only when the row already has content (i > start) β€” otherwise you'd emit empty rows forever. This is the classic infinite-loop bug in wrap code; the tests include the killer case.
  • After a break, re-measure the carry-over. The characters between the break point and your scan position move down to the new row; the new row's running width is their total, and the break-opportunity tracker resets to the last space among them. Then re-test the same character that overflowed β€” it may overflow the new row too.
  • The empty line still exists. An empty logical line produces one empty row {0, 0} β€” it occupies vertical space and the caret can sit on it. Zero rows would make the line invisible and unclickable.

This challenge wraps printable-ASCII text (one byte = one column unit, widths from the metrics table, anything else measuring as '?'); the final challenge generalizes the same algorithm to UTF-8 codepoints using your lesson-6 decoder. Kerning is deliberately ignored during wrapping β€” a break destroys the pair anyway, and real shaping engines measure runs, not pairs, at this stage.

Cache it, invalidate it honestly

Wrapping is O(line length), and a keystroke changes one logical line β€” yet a naive editor re-wraps the whole document per keystroke (and then wonders why a 100k-line file types slowly). The cure is a layout cache: line number β†’ its rows, filled lazily as lines become visible, so cold lines are never wrapped at all.

The hard part of any cache is invalidation, and text edits are a particularly instructive case because line numbers shift. Say lines [first, first + old_count) were edited and became new_count lines:

  • Cached entries in the edited range are stale: drop them (they'll be re-wrapped lazily if ever visible again).
  • Entries below the edit still hold perfectly good rows β€” but they're filed under old line numbers: re-key them by new_count - old_count. Dropping them instead would be correct but would re-wrap the whole visible tail after every Enter keypress β€” the cache would stop earning its rent exactly when files get big.
  • Entries above the edit are untouched.

Two width-related notes that follow from the same honesty: when the window resizes, every row boundary is suspect β€” the whole cache drops (and this is fine: it refills lazily, visible lines first). And the cache key deliberately does not include the width; the owner clears on width change instead. One cache, one invalidation policy, no stale reads.

β€Ί Wrap a Line

18 pts

Implement wrap_line per the algorithm above.

  • advances points at 95 widths for ' '..'~'; characters outside that range measure as '?' does. The input is printable ASCII (plus possibly stray bytes, which just measure as '?').
  • Break opportunities are after each space (' ' only β€” no tabs here).
  • On overflow (running width + next char's width > max_width, and the row is non-empty): break at the last opportunity after the row start if there is one, else before the current character. The space ending an upper row may overhang max_width... no β€” the space was counted when scanned; it can trigger overflow itself and wrap like any character.
  • An empty line yields exactly one row, {0, 0}. All rows are contiguous: each begins where the previous ended, the first at 0, the last ending at line.size().

Log in to submit a solution and earn points.

β€Ί The Layout Cache

12 pts

Implement LayoutCache: rows by line number, with shift-aware invalidation.

  • put(line, rows) stores (replacing any entry); get(line) returns a pointer to the stored rows or nullptr β€” pointer-or-null is the idiom for "maybe a big object" where std::optional would copy.
  • on_lines_edited(first, old_count, new_count): lines [first, first + old_count) were replaced by new_count lines. Drop cached entries in the edited range; re-key entries at >= first + old_count by new_count - old_count; leave entries before first alone.
  • clear() for width changes; cached_count() so the tests can see evictions happen.

Log in to submit a solution and earn points.