// lesson: hit-testing-and-the-caret
Hit Testing and the Caret
The user clicks at pixel (312, 148). Which byte of the document did they mean? That question โ hit testing โ and its inverse โ where on screen does document offset 5,231 live? โ are the two coordinate transforms an editor runs constantly: every click, every drag tick, every caret blink, every "scroll until the cursor is visible".
It pays to see the coordinate spaces as a pipeline with one honest converter at each stage:
pixel (x, y)
<-> visual row index (y / line_height, within this lesson's line)
<-> byte offset in row (x vs the row's caret positions)
<-> document offset (row.begin + local offset; lesson 8's index
adds the logical-line base in the full app)
Each converter must be the inverse of its partner, built on the same
metrics. The moment hit testing measures text one way and painting
another, clicks land one character off and the bug report writes itself.
That's why caret_x (offset โ pixel) and index_at_x (pixel โ offset)
in this lesson share one advance table โ and why lesson 5 nagged you to
measure and draw with the same code.
The half-advance rule
Where does a click inside a character land the caret? Not "before the character you hit" โ users click sloppily, aiming at the gap between letters. Every editor since forever uses the half-advance rule: a click in the left half of a glyph puts the caret before it, in the right half, after it. Equivalently: snap to the nearest caret position. The tests pin the boundary exactly: with a 10px-wide glyph spanning xโ[0,10), clicks at xโค4 go before, xโฅ5 after. Clicks left of the row snap to its first offset; clicks beyond its last glyph snap to its end; clicks above the first row snap into it, likewise below the last โ clamping toward sensible text positions rather than rejecting the event is what makes an editor feel solid at its edges.
Caret affinity: one offset, two homes
Wrap "aaa bbb" at width 40 (10px glyphs) and you get rows {0,4} and
{4,7}. Now: where is the caret when its offset is 4? It's the end of
row 0 and the start of row 1 โ the same byte offset, two different
pixels. This ambiguity is called caret affinity, every wrapped-text
editor must resolve it, and both answers are correct at different times:
- Click at the ragged end of row 0 (or press End there): the caret should sit where you clicked โ end of the upper row. Upstream affinity.
- Click at the left edge of row 1 (or press Home, or arrow-right onto it): start of the lower row. Downstream affinity.
So a document position for caret purposes is not a bare size_t; it's an
offset plus an affinity bit, and hit testing is what sets the bit:
clicking in a row that resolves to that row's end โ when another row
continues the line โ means upstream. (Try End vs Home around a wrap point
in any editor; you're watching this bit flip. Only wrap boundaries need
it: at a real '\n', the offsets on either side differ, so there's no
ambiguity to resolve.)
struct DocPos {
size_t offset;
bool upstream; // at a wrap boundary: caret shows at the END of the
// earlier row rather than the start of the later one
};
Up, Down, and the goal column
Vertical caret movement hides a classic piece of state. Put the caret at
column 40, press Down through a short row (10 wide), then Down again into
a long row: the caret should return to column 40, not stick at 10. So
Up/Down do not move to "the same column"; they move to the goal x
โ the pixel x remembered from the last horizontal positioning. Arrow
up/down reads the goal (setting it from the current position only if
unset); clicking or typing clears it. Inside a target row, the goal x
resolves to an offset with the same nearest-caret rule as a mouse click โ
index_at_x again, one function, third customer.
At the document's edges, vertical motion clamps: Up on the first row and Down on the last leave the caret in place (TextEdit-style; some editors jump to line start/end โ either is defensible, ours is the simpler contract, but the goal x must still be recorded so a later Down works).
โบ From Pixels to Positions
18 ptsImplement the three converters over the wrapped rows of one logical line (the final challenge stacks multiple lines; nothing about the logic changes).
caret_x(text, row, advances, offset): pixel x of the caret positionoffsetwithinrowโ the summed advances oftext[row.begin .. offset). Callers guaranteerow.begin <= offset <= row.end.index_at_x(text, row, advances, x): the offset in[row.begin, row.end]whose caret position is nearestx, half-advance rule, ties to the right: a click at exactlyleft + adv/2(integer division) goes after the glyph.x < 0returnsrow.begin;xpast the last glyph returnsrow.end.hit_test(text, rows, advances, line_height, x, y): rows are the wrapped rows of the line, top to bottom,rows[i]covering pixelsy โ [i*line_height, (i+1)*line_height). Clampyinto the row range (negative โ row 0, too large โ last row), resolvexwithin that row, and setupstreamexactly when the resolved offset equals the row'sendand a later row exists.
Characters outside ' '..'~' measure as '?', as in lesson 9.
Log in to submit a solution and earn points.
โบ Up and Down with a Goal
15 ptsImplement row_of โ which visual row displays a given caret? โ and
move_vertical, the Up/Down handler. The starter provides working
caret_x and index_at_x (the lesson's converters) to build on.
row_of(rows, offset, upstream): the index of the row that displays the caret. An offset strictly inside a row is unambiguous. At a shared boundary (offset == rows[i].end == rows[i+1].begin),upstreampicks rowi, otherwise rowi+1. The line-end offset belongs to the last row.move_vertical(text, rows, advances, c, dir)withdirโ1 (up) or +1 (down): resolve the goal x (c.goal_x, or the caret's current pixel x ifgoal_x < 0), step to the adjacent row, resolve the goal within it (nearest caret position, as always), and setupstreamexactly ashit_testwould. At the top/bottom edge the caret stays put โ but the returned caret must carry the resolvedgoal_xeither way.
Log in to submit a solution and earn points.