text.rs
raw
//! Text integration: the font database, paragraphs, and parley layouts →
//! display-list glyph runs.
//!
//! [`Paragraph`] is the unit every text-bearing widget is built from — one
//! run of characters, the styled spans over them, and the single parley layout
//! they shape to. It lives here rather than inside the [`Text`] widget because
//! a widget that renders a *document* has to stack several of them itself, and
//! there is one implementation of "which span is under this point" for both.
//!
//! [`Text`]: crate::widget::Text
mod paragraph;
pub use paragraph::{LinkStyle, Paragraph, RichText, TextSpan};
use guiduck_scene::geom::Point;
use guiduck_scene::paint::{Blob, Brush};
use guiduck_scene::{Fragment, Glyph, GlyphRun};
use parley::fontique::{Collection, CollectionOptions, GenericFamily};
use parley::{FontContext, Layout, LayoutContext, PositionedLayoutItem};
/// What a `:font-family` string means.
///
/// CSS `font-family` semantics, which is what parley's own default
/// (`FontFamily::Source("sans-serif")`) is stated in and what a reader writing
/// the string means: a comma-separated preference list, in which the generic
/// names — `sans-serif`, `monospace`, `serif` — are generic rather than the
/// names of faces somebody happened to install.
///
/// Resolving the string as a bare *face* name instead makes
/// `:font-family "monospace"` a request for a family called "monospace", which
/// nothing has — and the failure is not a clean one. The request does not fall
/// back as a whole; shaping falls back per cluster, so the text arrives in
/// whatever faces happen to answer for each character, which is how a digit
/// ends up drawn by the colour-emoji font while the letters beside it do not.
///
/// This is the one definition, and every text-bearing widget calls it: a
/// paragraph's default, a span, a button's label, a menu row, a text input.
/// A widget spelling it for itself is how they came to disagree before.
pub fn font_family(family: &str) -> parley::FontFamily<'_> {
parley::FontFamily::Source(std::borrow::Cow::Borrowed(family))
}
/// Shared text resources: the font database and parley's reusable layout
/// scratch space. One per UI tree (or per test), not per widget.
pub struct TextContext {
pub font_cx: FontContext,
pub layout_cx: LayoutContext<Brush>,
}
impl TextContext {
/// A text context using the platform's font collection.
pub fn new() -> Self {
Self {
font_cx: FontContext::new(),
layout_cx: LayoutContext::new(),
}
}
/// A text context with the platform's fonts plus the given embedded
/// fonts — the standard setup for applications that ship their own
/// faces but still want system fallback (e.g. CJK coverage the shipped
/// font lacks).
pub fn with_extra_fonts(fonts: impl IntoIterator<Item = Blob<u8>>) -> Self {
let mut context = Self::new();
for font in fonts {
context.font_cx.collection.register_fonts(font, None);
}
context
}
/// A text context that sees only the given fonts and never the system's,
/// so layout and rendering are identical on every machine. Used by golden
/// tests and sample scenes.
pub fn hermetic(fonts: impl IntoIterator<Item = Blob<u8>>) -> Self {
let mut collection = Collection::new(CollectionOptions {
system_fonts: false,
..Default::default()
});
let mut registered = Vec::new();
for font in fonts {
registered.extend(
collection
.register_fonts(font, None)
.into_iter()
.map(|(family, _)| family),
);
}
// Point every generic family at what was actually loaded.
//
// Registering a font makes it findable *by name*; it does not make it
// the answer to "sans-serif", which is what text with no `:font-family`
// asks for. Without this a hermetic context renders no glyphs at all
// while every string-level test still passes — a silent failure that
// looks like the widget's fault and never is. A context holding
// exactly these fonts and no others should answer every generic query
// with them: there is nothing else it could honestly mean.
for generic in [
GenericFamily::SansSerif,
GenericFamily::Serif,
GenericFamily::Monospace,
GenericFamily::Cursive,
GenericFamily::Fantasy,
GenericFamily::SystemUi,
GenericFamily::UiSansSerif,
GenericFamily::UiSerif,
GenericFamily::UiMonospace,
GenericFamily::UiRounded,
GenericFamily::Emoji,
GenericFamily::Math,
GenericFamily::FangSong,
] {
collection.set_generic_families(generic, registered.iter().copied());
}
Self {
font_cx: FontContext {
collection,
source_cache: Default::default(),
},
layout_cx: LayoutContext::new(),
}
}
}
impl Default for TextContext {
fn default() -> Self {
Self::new()
}
}
/// Append every glyph run of a laid-out paragraph to `fragment`, with the
/// layout's top-left corner placed at `origin` in fragment coordinates.
pub fn append_layout(fragment: &mut Fragment, layout: &Layout<Brush>, origin: Point) {
for line in layout.lines() {
for item in line.items() {
let PositionedLayoutItem::GlyphRun(glyph_run) = item else {
// Inline boxes occupy space in the layout but paint nothing
// themselves; their content is painted by whoever owns them.
continue;
};
let run = glyph_run.run();
let glyphs: Vec<Glyph> = glyph_run
.positioned_glyphs()
.map(|g| Glyph {
id: g.id,
x: g.x + origin.x as f32,
y: g.y + origin.y as f32,
})
.collect();
// Underline and strikethrough are the run's *style*, not glyphs:
// parley works out where the line goes from the font's metrics
// and leaves the drawing to us. Painted under the glyphs, so a
// descender crosses the rule rather than being hidden by it.
let baseline = glyph_run.baseline() + origin.y as f32;
let left = glyph_run.offset() + origin.x as f32;
let advance = glyph_run.advance();
let metrics = run.metrics();
let style = glyph_run.style();
for (decoration, default_offset, default_size) in [
(
&style.underline,
metrics.underline_offset,
metrics.underline_size,
),
(
&style.strikethrough,
metrics.strikethrough_offset,
metrics.strikethrough_size,
),
] {
let Some(decoration) = decoration else {
continue;
};
// The offset is measured up from the baseline, so it goes down
// the screen — and it names the *top* of the rule, which then
// extends downward by its thickness (the OpenType meaning of
// `underlinePosition`/`strikeoutPosition`, which is where
// parley reads these from). Taking the thickness off the top as
// well would lift the whole rule by its own height: at ordinary
// UI sizes an underline lands straddling the baseline and looks
// like it is cutting the letters off.
let offset = decoration.offset.unwrap_or(default_offset);
let size = decoration.size.unwrap_or(default_size).max(1.0);
let top = f64::from(baseline - offset);
fragment.fill(
guiduck_scene::geom::Rect::new(
f64::from(left),
top,
f64::from(left + advance),
top + f64::from(size),
),
decoration.brush.clone(),
);
}
if glyphs.is_empty() {
continue;
}
fragment.glyph_run(GlyphRun {
font: run.font().clone(),
size: run.font_size(),
brush: glyph_run.style().brush.clone(),
glyphs,
normalized_coords: run.normalized_coords().to_vec(),
// UI text is hinted. Both backends apply the same rule
// (hinting survives uniform axis-aligned scale; glyph y
// snaps to the pixel grid) with the same skrifa options,
// so the outlines agree and only anti-aliasing differs.
hint: true,
});
}
}
}