Styled runs
One label can carry several styles:
let (text, runs) = TextBuilder::new()
.base(Font::Body)
.text("Save the file as ")
.code("notes.md")
.text(" before you ")
.strong("quit")
.text(".")
.build();
label(text).runs(runs)
TextBuilder returns the plain string and the runs that style it, which is exactly what
label().runs() takes. .runs_from(builder) does both in one call.
Why not several labels in a row
A row of labels looks identical on one line and then goes wrong everywhere else. It wraps at the row’s boundaries rather than between words, so a narrow window breaks the sentence in the wrong place. A drag selects one label, not the sentence. A screen reader announces each fragment as its own element. Text runs keep it one paragraph: one wrap, one selection, one announcement.
The run model
A TextRun is a byte range into the string plus what to do with it:
pub struct TextRun {
pub range: std::ops::Range<usize>,
pub font: FontSpec, // style + weight + italic + monospace + tabular + scale
pub color: Option<Color>,
pub background: Option<Color>,
pub underline: Underline, // None | Single | Double | Dotted | Wavy
pub strikethrough: bool,
pub link: Option<String>,
}
Runs must be ascending, non-overlapping, inside the string, and on character boundaries.
runs_are_valid checks that once in the pieces layer, so no backend has to: overlapping ranges
would produce a different wrong answer per platform, and a range splitting a multi-byte character
would panic on str slicing in some backends and render mojibake in others. Runs that fail the
check are dropped with a warning and the text renders unstyled.
Text and runs travel together, in LabelPatch::Runs(String, Vec<TextRun>), because a range only
means something against a particular string; patching one without the other would break on the
next edit.
FontSpec::monospace asks for the platform’s fixed-pitch face. It rides the ordinary font path,
so it works on a whole label (label("…").monospace()) as well as on a run.
FontSpec::scale is a relative size: it multiplies whatever the semantic style resolves to, so
a run at 1.4 still tracks the reader’s text-size setting. Font::System(pt) is the absolute form
and does not, which is why an editor’s size control moves scale and an imported document’s
font-size: 14px lands in Font::System.
A label’s runs are the character half of StyledText, the document type
.markdown() produces and day-piece-texteditor edits. The same runs render in
both places.
Builder vocabulary
| Method | Run |
|---|---|
.text(s) | unstyled, at the base font |
.strong(s) | bold |
.emphasis(s) | italic |
.code(s) | the fixed-pitch face |
.colored(s, c) | a color |
.underline(s) | underlined |
.highlight(s, c) | a background, with a readable foreground over it |
.sized(s, k) | k times the base size (relative, so it still scales) |
.strikethrough(s) | struck through |
.link(s, url) | drawn as a link (see below) |
.base(font) sets the font the runs vary from; without it, runs sit on the label’s own font.
Per-toolkit
| toolkit | Mechanism | Fixed-pitch face |
|---|---|---|
| AppKit | NSAttributedString on the NSTextField | monospacedSystemFontOfSize:weight: |
| UIKit | NSAttributedString on the UILabel | monospacedSystemFontOfSize:weight:, scaled by UIFontMetrics |
| GTK | Pango markup | font_family="monospace" |
| Qt | Qt rich text | <code>, which Qt maps to its own fixed font |
| Android | SpannableString spans | TypefaceSpan("monospace") |
| ArkUI | ARKUI_NODE_SPAN children | HarmonyOS Sans Mono, monospace |
| XAML | Run inlines in TextBlock.Inlines | Consolas, Courier New, monospace |
| web-dom | <span class="day-run"> children | the ui-monospace stack |
Ranges convert to UTF-16 for the Apple and Android backends, which index text that way; any emoji or CJK in the string makes the two disagree.
What each toolkit cannot draw
| attribute | where it degrades |
|---|---|
Underline::Double | Qt draws a single rule; Android draws a single rule |
Underline::Dotted | GTK draws a single rule (Pango has no dotted); Android draws a single rule |
Underline::Wavy | GTK draws the spell-check squiggle (Underline::Error); Android draws a single rule |
background | everywhere except where noted below; XAML needs a TextHighlighter, since a Run has no background |
scale | GTK takes an absolute Pango size in 1/1024 pt; the relative font_scale/size="N%" attributes are Pango 1.50’s, and a Pango that does not know an attribute fails the whole markup parse and renders the label empty. Qt takes points too: its CSS subset ignores a percentage |
Everything else draws on all eight.
Link activation is Cap::TextLinks, and it is narrower than rendering:
| toolkit | Activation |
|---|---|
| GTK | activate-link on the label |
| Qt | linkActivated on the label |
| UIKit | a text-view delegate (see below) |
| Android | a ClickableSpan + LinkMovementMethod |
| XAML | Hyperlink.Click |
| web-dom | the anchor’s click, with its navigation cancelled |
| AppKit | the native field editor reports to the DayLabel text-field subclass |
| ArkUI | not yet |
Every one of these reports the target to the app rather than opening it itself, so a label’s
.on_link() decides. Its default calls open_link: a leading # navigates to the route after
the hash, and other targets open through Toolkit::open_url. This also applies to links made
with TextBuilder::link; see markdown links for the route contract and
custom-handler behavior. Where activation is missing the run still draws as a link; the tap does
nothing.
Some backend details affect how runs are built.
On macOS the label itself handles the field editor’s link callback. AppKit makes the
NSTextField the shared field editor’s delegate; it does not forward
textView:clickedOnLink:atIndex: to the field’s separate control delegate. DayLabel, an
NSTextField subclass, handles that callback and returns true, suppressing AppKit’s external
URL opening. It reports the original target through Event::LinkActivated, including #route
targets. The subclass is used for plain labels too, so links added by reactive markdown patches
work without replacing the view. Labels with links remain selectable for native hit testing;
removing links restores the app’s explicit selection setting. See Apple’s
Working With the Field Editor
and the native link regression.
GTK renders runs as markup, not as a pango::AttrList, because Pango’s attributes cannot
express a link and the markup dialect can. A GtkLabel’s attribute list overrides the
attributes its markup parsed, so a base weight attribute spanning the label silently defeats a
<b> run. So a label with runs carries no attribute list at all, and its base
font arrives as a wrapping <span>. It also measures from that markup: label.text() is the
markup with its tags stripped, and measuring it would size every run at the base font.
Qt does not resolve a generic monospace family from a style attribute (it rendered
proportional), so the fixed face comes from <code> instead.
A label with a link on iOS is a UITextView. UIKit reserves both selection and link hit
testing for text-input views, so .selectable() rebuilds the label as a read-only text view, and
a label that arrives with a link run is built as one from the start. That swap carries the
attributed text across rather than the plain string. A link that first appears in a later patch
cannot upgrade the backing, since patch has no way to hand back a new handle: seed the text with
its link, or mark the label .selectable().
Markdown
markdown.md covers .markdown(), which parses inline markdown at run time and
produces exactly these runs; it is the convenient way to get them when the text is a translated
string or something a user typed.
What Cap answers
Cap::TextRuns is Native on all eight backends. Cap::TextLinks is Native on seven (AppKit,
GTK, Qt, UIKit, Android, XAML, web-dom) and Unsupported on ArkUI.