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