smudge_text/glyphs.rs
1//! Conversion of a laid out text block into the glyph runs paint operations
2//! carry.
3//!
4//! The conversion turns parley's positioned glyphs into the library's own
5//! [`GlyphRun`], which carries the font as plain bytes so that paint
6//! operations never depend on parley. Each run's ink bounds are measured with
7//! skrifa and cover every glyph's outline plus one logical pixel on every
8//! side for anti-aliasing, which is the area renderers may rely on for damage.
9
10use std::cell::RefCell;
11use std::collections::HashMap;
12
13use parley::FontData;
14use parley::PositionedLayoutItem;
15use skrifa::GlyphId;
16use skrifa::MetadataProvider;
17use skrifa::instance::{LocationRef, Size};
18use smudge_core::geometry::{Color, Point, Rect};
19use smudge_core::paint::{FontBlob, GlyphRun, PositionedGlyph};
20
21use crate::layout::Cut;
22use crate::layout::Ellipsis;
23use crate::layout::TextLayout;
24use crate::layout::Truncation;
25
26thread_local! {
27 /// The font blobs already mapped out of parley's font data, keyed by the
28 /// blob identifier and face index. One entry per font keeps every run of a
29 /// layout sharing the bytes, and keeps the identifier stable so renderers
30 /// can cache glyphs under it.
31 ///
32 /// Entries hold the font bytes alive for the life of the thread. Fonts are
33 /// few and outlive layouts in practice, so nothing collects them.
34 static FONT_BLOBS: RefCell<HashMap<(u64, u32), FontBlob>> = RefCell::new(HashMap::new());
35}
36
37impl TextLayout {
38 /// Converts the laid out text into glyph runs at `origin`, in paint order.
39 ///
40 /// `origin` is where the top left corner of the layout box goes, in the
41 /// caller's paint coordinates, which leave out the scroll offsets of
42 /// enclosing views, so the first line's baseline lies one ascent below
43 /// it. The layout itself carries no color, so `color` is the
44 /// color every returned run paints with, resolved by the caller at paint
45 /// time. Each run carries the font as bytes, the size its style used, the
46 /// given color, and glyphs positioned in those same coordinates. A run
47 /// whose text is empty produces no runs.
48 ///
49 /// # Panics
50 ///
51 /// Panics under the conditions of [`crate::TextSystem::with`], in
52 /// particular when called from inside another `with` call on the same
53 /// thread.
54 pub fn glyph_runs_at(&self, origin: Point, color: Color) -> Vec<GlyphRun> {
55 glyph_runs_with(&self.layout, origin, color, self.truncation.as_ref())
56 }
57}
58
59/// Converts the glyph runs of any laid out text into paint operations' runs.
60///
61/// The conversion is shared with the text input, which paints a parley
62/// [`Layout`](parley::Layout) it drives through the editor rather than a
63/// [`TextLayout`] it laid out itself, and truncates nothing.
64pub(crate) fn glyph_runs<B: parley::Brush>(
65 layout: &parley::Layout<B>,
66 origin: Point,
67 color: Color,
68) -> Vec<GlyphRun> {
69 glyph_runs_with(layout, origin, color, None)
70}
71
72/// Converts the glyph runs of any laid out text into paint operations' runs,
73/// cut to `truncation`.
74///
75/// `truncation` limits the conversion to a truncation's kept lines and cuts
76/// the last of them: the runs before the cut survive whole, the cut run
77/// keeps its leading glyphs, and the ellipsis follows them in a run of its
78/// own, since its font can differ from the cut run's.
79pub(crate) fn glyph_runs_with<B: parley::Brush>(
80 layout: &parley::Layout<B>,
81 origin: Point,
82 color: Color,
83 truncation: Option<&Truncation>,
84) -> Vec<GlyphRun> {
85 let kept_lines = truncation.map_or(usize::MAX, |truncation| truncation.kept_lines);
86 let mut runs = Vec::new();
87 for (line_index, line) in layout.lines().enumerate().take(kept_lines) {
88 let cut = match truncation {
89 Some(truncation) if line_index + 1 == truncation.kept_lines => truncation.cut.as_ref(),
90 _ => None,
91 };
92 let mut run_index = 0usize;
93 for item in line.items() {
94 let PositionedLayoutItem::GlyphRun(run) = item else {
95 continue;
96 };
97 let kept = match cut {
98 Some(cut) if run_index > cut.run => break,
99 Some(cut) if run_index == cut.run => cut.glyphs,
100 _ => usize::MAX,
101 };
102 run_index += 1;
103 if kept == 0 {
104 continue;
105 }
106 let glyphs = run
107 .positioned_glyphs()
108 .take(kept)
109 .map(|glyph| (glyph.id, origin.x + glyph.x, origin.y + glyph.y));
110 runs.push(paint_run(
111 run.run().font(),
112 run.run().font_size(),
113 glyphs,
114 color,
115 ));
116 }
117 if let Some(Cut {
118 x,
119 baseline,
120 ellipsis: Some(ellipsis),
121 ..
122 }) = cut
123 {
124 let y = origin.y + baseline;
125 let dots = (0..ellipsis.dots).map(|dot| {
126 let dot_x = origin.x + x + dot as f32 * ellipsis.advance;
127 (ellipsis.glyph, dot_x, y)
128 });
129 runs.push(paint_run(&ellipsis.font, ellipsis.size, dots, color));
130 }
131 }
132 runs
133}
134
135/// Builds one [`GlyphRun`] out of the glyph identifiers and absolute pen
136/// positions `glyphs` yields, painted with `font` at `size`.
137///
138/// Every glyph's ink is measured with skrifa against that font and size.
139fn paint_run(
140 font: &FontData,
141 size: f32,
142 glyphs: impl Iterator<Item = (u32, f32, f32)>,
143 color: Color,
144) -> GlyphRun {
145 // skrifa reads the font once per run, not once per glyph.
146 let metrics = skrifa::FontRef::from_index(font.data.as_ref(), font.index)
147 .ok()
148 .map(|font| font.glyph_metrics(Size::new(size), LocationRef::default()));
149 let mut painted = Vec::new();
150 let mut ink = Rect::default();
151 for (id, x, y) in glyphs {
152 if let Some(bounds) = metrics
153 .as_ref()
154 .and_then(|metrics| metrics.bounds(GlyphId::new(id)))
155 {
156 // Font coordinates grow up, window coordinates down.
157 ink = ink.union(Rect::new(
158 x + bounds.x_min,
159 y - bounds.y_max,
160 x + bounds.x_max,
161 y - bounds.y_min,
162 ));
163 }
164 painted.push(PositionedGlyph { id, x, y });
165 }
166 GlyphRun {
167 font: font_blob(font),
168 size,
169 color,
170 glyphs: painted,
171 ink_bounds: expand(ink, 1.0),
172 }
173}
174
175/// Resolves the ellipsis glyph truncation appends, measured with the font
176/// and size of the run it ends.
177///
178/// The horizontal ellipsis stands in for the cut text, or three full stops
179/// when the font maps no ellipsis. Returns `None` when the font maps
180/// neither, so truncation looks for the ellipsis elsewhere.
181pub(crate) fn ellipsis_glyph(font: &FontData, font_size: f32) -> Option<Ellipsis> {
182 let face = skrifa::FontRef::from_index(font.data.as_ref(), font.index).ok()?;
183 let charmap = face.charmap();
184 let (glyph, dots) = match charmap.map('\u{2026}') {
185 Some(id) => (id.to_u32(), 1),
186 None => (charmap.map('.')?.to_u32(), 3),
187 };
188 let metrics = face.glyph_metrics(Size::new(font_size), LocationRef::default());
189 let advance = metrics.advance_width(GlyphId::new(glyph)).unwrap_or(0.0);
190 Some(Ellipsis {
191 font: font.clone(),
192 size: font_size,
193 glyph,
194 dots,
195 advance,
196 })
197}
198
199/// Returns the [`FontBlob`] for parley's font data, reusing the blob the font
200/// collection already holds.
201fn font_blob(font: &FontData) -> FontBlob {
202 FONT_BLOBS.with(|cache| {
203 cache
204 .borrow_mut()
205 .entry((font.data.id(), font.index))
206 .or_insert_with(|| {
207 let (data, id) = font.data.clone().into_raw_parts();
208 FontBlob {
209 id,
210 data,
211 index: font.index,
212 }
213 })
214 .clone()
215 })
216}
217
218/// Grows a non-empty rectangle by `by` pixels on every side.
219fn expand(rect: Rect, by: f32) -> Rect {
220 if rect.is_empty() {
221 rect
222 } else {
223 Rect::new(rect.x0 - by, rect.y0 - by, rect.x1 + by, rect.y1 + by)
224 }
225}
226
227#[cfg(test)]
228#[path = "glyphs_tests.rs"]
229mod tests;