7 Typography
dxtr edited this page 2026-10-18 16:20:59 +02:00

Typography

Tabs, justification, and cutting text short with an ellipsis. In the examples, font is DejaVu Sans at size 16, whose space is 5.09 layout units wide.

Tabs

A tab advances to the next tab stop. :tab-stops sets where they are:

:tab-stops Stops
nil (the default) Every eight spaces of the primary font.
a positive real Every so many layout units.
a list of increasing positive reals At those distances, then every eight spaces of the primary font after the last.

The primary font is the first font of layout-text's font argument. If it has no space glyph, eight spaces count as four ems. A stop that is not a positive real is a type-error, and a list whose stops do not increase a textrender-error (see Errors).

(map 'list (lambda (g) (list (tr:glyph-text-index g) (tr:glyph-x g)))
     (tr:layout-glyphs
      (tr:layout-text (format nil "a~Cb~Cc" #\Tab #\Tab) font
                      :tab-stops '(40 100))))
;; => ((0 0.0) (1 9.8046875) (2 40.0) (3 50.15625) (4 100.0))

The a is at 0, the tab after it at 9.80, and the b at the first stop, 40. The second tab takes the c to 100.

  • Stops are measured from the line's start edge: from the left in a left-to-right paragraph, and from the right in a right-to-left one.
  • A tab right at a stop goes to the next one. Two tabs in a row never share a stop.
  • Tabs count when wrapping, at their expanded width.
  • A tab draws as a space. Its glyph is the font's space glyph, with the advance stretched to the stop, so renderers need no special case.

Justification

:align :justify widens the spaces of a line so that it fills :width exactly:

(tr:layout-text "The quick brown fox jumps over the lazy dog" font
                :width 120 :align :justify)
;; lines "The quick ", "brown fox ", "jumps over " end at x 120.0;
;; the last line, "the lazy dog", keeps its natural width, 98.65
  • Which lines. Every line except a paragraph's last, a line ended by a forced break such as U+2028 LINE SEPARATOR, and a line ending in an ellipsis. Those stay at the paragraph's start edge.
  • Which spaces. Every space separator (Unicode category Zs: the space, no-break space, ideographic space and so on) except trailing ones, each widened by the same amount.
  • No spaces. A line without spaces cannot be justified and stays at the start: the left in left-to-right text, the right in right-to-left text.
  • No width. Without :width there is nothing to fill, and :justify aligns lines at the start.

The widened spaces change glyph-advance of the space glyphs, and caret positions and selections follow them.

Ellipsis

:max-lines keeps at most that many lines. Text that does not fit is left out, and the last line kept ends in :ellipsis, "…" by default:

(let ((layout (tr:layout-text "The quick brown fox jumps over the lazy dog" font
                              :width 120 :max-lines 2)))
  (list (lines-of layout)                ; LINES-OF from Laying Out Text
        (tr:layout-truncated-p layout)
        (map 'list #'tr:line-truncated-p (tr:layout-lines layout))))
;; => (("The quick " "brown fox") T (NIL T))

The second line now reads "brown fox…": its trailing space is gone and an ellipsis run follows "fox".

  • Paragraphs count as lines. (format nil "one~%two~%three") with :max-lines 2 keeps "one" and "two…".
  • The last line keeps its own text. The ellipsis goes after the text the line was broken with, without its trailing whitespace. If that would be wider than :width, the line is cut back a grapheme at a time until the ellipsis fits. A line too short for even one grapheme and the ellipsis keeps just the ellipsis. Without :width, nothing is cut.
  • The logical end. The ellipsis goes at the end of the text in reading order: on the right in left-to-right text, on the left in right-to-left text.
  • Its style and fonts. The ellipsis takes the style of the last character kept, so it has that character's :data (its color, say). Each of its graphemes comes from that style's fallback list, as text does: the last character's font if it has the glyph, or else the first font in the list that has it. CJK text in Droid Sans Fallback, which has no "…", gets its ellipsis from the next font in the list.
  • :ellipsis nil leaves text out without marking it, and any other string, such as "...", replaces the default.
  • Reporting. layout-truncated-p is true when text was left out, and line-truncated-p is true for the line that was cut. Show the full text in a tooltip when a label is truncated.

Cutting lines short

:wrap nil keeps each paragraph on one line, as a cell of a table or a tree does, and cuts a line longer than :width at the last grapheme boundary where it fits with the ellipsis after it. Wrapping with :max-lines 1 cuts at a word instead:

(list (lines-of (tr:layout-text "Node number 12" font :width 100 :wrap nil))
      (lines-of (tr:layout-text "Node number 12" font :width 100 :max-lines 1)))
;; => (("Node num") ("Node")), each drawn with "…" after it
  • Each paragraph is cut on its own. Every paragraph keeps its line, and each that is too long is cut: (format nil "Node number 12~%Second paragraph here") gives "Node num…" and "Second pa…".
  • :ellipsis nil keeps all that fits, here "Node numb".
  • A line cut short covers only the text it shows. The text cut off belongs to no line, and line-truncated-p is true for the line, as layout-truncated-p is for the layout.
  • Without :width, nothing is cut: each paragraph is one line as long as it is.

The cut is found from the advances the paragraph was shaped with, then checked by shaping the line cut there, so a long line costs a few shapings rather than one for every grapheme it loses.

The ellipsis run

The ellipsis is a run of its own, or one run per font when its graphemes come from different fonts, whose run-start equals its run-end: it covers no text. Its glyphs' glyph-text-index is the index where the text was cut, and run-script is nil. The line's height makes room for the ellipsis's fonts.

For hit testing, the ellipsis has no caret stops, and every index in text left out maps to the end of the line before it: line-at-index returns that line, and caret-position the position after the last character it kept, before the ellipsis. move-caret moves a caret there from where it is drawn. A click on the ellipsis gives that index too.