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

MethodRun
.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

toolkitMechanismFixed-pitch face
AppKitNSAttributedString on the NSTextFieldmonospacedSystemFontOfSize:weight:
UIKitNSAttributedString on the UILabelmonospacedSystemFontOfSize:weight:, scaled by UIFontMetrics
GTKPango markupfont_family="monospace"
QtQt rich text<code>, which Qt maps to its own fixed font
AndroidSpannableString spansTypefaceSpan("monospace")
ArkUIARKUI_NODE_SPAN childrenHarmonyOS Sans Mono, monospace
XAMLRun inlines in TextBlock.InlinesConsolas, Courier New, monospace
web-dom<span class="day-run"> childrenthe 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

attributewhere it degrades
Underline::DoubleQt draws a single rule; Android draws a single rule
Underline::DottedGTK draws a single rule (Pango has no dotted); Android draws a single rule
Underline::WavyGTK draws the spell-check squiggle (Underline::Error); Android draws a single rule
backgroundeverywhere except where noted below; XAML needs a TextHighlighter, since a Run has no background
scaleGTK 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:

toolkitActivation
GTKactivate-link on the label
QtlinkActivated on the label
UIKita text-view delegate (see below)
Androida ClickableSpan + LinkMovementMethod
XAMLHyperlink.Click
web-domthe anchor’s click, with its navigation cancelled
AppKitthe native field editor reports to the DayLabel text-field subclass
ArkUInot 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.