/* This file is part of DarkFi (https://dark.fi) * * Copyright (C) 2020-2026 Dyne.org foundation * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU Affero General Public License as * published by the Free Software Foundation, either version 3 of the * License, or (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU Affero General Public License for more details. * * You should have received a copy of the GNU Affero General Public License * along with this program. If not, see . */ //! GPU text rendering pipeline. //! //! Text is drawn as textured quads on the GPU; no platform text API is //! involved. The pipeline has three layers: //! //! * Layout (`make_layout`): parley shapes a string into glyph runs //! using the font stack (IBM Plex Mono, Noto Color Emoji, DarkIRC //! Emoji) registered in `GLOBAL_FONT_CTX`. Coordinates are physical //! pixels (window scale is baked in by parley); `TextLayout` remembers //! the scale, and consumers divide by it exactly once to get the //! virtual units the renderer expects. Rasterization still happens at //! physical resolution so text stays crisp while the renderer //! re-applies the scale via `SetScale`. //! //! * Rendering (`render`): two passes over a layout. First every glyph //! of every run is rasterized with swash and packed into an `Atlas`: //! one RGBA8 texture per call, color glyphs stored as RGBA and mask //! glyphs as alpha. Then each run becomes a `DrawMesh` of quads whose //! UVs reference that texture, so a whole run draws in one call. This //! atlas is transient (one per layout): the right trade-off for //! arbitrary dynamic text such as chat messages and labels, where //! layouts are short-lived and each draws from its own texture. //! //! * Batching for fixed sets (`string_atlas`): the opposite trade-off. //! `make_string_atlas` lays out many fixed strings up front and packs //! all their glyphs into a single shared atlas: one texture and one //! raster per glyph for the entire set, returning per-string quad //! geometry to the caller. Used by the emoji picker, where rendering //! each icon through the dynamic path allocated one texture plus a //! vertex/index buffer pair per emoji (~574 icons, ~2s of generation) //! for glyphs that only ever needed to reference a shared sheet. //! //! The packer itself lives in `atlas`: sprites are separated by a 2px //! gap on all sides to prevent UV bleed, and can optionally wrap into //! rows capped at `MAX_TEXTURE_DIMENSION` so large fixed sets stay //! within GPU texture size limits (GLES3/WebGL2 only guarantee 2048), //! failing loudly at build time instead of corrupting at draw time. use parley::fontique::{Collection, CollectionOptions, SourceCache, SourceCacheOptions}; use std::{ cell::RefCell, ops::Range, sync::{Arc, LazyLock}, }; use crate::mesh::Color; pub mod atlas; mod editor; pub use editor::Editor; mod render; #[cfg(not(target_os = "android"))] pub use render::render_raw_layout; pub use render::{ render_backgrounds, render_layout, render_layout_shadowed, render_layout_with_opts, DebugRenderOptions, Shadow, }; mod string_atlas; pub use string_atlas::make_string_atlas; pub static GLOBAL_FONT_CTX: LazyLock = LazyLock::new(|| { let mut font_ctx = parley::FontContext { collection: Collection::new(CollectionOptions { shared: true, system_fonts: true }), source_cache: SourceCache::new(SourceCacheOptions { shared: true }), }; let font_data = include_bytes!("../../data/font/ibm-plex-mono-regular.otf") as &[u8]; font_ctx.collection.register_fonts(peniko::Blob::new(Arc::new(font_data)), None); let font_data = include_bytes!("../../data/font/NotoColorEmoji.ttf") as &[u8]; font_ctx.collection.register_fonts(peniko::Blob::new(Arc::new(font_data)), None); let font_data = include_bytes!("../../data/font/darkfi-custom-emoji.ttf") as &[u8]; font_ctx.collection.register_fonts(peniko::Blob::new(Arc::new(font_data)), None); font_ctx }); thread_local! { pub static THREAD_LAYOUT_CTX: RefCell> = RefCell::new(parley::LayoutContext::new()); } const FONT_STACK: &[parley::FontFamilyName<'_>] = &[ parley::FontFamilyName::named("IBM Plex Mono"), parley::FontFamilyName::named("Noto Color Emoji"), parley::FontFamilyName::named("DarkIRC Emoji"), ]; /// A parley layout paired with the window scale it was built with. /// /// Parley bakes the builder scale into every coordinate (font size, /// advances, glyph positions), so the underlying layout is in physical /// pixels. The renderer applies the window scale again via `SetScale`, /// so geometry consumed in virtual units must be divided by the scale /// exactly once. The accessors below do that division. Rendering code /// in `render.rs` divides when emitting meshes. Glyph rasterization /// still happens at physical resolution so text stays crisp. #[derive(Clone)] pub struct TextLayout { layout: parley::Layout, /// Scale parley baked into `layout`. Divide physical coords by this /// to get virtual units. scale: f32, } impl std::ops::Deref for TextLayout { type Target = parley::Layout; fn deref(&self) -> &Self::Target { &self.layout } } impl std::ops::DerefMut for TextLayout { fn deref_mut(&mut self) -> &mut Self::Target { &mut self.layout } } impl Default for TextLayout { fn default() -> Self { Self { layout: parley::Layout::default(), scale: 1. } } } impl TextLayout { pub fn scale(&self) -> f32 { self.scale } /// Height in virtual units pub fn height(&self) -> f32 { self.layout.height() / self.scale } /// Width in virtual units pub fn width(&self) -> f32 { self.layout.width() / self.scale } } pub fn make_layout( text: &str, text_color: Color, font_size: f32, lineheight: f32, window_scale: f32, width: Option, underlines: &[Range], ) -> TextLayout { make_layout2( text, text_color, font_size, lineheight, window_scale, width, underlines, &[], parley::Alignment::Start, parley::OverflowWrap::Normal, ) } pub fn make_layout2( text: &str, text_color: Color, font_size: f32, lineheight: f32, window_scale: f32, width: Option, underlines: &[Range], foreground_colors: &[(Range, Color)], text_align: parley::Alignment, overflow_wrap: parley::OverflowWrap, ) -> TextLayout { make_layout_with_indent( text, text_color, font_size, lineheight, window_scale, width, underlines, foreground_colors, text_align, overflow_wrap, 0., ) } /// Reserve space before the first line without changing text or style ranges. pub fn make_layout_with_indent( text: &str, text_color: Color, font_size: f32, lineheight: f32, window_scale: f32, width: Option, underlines: &[Range], foreground_colors: &[(Range, Color)], text_align: parley::Alignment, overflow_wrap: parley::OverflowWrap, first_line_indent: f32, ) -> TextLayout { THREAD_LAYOUT_CTX.with(|layout_ctx| { let mut layout_ctx = layout_ctx.borrow_mut(); let mut font_ctx = GLOBAL_FONT_CTX.clone(); let mut builder = layout_ctx.ranged_builder(&mut font_ctx, text, window_scale, false); builder.push_default(parley::LineHeight::FontSizeRelative(lineheight)); builder.push_default(parley::StyleProperty::FontSize(font_size)); builder.push_default(parley::StyleProperty::from(FONT_STACK)); builder.push_default(parley::StyleProperty::Brush(text_color)); builder.push_default(parley::StyleProperty::OverflowWrap(overflow_wrap)); for underline in underlines { builder.push(parley::StyleProperty::Underline(true), underline.clone()); } for (range, color) in foreground_colors { builder.push(parley::StyleProperty::Brush(*color), range.clone()); } let mut layout: parley::Layout = builder.build(text); // The wrap width is given in virtual units while the layout // coordinates are physical, so scale it up before breaking. let width = width.map(|w| w * window_scale); if first_line_indent > 0. { let width = width.unwrap_or(f32::MAX); let indent = first_line_indent * window_scale; let mut breaker = layout.break_lines(); breaker.state_mut().set_layout_max_advance(width); breaker.state_mut().set_line_max_advance((width - indent).max(0.)); breaker.state_mut().set_line_x(indent); breaker.break_next(); breaker.state_mut().set_line_x(0.); breaker.break_remaining(width); } else { layout.break_all_lines(width); } layout.align(text_align, parley::AlignmentOptions::default()); TextLayout { layout, scale: window_scale } }) } #[cfg(test)] mod tests { use super::*; #[test] fn first_line_indent_uses_full_width_on_continuations() { for scale in [1., 1.5, 2.] { let text = "word ".repeat(30); let layout = make_layout_with_indent( &text, [1.; 4], 14., 20. / 14., scale, Some(200.), &[], &[], parley::Alignment::Start, parley::OverflowWrap::Normal, 50., ); assert!(layout.lines().count() > 2); for (i, line) in layout.lines().enumerate() { let run = line .items() .find_map(|item| match item { parley::PositionedLayoutItem::GlyphRun(run) => Some(run), _ => None, }) .unwrap(); let expected = if i == 0 { 50. } else { 0. }; assert!((run.offset() / scale - expected).abs() < 0.01); } let old_layout = make_layout(&text, [1.; 4], 14., 20. / 14., scale, Some(150.), &[]); assert!(layout.height() < old_layout.height()); for line in old_layout.lines() { assert_eq!(line.metrics().offset, 0.); } } } #[test] fn first_line_indent_preserves_empty_and_overflow_metrics() { for text in ["", "\n", "first\nsecond", "averylongunbrokenword"] { for width in [0., 30., 80., 200.] { let layout = make_layout_with_indent( text, [1.; 4], 14., 20. / 14., 1., Some(width), &[], &[], parley::Alignment::Start, parley::OverflowWrap::Normal, 50., ); let expected_lines = if text.contains('\n') { 2 } else { 1 }; assert_eq!(layout.lines().count(), expected_lines, "{text:?}, width={width}"); assert!((layout.height() - 20. * expected_lines as f32).abs() < 0.01); for (i, line) in layout.lines().enumerate() { assert_eq!(line.metrics().offset, if i == 0 { 50. } else { 0. }); } } } } }