zgui

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.

ElementFor
textprose, a value, a message — words the user reads
labelwords 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.

StageWhat it doesWhat it produces
GenerateCollects the inline content of one block into a single string, collapses white space, expands preserved tabs, and records where every generated byte came fromone string, one map back to the source
Select a faceWalks font-family in author order and matches font-weight, font-style and font-stretch against what each family holdsone face per run
ShapeTurns characters into positioned glyphs: applies the face's kerning and OpenType features, instances its variable axes, and reorders bidirectional textglyphs with advances, and where a line may be cut
BreakCuts those glyphs into lines that fit a given width, then aligns and indents themline boxes, each with a baseline
RasteriseTurns one glyph at one size into pixels, or into curvesatlas 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.

PathWhenCost
Atlassize at most 96 device pixels, transform is a pure translation, brush is one colour — or the face has colour glyphsrasterised once per distinct glyph, then one quad per glyph
Outlineanything else: display sizes, rotation, scaling, a gradient brushfilled 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.

PropertyWhat it does
font-familya list of families, in author order, ending in a generic: serif, sans-serif, monospace, cursive, fantasy, system-ui
font-sizethe size glyphs are drawn at
font-weight1 to 1000. A weight no face covers is synthesised by thickening the stems
font-stylenormal, italic, oblique <angle>
font-stretchmatched against the face's own width axis
font-variation-settingsvariable-font axes, by tag
font-optical-sizingauto drives the opsz axis from the font size; none leaves it alone
font-feature-settingsOpenType features, by tag. An author's tag outranks any the properties below imply
font-kerningnormal, none — the kern feature
font-variant-ligaturesliga, clig, dlig, hlig, calt
font-variant-capssmcp and the rest of the caps features
font-variant-numerictnum, zero, and the other numeric features
font-variant-positionsups, subs
font-variant-east-asianjp04, ruby, and the rest
line-heightnormal is the face's own preferred spacing; a number is a multiple of the font size; a length is itself
letter-spacingextra advance after every cluster. A percentage is of the font size
word-spacingextra advance on every space. A percentage is of a space's advance in the chosen face
word-breaknormal, break-all, keep-all — which positions are break opportunities
overflow-wrapnormal, break-word, anywhere — whether an over-long word may be cut
text-wrap-modewrap, nowrap — whether soft wrapping happens at all
white-space-collapsecollapse, preserve, preserve-breaks, break-spaces
white-spacethe shorthand for the two above: normal, pre, pre-line, pre-wrap, nowrap
tab-sizehow many space advances a preserved tab stands for
text-alignstart, end, left, right, center, justify
text-indentthe first line's indent, with hanging and each-line
directionltr, rtl — the paragraph's base direction, which decides bidirectional reordering and which edge start means
colorthe text colour
text-decoration-lineunderline, overline, line-through, in any combination
text-decoration-colorthe lines' colour, currentColor by default
text-decoration-stylesolid, double, dotted, dashed, wavy
text-shadowoffset, blur and colour, drawn behind the glyphs
vertical-alignhow 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.

PropertyWhat to do instead
text-transformchange the string, or set font-variant-caps: small-caps for small capitals
text-overflowsee Truncating one line
text-align-lastnothing; the last line is aligned like the rest
text-justifynothing; a justified line is stretched one way
line-breaknothing; break strictness beside CJK punctuation is the engine's default
writing-modenothing; there is no vertical inline formatting context yet
font-synthesis-weightnothing; a weight no face covers is always synthesised
font-language-overrideset the document language instead
unicode-bidiset direction on the block
text-rendering, caret-color, -webkit-text-securitynothing

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-spaceRuns of spacesSource newlinesSoft wrapping
normalbecome one spaceare white spaceyes
presurvive exactlyforce a breakno
pre-wrapsurvive exactlyforce a breakyes
pre-linebecome one spaceforce a breakyes
nowrapbecome one spaceare white spaceno

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.

DeclarationAn unbreakable 400 px word in a 200 px block
nothingone 400 px line, painted outside the block
overflow-wrap: break-wordcut at 200 px onto two lines
overflow-wrap: anywherethe same, and the block's minimum content width drops to one character
word-break: break-allcut, 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.

MethodWhat 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 fromthe operating system, plus registrationsregistrations only
Same pixels on two machinesnoyes
Generic familieswhatever the environment configuresthe first registered family fills every generic role nothing else claimed
With no registration at alldraws with the machine's facesdraws 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.

UnitMetricFallback when the face declares none
exx-heighthalf the font size
chthe advance of the digit zerohalf the font size
capcap heightthe face's ascent
icthe advance of the water ideographthe 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.

ChangeCost
the stringreshape
font-family, font-size, font-weight, font-style, font-stretchreshape
any font-variant-*, font-feature-settings, font-variation-settings, font-kerningreshape
line-height, letter-spacing, word-spacingreshape
word-break, white-space-collapse, directionreshape
the window moving to a display with a different scale factorreshape
the width the paragraph is givenrebreak
text-align, text-indent, overflow-wrap, text-wrap-moderebreak
vertical-align on something inlinerebreak
color, background-color, border-*, opacity, transformneither

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.

AnswerWhen
already reflectedthe glyphs are currently broken at exactly this request
recalleda previous pass at this width is remembered
oweda 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.

Half the leading
Line box top
Hello, typography
Face ascent
Baseline
Face descent
Half the leading
Line box bottom
How a line box is divided around its baseline

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; }
ValueWhere the box goes
baselineits own baseline on the line's baseline
middleits midpoint on the baseline raised by half the block's x-height
text-topits top edge on the top of the block's content area
text-bottomits bottom edge on the bottom of that area
superraised by 0.34 of the font size
sublowered by 0.20 of the font size
a lengthraised by that much
top, bottomits 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:

  • .article is a flex column, so each of its three children is its own paragraph.
  • .article__heading sets line-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__body wraps at 380 px minus the padding. overflow-wrap: break-word cuts a word that would otherwise overflow, and leaves every other word alone.
  • .article__path refuses soft breaks and clips at its own edge. It is a block, so overflow applies 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

On this page