// lesson: cursor-math-and-the-viewport
Cursor Math and the Viewport
Here is a bug every first-time editor author ships: open a file with a tab in it, press the right-arrow key, and watch the cursor jump eight columns while your position bookkeeping says it moved one. The buffer and the screen disagree about geometry, and the disagreement has a name in kilo's source that we'll adopt: cx versus rx.
- cx — the chars index: how many characters into the line's
string the cursor is. This is what
TextBufferoperations take: byte positions instd::strings. - rx — the render index: which screen column the cursor occupies.
This is what
build_frame's cursor-park parameter needs.
For a line of plain ASCII they're equal. One character breaks the equality: Tab (0x09). A tab is one character in the buffer but renders as one to eight columns on screen, because a tab doesn't mean "eight spaces" — it means advance to the next tab stop, the next column that's a multiple of the tab width. Typewriter semantics, faithfully preserved by every terminal since. Given tab stops every 8:
buffer: \t x \t y cx: 0 1 2 3
screen: ········x·······y rx: 0-7 8 9-15 16
^ first tab: 8 cols (cursor was at 0)
^ second tab: 7 cols (was at 9)
The same \t costs 8 columns or 7 columns depending on where it
starts. So there's no per-character lookup table — converting cx to rx
requires replaying the line from column zero:
rx = 0
for each of the first cx characters:
if it's a tab: rx += (tabstop - 1) - (rx % tabstop) # to the stop...
rx += 1 # ...inclusive
The (tabstop - 1) - (rx % tabstop) idiom: how far to the character
before the next stop, and then the shared rx += 1 steps onto the
stop itself. Every editor and terminal implements exactly this rule, so
your rendering agrees with what the terminal does when it prints the
tab.
The inverse — rx to cx — is needed the moment the user clicks a
screen column, or (sooner, for us) presses j and the editor must
figure out which character of the next line sits under the unchanged
screen column. There's no closed form; you replay the line, watching
for the render position to pass the target. Two conversions, one
replay-loop each — pure functions, perfect for headless tests, and
consumed directly by the second half of this lesson.
The viewport
A 100,000-line file, a 40-row window. The editor shows a viewport: a
contiguous band of lines starting at row_off, and (for long lines) a
horizontal band of columns starting at col_off. Rendering row r of
the screen means drawing buffer line row_off + r, sliced to columns
[col_off, col_off + screen_cols) — that slice is exactly what you feed
build_frame, and the cursor parks at screen position (cur_row - row_off, cur_rx - col_off).
The design question is the policy: when does the viewport move? The vi answer — and the one that feels right in every editor since — is: the cursor moves; the viewport follows just enough to keep it visible. Never more. Scrolling by whole pages when the cursor walks off the edge (some 1980s editors did) disorients; scrolling by exactly one line feels like the window is glued to the cursor. The rule, per axis:
- Cursor above the window (
cur_row < row_off): snap the top edge to it —row_off = cur_row. - Cursor below the last visible row (
cur_row >= row_off + screen_rows): snap the bottom edge to it —row_off = cur_row - screen_rows + 1. - Otherwise: don't touch it. (This case matters as much as the other two — a viewport that recenters on every keystroke is unusable.)
The horizontal axis is identical with col_off/cur_rx/screen_cols —
and note it's rx, not cx, that the horizontal comparison uses: the
viewport is a window over screen columns, and cx_to_rx is what turns the cursor's buffer position into the
coordinate this policy clamps. Wire them in the wrong order (clamp on
cx, render with rx) and files with tabs scroll horizontally at the
wrong moment — by an amount that depends on how many tabs are left of
the cursor, a lovely class of bug to debug at 40 columns.
The scroll function runs every frame, between "the keypress moved the
cursor" and "build the frame" — which also makes it the resize handler:
when SIGWINCH delivers a new, smaller screen_rows, the same clamp
walks the viewport back to keep the cursor on screen. One pure
function, two jobs.
› Tab-Stop Arithmetic
15 ptsImplement both conversions:
cx_to_rx(line, cx, tabstop): the render column of character indexcx(0 ≤ cx ≤ line.size(); cx == size means "just past the last char"). Replay the firstcxcharacters with the tab-stop rule.rx_to_cx(line, rx, tabstop): the character index whose render column covers or first exceedsrx— replay the whole line, and return the firstcxwhose running render column goes strictly aboverx; if the line ends first (the target column is past the end of the line), returnline.size(). A consequence the tests pin: any rx inside a tab's span maps to the tab character itself, so a cursor can never land "inside" a tab.
tabstop is always ≥ 1. Non-tab characters are one column each (we
render plain ASCII; the multi-column story for CJK characters and
emoji is a rabbit hole — wcwidth() — that we acknowledge and step
around).
Log in to submit a solution and earn points.
› Scroll to Follow the Cursor
15 ptsImplement scroll, applying the follow-the-cursor policy to both axes
and returning the adjusted viewport. Inputs the tests rely on:
cur_row, cur_rx are the cursor's buffer row and render column;
screen_rows, screen_cols are ≥ 1; all values are non-negative ints;
offsets in the input viewport may be arbitrarily stale (a resize may
have shrunk the screen since they were computed) but never negative.
Log in to submit a solution and earn points.