// 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 pts

Implement 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 position offset within row โ€” the summed advances of text[row.begin .. offset). Callers guarantee row.begin <= offset <= row.end.
  • index_at_x(text, row, advances, x): the offset in [row.begin, row.end] whose caret position is nearest x, half-advance rule, ties to the right: a click at exactly left + adv/2 (integer division) goes after the glyph. x < 0 returns row.begin; x past the last glyph returns row.end.
  • hit_test(text, rows, advances, line_height, x, y): rows are the wrapped rows of the line, top to bottom, rows[i] covering pixels y โˆˆ [i*line_height, (i+1)*line_height). Clamp y into the row range (negative โ†’ row 0, too large โ†’ last row), resolve x within that row, and set upstream exactly when the resolved offset equals the row's end and 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 pts

Implement 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), upstream picks row i, otherwise row i+1. The line-end offset belongs to the last row.
  • move_vertical(text, rows, advances, c, dir) with dir โˆ’1 (up) or +1 (down): resolve the goal x (c.goal_x, or the caret's current pixel x if goal_x < 0), step to the adjacent row, resolve the goal within it (nearest caret position, as always), and set upstream exactly as hit_test would. At the top/bottom edge the caret stays put โ€” but the returned caret must carry the resolved goal_x either way.

Log in to submit a solution and earn points.