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,
            });
        }
    }
}