3 Carets and Selection
dxtr edited this page 2026-10-18 15:49:23 +02:00

Carets and Selection

A layout answers the questions a text editor asks: where to draw the caret, which index a click lands on, which rectangles a selection covers, and where the arrow keys move the caret. They all work on any layout, horizontal or vertical, in either y-axis convention, with bidirectional text.

The examples use this layout, with font DejaVu Sans at size 16:

(defvar *layout* (tr:layout-text "Hello שלום world" font))

Characters 0 to 5 are "Hello ", 6 to 9 the Hebrew word, and 10 to 15 " world". On the screen the Hebrew reads right to left, so its first letter, character 6, is its rightmost.

Caret stops

A caret sits at a text index, between two characters. It may stop only at grapheme boundaries, the caret stops, so it never splits a letter from its accents or an emoji sequence:

(let ((layout (tr:layout-text (format nil "e~Cx" (code-char #x301)) font)))
  (list (tr:next-caret-index layout 0)       ; over e + combining acute
        (tr:previous-caret-index layout 2)))
;; => (2 0)
  • next-caret-index and previous-caret-index step one grapheme forward or back in logical order, and return the index unchanged at the ends of the text. Use them for Backspace and Delete: delete the text between the caret and previous-caret-index, or next-caret-index.
  • A paragraph separator is a caret stop on both sides, and CR LF is one step.
  • Every function here moves an index inside a grapheme back to the grapheme's start.

Drawing the caret

caret-position returns where the caret at an index goes, as three values: its position along the line and the line's two edges across it. In horizontal text that is x, and the y of the line's top and bottom:

(tr:caret-position *layout* 6)
;; => 81.046875, 0.0, 18.625
(tr:caret-segment *layout* 6)
;; => 81.046875, 0.0, 81.046875, 18.625

caret-segment returns the same caret as a line segment x0 y0 x1 y1, ready to draw. In vertical text the caret is horizontal: caret-position returns its y and the column's left and right x.

Which edge

The caret at index i is drawn at the leading edge of character i: its left edge in left-to-right text, its right edge in right-to-left text, its top in vertical text. At the end of a line it is drawn at the trailing edge of the last character. Here are all seventeen carets of *layout*:

(loop for i to 16 collect (tr:caret-position *layout* i))
;; => (0.0 12.03125 21.875 26.320313 30.765625 40.554688
;;     81.046875 69.71094 60.617188 56.257813
;;     81.046875 86.13281 99.21875 109.00781 115.58594 120.03125 130.1875)

Index 6, before the Hebrew word, is at its right end, 81.05, where reading it starts. Index 10, after it, is at the same place: the leading edge of the space that follows. At a boundary between directions, two indexes can share a position, and some positions belong to no index.

  • Ligatures. Carets inside a ligature are spread evenly across it: in "office", whose ffi is one glyph, the carets at 2 and 3 divide that glyph into three equal parts.
  • Line breaks. At a soft line break, the index after the last character of one line is also the start of the next; the caret is drawn at the start of the next line. line-at-index returns the line an index belongs to.
  • Text left out. An index in text that :max-lines or a line cut short left out has its caret at the end of the line before it, before the ellipsis, and line-at-index returns that line. See Typography.
  • Empty lines have their caret where the alignment puts the start of the line: at the start edge, for :align :start.

Hit testing

hit-test returns the caret index nearest a point, in the layout's coordinates:

(tr:hit-test *layout* 40.0 10.0)
;; => 5

x 40 is in the o of "Hello", past its middle, so the caret goes after it.

  • The line is the one at the point's y, or the first or last line for a point above or below the text.
  • The character is the nearest on that line. A point in the half of a character nearer its leading edge gives the index before it; the other half gives the index after it. Right-to-left characters are mirrored: the right half of a Hebrew letter is before it.
  • Past the end of a wrapped line, a point gives the index before the line's last grapheme, so the caret stays on that line instead of jumping to the start of the next. On a line ended by a newline, it gives the index before the newline. On the last line, it gives the end of the text.

In vertical text hit-test picks the column by x and the character by y.

Selections

selection-rectangles returns the rectangles that cover the text from one index to another, as a list of rects:

(tr:selection-rectangles *layout* 3 9)
;; => (#S(CL-TEXTRENDER:RECT :X 26.320313 :Y 0.0 :WIDTH 19.320313 :HEIGHT 18.625)
;;     #S(CL-TEXTRENDER:RECT :X 56.257813 :Y 0.0 :WIDTH 24.789063 :HEIGHT 18.625))

"lo " and the first three Hebrew letters are logically contiguous, but not on the screen: the selection comes out in two pieces, one per contiguous stretch.

  • Each rectangle spans its line's full height. A selection over several lines has a rectangle (or several) per line.
  • The bounds may come in either order, and equal bounds give ().
  • rect-x, rect-y, rect-width and rect-height read a rectangle. x and y are its smallest coordinates, in the layout's y-axis convention.

Moving the caret

move-caret moves the caret one step with :left, :right, :up or :down, as an editor's arrow keys do, and returns the new index and a goal position:

(loop with i = 0
      for j = (tr:move-caret *layout* i :right)
      until (= i j)
      collect (setf i j))
;; => (1 2 3 4 5 9 8 7 6 10 11 12 13 14 15 16)

Along a line it moves visually. Right moves one grapheme to the right on the screen, so through the Hebrew word it visits 9, 8, 7, 6: the leftmost letter first. At the end of a line it continues onto the next line in reading order (or the previous one, moving against it), entering from the side the caret was moving from. At the ends of the text it stays put.

Across lines it keeps a goal. Up and down go to the caret stop on the adjacent line nearest a goal position along the line. The second value is that goal: pass it back on the next vertical move, so moving through a short line does not lose the column.

(let ((layout (tr:layout-text
               (format nil "A long line~%short~%Another long line") font)))
  (multiple-value-bind (index goal) (tr:move-caret layout 10 :down)
    (list index goal (tr:move-caret layout index :down goal))))
;; => (17 74.67969 27)

From index 10, near the end of the first line, down lands at the end of "short" (17), and down again, with the goal, lands at index 27 in the third line, below where the caret started rather than below the end of "short". Without a goal, move-caret aims for the caret's own position. Moves along a line return the new caret's position as their goal. So an editor keeps the goal from every arrow key, passes it to the next up or down move, and forgets it when the caret moves another way, by typing or clicking.

In vertical text the axes swap: up and down move along a column, and left and right move between columns.

Logical moves use next-caret-index and previous-caret-index instead.

A text field

Recipes puts these together into a text field with typing, Backspace, arrow keys, Shift-selection and clicking.