// lesson: scrolling-and-damage

Scrolling and Damage

Open a 200,000-line log file in your editor. The window shows 40 lines. Any work proportional to 200,000 โ€” wrapping every line, painting every line, even iterating every line per frame โ€” is work you'll feel as lag. The discipline that keeps editors fast has one name: do O(viewport) work, not O(document) work. This lesson builds its two halves: knowing which rows are on screen (scrolling & virtualization), and knowing which pixels actually changed (damage tracking).

The scroll model

Scrolling is one integer: scroll_y, the document-space pixel row shown at the top of the viewport. The content is row_count * line_height pixels tall; the viewport shows viewport_h of them. Three small functions define the whole system, and each has a classic off-by-one lurking:

  • Clamping. Valid scroll positions are [0, max(0, content_h - viewport_h)] โ€” you can't scroll above the top, and the last line stops at the bottom of the window rather than scrolling up past it. (When content is shorter than the viewport, the only valid position is 0 โ€” that max(0, ...) is the line everyone forgets, and without it short files jitter.)
  • Visibility. The rows intersecting the viewport run from scroll_y / line_height (integer division โ€” a partially visible top row counts) through the row containing the viewport's last pixel: `ceil((scroll_y
    • viewport_h) / line_height)`, capped at the row count. Only rows in this half-open range get wrapped (via the lesson-9 cache) and painted. This range is the virtualization: nothing outside it is ever touched.
  • Reveal. When typing or arrow keys move the caret off screen, the view follows โ€” by the minimum amount. If the caret row's top is above the viewport, align tops; if its bottom is below, align bottoms; if it's visible, don't move at all (gratuitous scrolling on every keystroke is deeply disorienting). Compute bottom-alignment first, then top โ€” in the degenerate case of a viewport shorter than one line, the row's top must win, so the top correction is applied last.

Where does the lesson-4 copy_rect fit? When scroll_y changes by ฮ”, the surviving viewport_h - |ฮ”| pixels are already rendered โ€” blit them to their new position and only the revealed strip needs painting. The strip is damage, which brings us to the second half.

Damage: repaint what changed, nothing else

Between two frames, almost nothing changes: a character appears, the caret moves, a selection extends. The naive editor repaints the window anyway โ€” and at 4K that's 8 million pixels of glyph blitting per keystroke, most redrawing what was already there. The professional pattern is a damage list (the same idea the X server exposes as Expose rectangles, Win32 as the invalid region, Cocoa as setNeedsDisplayInRect:): every state change registers the rectangle it dirtied; at paint time, only pixels inside the accumulated damage get recomputed, and only that region is pushed to the screen.

The interesting design question is what to do when damage rectangles pile up. Keep every rect and you repaint overlapping areas repeatedly and blit dozens of tiny regions; collapse everything to one bounding box and a caret blink plus a status-bar update repaints the whole diagonal between them. The workable middle ground โ€” used, in fancier region-algebra form, by every windowing system โ€” is pairwise coalescing with a no-waste rule: merge two rects when doing so costs nothing, i.e. when they overlap, or when their union's area doesn't exceed the sum of theirs (adjacent same-height rects merge into one clean strip; far-apart specks stay separate). One subtlety your implementation must handle: merging two rects can make the merged result newly mergeable with a third โ€” after each merge, rescan until nothing combines. The list stabilizes fast in practice (an editor's damage is a handful of text bands), and take() โ€” return everything, clear the list โ€” is the paint loop's entire interface.

Half-open rects earn their keep here one more time: "adjacent" is exactly a.x + a.w == b.x, no fudge terms, and the area arithmetic in the merge rule is exact.

โ€บ The Visible Window

12 pts

Implement the scroll math. All heights are pixels; rows is the total visual row count from layout.

  • content_height(rows, lh) โ€” total pixel height.
  • clamp_scroll(scroll_y, viewport_h, lh, rows) โ€” nearest valid scroll position.
  • visible_rows(scroll_y, viewport_h, lh, rows) โ€” the half-open range of row indices intersecting the viewport. scroll_y is already clamped; a non-positive viewport_h or zero rows yields an empty range. Partially visible rows count at both ends.
  • scroll_to_reveal(scroll_y, viewport_h, lh, row) โ€” the minimally adjusted scroll position making row fully visible (top-aligned when the viewport is shorter than one line, per the lesson).

Log in to submit a solution and earn points.

โ€บ The Damage List

15 pts

Implement DamageList with no-waste coalescing. The starter includes the lesson-2 geometry kit, solved.

  • add(r): ignore empty rects. Otherwise merge r with any stored rect it's mergeable with โ€” mergeable means they overlap, or the union's area is no larger than the sum of their areas (compute areas in 64-bit: long long). Merging removes the partner, replaces r with the union, and rescans, because the grown rect may now absorb others. When nothing merges, store r.
  • empty(), count(): bookkeeping for the tests.
  • bounds(): bounding box of everything stored (empty rect when empty) โ€” what you'd hand to a single xcb_put_image.
  • take(): return the list and clear it โ€” called once per paint.

Log in to submit a solution and earn points.