Text and fonts
How a string becomes glyphs on the screen — the two text elements, every text property that works, registering a face, and what re-shapes a paragraph.
Text is the one thing every interface draws. This page covers the two text elements, the CSS properties that reach the text engine, how faces are chosen and registered, and which changes cost a fresh shaping pass. It assumes the guide, in particular Styling and Layout.
text and label
use zgui::prelude::*;
view! {
column(class = "card") {
label(class = "card__title") {"Storage"}
text(class = "card__body") {"Everything written to disk since installation."}
}
}The framework's own style sheet gives the two names one rule between them:
text, label { display: inline; }Nothing else in the framework treats them differently. label exists so that a caption can be
selected, styled and given a role separately from prose. Choose by what the words are.
| Element | For |
|---|---|
text | prose, a value, a message — words the user reads |
label | words that name something else: a field's caption, a control's title, a heading |
label gets no accessibility role from its name. Write a11y:role and a11y:labelled_by yourself;
see Accessibility.
A string literal is already a text node. column {"41 GB"} needs no element around it. Reach for
text or label when you want something a selector can match, a class can carry, or a listener can
attach to.
Which elements share a paragraph
A paragraph is one run of inline content laid out together: one string, one set of glyphs, one set of lines. Where the paragraph boundaries fall is decided by the parent, not by the text element.
- Inside a block container —
box,control,field,editor,scroll— each run of adjacent inline children becomes one paragraph. They flow together and share lines. - Inside a flex container —
row,column,stack— every child becomes a block-level item. Each is its own paragraph.
view! {
box {
text {"Used "} // one paragraph: "Used 41 GB", broken as one
text {"41 GB"}
}
column {
text {"Used"} // two paragraphs, stacked
text {"41 GB"}
}
}This decides where text-align and text-indent apply. Both belong to the block that holds the
text, not to the inline run inside it. Write box(class = "note") with text-align: center on
.note, not on the text element.
From a string to pixels
Five stages turn the characters in the document into pixels on the screen.
| Stage | What it does | What it produces |
|---|---|---|
| Generate | Collects the inline content of one block into a single string, collapses white space, expands preserved tabs, and records where every generated byte came from | one string, one map back to the source |
| Select a face | Walks font-family in author order and matches font-weight, font-style and font-stretch against what each family holds | one face per run |
| Shape | Turns characters into positioned glyphs: applies the face's kerning and OpenType features, instances its variable axes, and reorders bidirectional text | glyphs with advances, and where a line may be cut |
| Break | Cuts those glyphs into lines that fit a given width, then aligns and indents them | line boxes, each with a baseline |
| Rasterise | Turns one glyph at one size into pixels, or into curves | atlas tiles, or filled outlines |
A glyph is one drawn shape in a face. It is not a character: ffi may be one glyph made from
three characters, and one character may be several glyphs. A cluster is the smallest group that
maps between the two, and it is what a caret can sit between.
Shaping is the expensive stage. It reads the face's tables, applies substitutions and positioning, and produces the advance of every cluster. Breaking is the cheap one: it walks glyphs that already exist and decides where the lines fall.
That ratio is why the two are separated. Laying out a flex or grid container asks each paragraph how wide and how tall it is at several candidate widths before it settles. Each of those questions costs a break. A shaped paragraph is held in a cache across frames and re-broken as often as layout asks.
Font fallback
A character the chosen face cannot draw is resolved a second time, on its own, by script. Fallback is per character, not per run: a Latin sentence with one Arabic word in it is drawn with two faces, and so is a sentence with one emoji in it.
Under Fonts::system() the fallback list is the operating system's. Under Fonts::shipped_only()
there is no system list, so the families the application registered are swept instead — bounded by
what it registered.
Rasterising
A glyph reaches the screen down one of two paths, chosen per run.
| Path | When | Cost |
|---|---|---|
| Atlas | size at most 96 device pixels, transform is a pure translation, brush is one colour — or the face has colour glyphs | rasterised once per distinct glyph, then one quad per glyph |
| Outline | anything else: display sizes, rotation, scaling, a gradient brush | filled as curves every frame |
The atlas is the only path that is hinted and can carry per-channel coverage, and it is the path
almost all body text takes. Glyph positions are quantised to quarters of a pixel before caching, so
the same glyph at four sub-pixel phases is four entries rather than an unbounded set
(crates/zgui-text/src/glyph/key.rs).
The properties that work
Every property below reaches the text engine and changes the output.
| Property | What it does |
|---|---|
font-family | a list of families, in author order, ending in a generic: serif, sans-serif, monospace, cursive, fantasy, system-ui |
font-size | the size glyphs are drawn at |
font-weight | 1 to 1000. A weight no face covers is synthesised by thickening the stems |
font-style | normal, italic, oblique <angle> |
font-stretch | matched against the face's own width axis |
font-variation-settings | variable-font axes, by tag |
font-optical-sizing | auto drives the opsz axis from the font size; none leaves it alone |
font-feature-settings | OpenType features, by tag. An author's tag outranks any the properties below imply |
font-kerning | normal, none — the kern feature |
font-variant-ligatures | liga, clig, dlig, hlig, calt |
font-variant-caps | smcp and the rest of the caps features |
font-variant-numeric | tnum, zero, and the other numeric features |
font-variant-position | sups, subs |
font-variant-east-asian | jp04, ruby, and the rest |
line-height | normal is the face's own preferred spacing; a number is a multiple of the font size; a length is itself |
letter-spacing | extra advance after every cluster. A percentage is of the font size |
word-spacing | extra advance on every space. A percentage is of a space's advance in the chosen face |
word-break | normal, break-all, keep-all — which positions are break opportunities |
overflow-wrap | normal, break-word, anywhere — whether an over-long word may be cut |
text-wrap-mode | wrap, nowrap — whether soft wrapping happens at all |
white-space-collapse | collapse, preserve, preserve-breaks, break-spaces |
white-space | the shorthand for the two above: normal, pre, pre-line, pre-wrap, nowrap |
tab-size | how many space advances a preserved tab stands for |
text-align | start, end, left, right, center, justify |
text-indent | the first line's indent, with hanging and each-line |
direction | ltr, rtl — the paragraph's base direction, which decides bidirectional reordering and which edge start means |
color | the text colour |
text-decoration-line | underline, overline, line-through, in any combination |
text-decoration-color | the lines' colour, currentColor by default |
text-decoration-style | solid, double, dotted, dashed, wavy |
text-shadow | offset, blur and colour, drawn behind the glyphs |
vertical-align | how an inline box sits against the line's baseline |
tab-size only reaches a tab that survived. Under the initial white-space-collapse: collapse a
tab is collapsible white space and becomes one space. Set white-space: pre first.
What is accepted and does nothing
These parse, cascade and reach the computed style. Nothing reads the value.
| Property | What to do instead |
|---|---|
text-transform | change the string, or set font-variant-caps: small-caps for small capitals |
text-overflow | see Truncating one line |
text-align-last | nothing; the last line is aligned like the rest |
text-justify | nothing; a justified line is stretched one way |
line-break | nothing; break strictness beside CJK punctuation is the engine's default |
writing-mode | nothing; there is no vertical inline formatting context yet |
font-synthesis-weight | nothing; a weight no face covers is always synthesised |
font-language-override | set the document language instead |
unicode-bidi | set direction on the block |
text-rendering, caret-color, -webkit-text-security | nothing |
text-decoration-thickness, text-underline-offset and text-underline-position are not generated
by this engine build at all: writing one is a dropped declaration. A decoration line is one pixel
thick and nothing changes it. Draw a border on a box when you need another thickness.
The repository's own docs/parity.md under-reports this group. Its harness does not link the paint
stage, so text-decoration-* and text-shadow are listed as unread there while the paint stage
declares and draws them. The table above is written against the code.
White space
The generate stage decides what the shaper is handed, so white space is settled before a single glyph exists.
Value of white-space | Runs of spaces | Source newlines | Soft wrapping |
|---|---|---|---|
normal | become one space | are white space | yes |
pre | survive exactly | force a break | no |
pre-wrap | survive exactly | force a break | yes |
pre-line | become one space | force a break | yes |
nowrap | become one space | are white space | no |
The shorthand sets white-space-collapse and text-wrap-mode together. Write the longhands when
you want one without the other.
Under collapsing, a run of white space at the very start of a paragraph, or straight after a forced break, disappears rather than becoming a space:
" leading" -> "leading"
"trailing " -> "trailing"
"a \n\t b" -> "a b"
" " -> ""The map from generated bytes back to the source is built at the same time, which is what puts a caret or a click at the character the user meant rather than the byte the shaper counted.
Wrapping and line breaking
Lines are broken to fit the width the block gives the paragraph. Three properties change the answer.
.tight { text-wrap-mode: nowrap; } /* only forced breaks end a line */
.cjk { word-break: break-all; } /* a break between any two characters */
.urls { overflow-wrap: break-word; } /* cut a long word only when it would overflow */word-break decides which positions are break opportunities at all, so it is a shaping property.
overflow-wrap decides which of the recorded opportunities may be taken when nothing else fits, so
it is a breaking property. They do different jobs and combine.
A word longer than its line
With overflow-wrap: normal — the initial value — an over-long word is not broken. It stays on
one line and overflows the block. Whether the overflow is visible depends on the block's overflow:
the initial visible lets it paint outside, and hidden clips it.
| Declaration | An unbreakable 400 px word in a 200 px block |
|---|---|
| nothing | one 400 px line, painted outside the block |
overflow-wrap: break-word | cut at 200 px onto two lines |
overflow-wrap: anywhere | the same, and the block's minimum content width drops to one character |
word-break: break-all | cut, and every other word may be cut too |
The difference between break-word and anywhere shows up in sizing. A flex or grid track sized
from content asks the paragraph how narrow it can be; anywhere answers with one character, and
break-word answers with the longest word.
Truncating one line
text-overflow does nothing, so a truncated line is built from wrapping and clipping.
.path {
text-wrap-mode: nowrap;
overflow: hidden;
}The line runs to its full length and the box cuts it at its edge. There is no ellipsis: put one in the string yourself if you want one, or shorten the string before it reaches the view.
Not built yet· An ellipsis drawn by the engine.text-overflow: ellipsis parses today and
changes nothing.
Fonts
One Fonts value holds every face an application draws with. It serves all three text seams — the
metrics the cascade resolves ex and ch against, the shaper, and the rasteriser — so a face
registered once is visible to all of them.
use std::sync::Arc;
use zgui::prelude::*;
fn main() -> Result<(), zgui::Error> {
let fonts = Fonts::shipped_only();
fonts
.register(Arc::new(*include_bytes!("../fonts/Inter-Regular.ttf")), Some("Inter"))
.expect("the shipped face");
app()
.with_fonts(fonts)
.with_stylesheet(SHEET)
.run(|| view! { Article() })
}Fonts is in the prelude. App::with_fonts replaces the collection; App::fonts borrows the one
already there, which is the shorter path when the default is what you want plus one face.
| Method | What it does |
|---|---|
Fonts::system() | the faces installed on this machine, plus whatever is registered. The default |
Fonts::shipped_only() | only the faces registered. Nothing is discovered |
fonts.register(data, family) | adds every face in one font file. family renames them |
fonts.metrics() | what answers the cascade's font-metric questions |
fonts.shaper() | a shaper over these faces |
fonts.raster() | what turns these faces' glyphs into pixels |
data is a FontData, which is Arc<dyn AsRef<[u8]> + Send + Sync>. A font file is between a
hundred kilobytes and several megabytes and is read by three stages, so it is shared rather than
copied. Arc::new(std::fs::read(path)?) and Arc::new(*include_bytes!("…")) both satisfy it.
register returns Err(FontError) when the bytes are not a font this engine can read:
Unrecognised, Malformed, or Empty for a valid file with no faces in it. Resolution is not
fallible in the same way — a family with no face has none.
System faces or shipped faces
The two modes differ in one thing, and it decides whether the result is reproducible.
system() | shipped_only() | |
|---|---|---|
| Where faces come from | the operating system, plus registrations | registrations only |
| Same pixels on two machines | no | yes |
| Generic families | whatever the environment configures | the first registered family fills every generic role nothing else claimed |
| With no registration at all | draws with the machine's faces | draws no text at all |
shipped_only() is what a screenshot test and a reference image want. system() is what a real
application wants unless it ships its own faces on purpose.
@font-face in a style sheet registers nothing. There is no loader wired to it. Fonts::register
is the only way a face enters the collection.
Font-relative units
ex, ch, cap and ic are resolved from the face that the element's own style selects. A face
that declares no such metric falls back rather than reporting zero.
| Unit | Metric | Fallback when the face declares none |
|---|---|---|
ex | x-height | half the font size |
ch | the advance of the digit zero | half the font size |
cap | cap height | the face's ascent |
ic | the advance of the water ideograph | the whole font size |
Registering a face clears the metric memo, so an element cascaded before the registration and one
cascaded after it never disagree about how tall an ex is.
What re-shapes and what does not
A shaped paragraph is cached under a key built from the string, the ordered runs and their shaping styles, the base direction and the device scale. Anything in that key changes the key, and a changed key is a fresh shape.
| Change | Cost |
|---|---|
| the string | reshape |
font-family, font-size, font-weight, font-style, font-stretch | reshape |
any font-variant-*, font-feature-settings, font-variation-settings, font-kerning | reshape |
line-height, letter-spacing, word-spacing | reshape |
word-break, white-space-collapse, direction | reshape |
| the window moving to a display with a different scale factor | reshape |
| the width the paragraph is given | rebreak |
text-align, text-indent, overflow-wrap, text-wrap-mode | rebreak |
vertical-align on something inline | rebreak |
color, background-color, border-*, opacity, transform | neither |
The last row is worth stating outright: a colour change re-shapes nothing. A run's colour is stored as a slot number in a table the shaped result does not own, so switching a theme rewrites a handful of table entries and leaves every shaped paragraph in the application valid.
The classification is derived from the two hashes rather than from a list, so a property cannot be classified one way and hashed the other.
What a width probe costs
Laying out a flex or grid container asks the same paragraph about several widths. Three answers are possible, and two of them are free.
| Answer | When |
|---|---|
| already reflected | the glyphs are currently broken at exactly this request |
| recalled | a previous pass at this width is remembered |
| owed | a real breaking pass, which is counted |
Four passes are remembered per paragraph. Three is enough for the questions a layout algorithm asks in a round — how narrow, how wide, how tall at the given width — and the fourth covers a nested grid. The bound is the point: a window being dragged proposes a new width every frame, and an unbounded memory would grow for as long as the drag lasted, once per paragraph on the page.
The measured cost of one edit in the framework's own kitchen-sink workload — one paragraph reshaped
and one box repainted — is 301.39 µs at the median (kitchen.keystroke, docs/performance.md).
Baselines and vertical metrics
Text sits on a baseline: the line the bottoms of most letters rest on, with descenders hanging below it. Aligning text with anything else means aligning baselines, not boxes.
Each line has a line box, and each line box has a height and a baseline inside it.
The face declares an ascent and a descent; together they are its content area. The difference
between the resolved line-height and that content area is the leading, split equally above and
below. A line-height tighter than the face asks for gives negative leading, which is legitimate
and common.
Every line box is at least as tall as the strut: an invisible zero-width box carrying the block's own font ascent, descent and line height. It is what stops an empty line, or a line holding only a small icon, from collapsing to nothing.
Aligning an icon with text
Two tools, for two different arrangements.
In a row, both children are flex items. Align them on their first baselines:
view! {
row(class = "field") {
vector(class = "field__icon", prop:svg = ICON)
text {"Saved to disk"}
}
}.field { align-items: baseline; gap: 8px; }Inside a block, the icon is inline and vertical-align moves it against the line's baseline:
.badge__icon { vertical-align: middle; }| Value | Where the box goes |
|---|---|
baseline | its own baseline on the line's baseline |
middle | its midpoint on the baseline raised by half the block's x-height |
text-top | its top edge on the top of the block's content area |
text-bottom | its bottom edge on the bottom of that area |
super | raised by 0.34 of the font size |
sub | lowered by 0.20 of the font size |
| a length | raised by that much |
top, bottom | its edge on the line box's own edge |
top and bottom cannot be resolved until every other box on the line has been placed, so they
cost a second breaking pass over the lines that carry one. The other values cost nothing extra.
A text panel
A heading, a paragraph of prose that wraps, and one line that is clipped.
use zgui::prelude::*;
/// A panel of text at three sizes.
#[component]
fn Article() -> impl IntoView {
view! {
column(class = "article") {
label(class = "article__heading") {"Storage"}
text(class = "article__body") {
"Everything this application has written to disk since it was installed, \
including caches it will regenerate and downloads it will not."
}
box(class = "article__path") {
text {"/home/user/.local/share/example/cache/objects/9f2c1a4b"}
}
}
}
}
const SHEET: &str = css!(
":root {
background-color: #0b0d12;
color: #e9edf6;
font-family: system-ui, sans-serif;
}
.article {
gap: 8px;
padding: 20px 24px;
max-width: 380px;
}
.article__heading {
font-size: 20px;
font-weight: 650;
line-height: 1.2;
letter-spacing: -0.2px;
}
.article__body {
font-size: 14px;
line-height: 1.55;
color: #8a93a6;
overflow-wrap: break-word;
}
.article__path {
font-family: monospace;
font-size: 12px;
color: #6c7488;
text-wrap-mode: nowrap;
overflow: hidden;
}"
);What each part is doing:
.articleis a flex column, so each of its three children is its own paragraph..article__headingsetsline-height: 1.2, tighter than the face asks for. The leading is negative and the heading's line box is shorter than its content area..article__bodywraps at 380 px minus the padding.overflow-wrap: break-wordcuts a word that would otherwise overflow, and leaves every other word alone..article__pathrefuses soft breaks and clips at its own edge. It is a block, sooverflowapplies to it.
Resizing the window re-breaks the body and re-breaks nothing else, because the heading and the path already fit. Switching the palette re-shapes nothing at all.
Next
Error handling
How a zgui application fails — Result as a view, ViewError and ErrorBoundary, every zgui::Error variant, what panics, and what fails silently.
Keyboard and focus
Key events and the three readings of a press, assembling text by hand, what is focusable, traversal order, focus rings, programmatic focus and focus traps.