Skip to main content

frust_text/
lib.rs

1//! Parley 0.11 text pipeline for Frust.
2//!
3//! Wraps parley's font matching and layout into a small, renderer-agnostic
4//! surface: [`TextContext`] owns the heavyweight font/layout state,
5//! [`TextStyle`] carries the styling knobs (family/weight/style/size/color/
6//! letter-spacing/line-height — the M3 type-scale surface), and [`TextLayout`]
7//! is a finished, measurable block of text that converts into
8//! [`frust_scene::GlyphRun`]s. Only `kurbo`/`peniko`/`frust-scene` types
9//! appear in the public API — no vello, wgpu, or parley types leak through.
10//! The parley -> vello glyph coordinate contract lives in
11//! [`convert`].
12
13mod context;
14mod convert;
15mod editor;
16mod layout;
17mod shape_cache;
18mod style;
19
20pub use context::{FontError, RegisteredFamily, TextContext, register_generic_fallback};
21pub use editor::{
22    EditOp, EditingState, EditingStateBytes, TextEditor, byte_to_utf16, sanitize_paste,
23    utf16_to_byte,
24};
25pub use layout::TextLayout;
26pub use shape_cache::ShapeCacheStats;
27pub use style::{
28    FamilyName, FontFamily, FontStyle, FontWeight, GenericSlot, LineHeight, TextAlign,
29    TextOverflow, TextStyle,
30};
31
32#[cfg(test)]
33mod tests {
34    use super::*;
35    use frust_scene::GlyphRun;
36    use kurbo::Point;
37    use peniko::Color;
38
39    fn style(size: f32) -> TextStyle {
40        TextStyle::new(size, Color::BLACK)
41    }
42
43    /// Assert that within each run the glyph x positions are non-decreasing.
44    fn assert_monotonic_x(run: &GlyphRun) {
45        let mut prev = f32::NEG_INFINITY;
46        for glyph in &run.glyphs {
47            assert!(
48                glyph.x >= prev,
49                "glyph x positions must be non-decreasing within a run: {} < {prev}",
50                glyph.x
51            );
52            prev = glyph.x;
53        }
54    }
55
56    #[test]
57    fn single_line_has_glyphs_with_monotonic_x() {
58        let mut cx = TextContext::new();
59        let layout = cx.layout("Hello from Frust", &style(32.0), None);
60
61        let size = layout.size();
62        assert!(
63            size.width > 0.0 && size.height > 0.0,
64            "expected non-zero size, got {size:?} — is a system font available? \
65             (parley GenericFamily::SystemUi failed to resolve)"
66        );
67
68        let runs = layout.to_scene_runs(Point::ORIGIN);
69        assert!(
70            !runs.is_empty(),
71            "expected at least one glyph run — no system font resolved?"
72        );
73
74        let total_glyphs: usize = runs.iter().map(|r| r.glyphs.len()).sum();
75        assert!(
76            total_glyphs > 0,
77            "expected a positive glyph count for non-empty text"
78        );
79
80        for run in &runs {
81            assert_eq!(run.font_size, 32.0);
82            assert_monotonic_x(run);
83        }
84    }
85
86    #[test]
87    fn constrained_width_produces_multiple_lines_with_distinct_baselines() {
88        let mut cx = TextContext::new();
89        // A width narrow enough to force wrapping of this multi-word string.
90        let layout = cx.layout(
91            "Hello from Frust, the pure Rust mobile UI toolkit",
92            &style(24.0),
93            Some(80.0),
94        );
95
96        let runs = layout.to_scene_runs(Point::ORIGIN);
97        assert!(
98            !runs.is_empty(),
99            "expected glyph runs — no system font resolved?"
100        );
101
102        // Distinct baselines show up as distinct glyph y values across runs.
103        let mut baselines: Vec<f32> = runs
104            .iter()
105            .filter_map(|r| r.glyphs.first().map(|g| g.y))
106            .collect();
107        baselines.sort_by(|a, b| a.partial_cmp(b).unwrap());
108        baselines.dedup();
109        assert!(
110            baselines.len() > 1,
111            "constrained width should yield >1 distinct baseline, got {baselines:?}"
112        );
113    }
114
115    #[test]
116    fn empty_string_yields_no_runs_and_zero_ish_size() {
117        let mut cx = TextContext::new();
118        let layout = cx.layout("", &style(20.0), None);
119
120        let runs = layout.to_scene_runs(Point::ORIGIN);
121        assert!(runs.is_empty(), "empty text must produce no glyph runs");
122
123        let size = layout.size();
124        assert_eq!(size.width, 0.0, "empty text should have zero width");
125        // Height may be a single empty line's height; it must be finite and small.
126        assert!(size.height.is_finite());
127    }
128
129    #[test]
130    fn to_scene_runs_translates_positions_by_origin() {
131        let mut cx = TextContext::new();
132        let text = "Ag";
133        let sty = style(28.0);
134
135        let at_origin = cx.layout(text, &sty, None).to_scene_runs(Point::ORIGIN);
136        let offset = Point::new(100.0, 50.0);
137        let translated = cx.layout(text, &sty, None).to_scene_runs(offset);
138
139        assert_eq!(at_origin.len(), translated.len());
140        assert!(!at_origin.is_empty(), "expected glyph runs for \"Ag\"");
141
142        for (base, moved) in at_origin.iter().zip(&translated) {
143            // Glyph-local coordinates are identical; only the run transform
144            // carries the origin translation.
145            assert_eq!(base.glyphs, moved.glyphs);
146            assert_eq!(base.transform, kurbo::Affine::translate((0.0, 0.0)));
147            assert_eq!(
148                moved.transform,
149                kurbo::Affine::translate((offset.x, offset.y))
150            );
151            // Run metadata is carried through.
152            assert_eq!(moved.font_size, 28.0);
153            assert!(matches!(moved.brush, peniko::Brush::Solid(_)));
154        }
155    }
156
157    #[test]
158    fn relative_glyph_positions_are_preserved() {
159        let mut cx = TextContext::new();
160        let runs = cx
161            .layout("WWW", &style(30.0), None)
162            .to_scene_runs(Point::ORIGIN);
163
164        let run = runs.first().expect("expected a glyph run for \"WWW\"");
165        assert!(
166            run.glyphs.len() >= 2,
167            "expected multiple glyphs to compare relative positions"
168        );
169        // Repeated glyphs should advance by a strictly positive step.
170        let x0 = run.glyphs[0].x;
171        let x1 = run.glyphs[1].x;
172        assert!(x1 > x0, "second glyph must advance past the first");
173    }
174
175    // --- expanded TextStyle knobs (family/weight/style/letter-spacing/line-height) ---
176
177    #[test]
178    fn absolute_line_height_scales_wrapped_block_height() {
179        let mut cx = TextContext::new();
180        let text = "Hello from Frust, the pure Rust mobile UI toolkit";
181
182        let mut natural = style(24.0);
183        natural.line_height = LineHeight::MetricsRelative(1.0);
184        let natural_layout = cx.layout(text, &natural, Some(80.0));
185
186        let mut tall = style(24.0);
187        tall.line_height = LineHeight::Absolute(80.0);
188        let tall_layout = cx.layout(text, &tall, Some(80.0));
189
190        assert!(
191            tall_layout.size().height > natural_layout.size().height,
192            "an absolute line height much larger than the font's natural line \
193             height should grow the wrapped block: natural {:?} vs tall {:?}",
194            natural_layout.size(),
195            tall_layout.size()
196        );
197    }
198
199    #[test]
200    fn font_size_relative_line_height_differs_from_metrics_relative() {
201        let mut cx = TextContext::new();
202        let text = "Hello\nworld"; // two hard-broken lines
203
204        let mut metrics = style(20.0);
205        metrics.line_height = LineHeight::MetricsRelative(1.0);
206        let metrics_layout = cx.layout(text, &metrics, None);
207
208        let mut font_relative = style(20.0);
209        font_relative.line_height = LineHeight::FontSizeRelative(3.0);
210        let font_relative_layout = cx.layout(text, &font_relative, None);
211
212        assert!(
213            font_relative_layout.size().height > metrics_layout.size().height,
214            "a 3x font-size-relative line height should be taller than the \
215             font's natural (1.0 metrics-relative) line height"
216        );
217    }
218
219    #[test]
220    fn letter_spacing_widens_the_layout() {
221        let mut cx = TextContext::new();
222        let text = "Hello";
223
224        let narrow = style(24.0);
225        let narrow_layout = cx.layout(text, &narrow, None);
226
227        let mut wide = style(24.0);
228        wide.letter_spacing = 20.0;
229        let wide_layout = cx.layout(text, &wide, None);
230
231        assert!(
232            wide_layout.size().width > narrow_layout.size().width,
233            "extra letter-spacing should widen the laid-out line: {:?} vs {:?}",
234            narrow_layout.size(),
235            wide_layout.size()
236        );
237    }
238
239    #[test]
240    fn bold_weight_selects_a_different_width_layout_than_regular() {
241        let mut cx = TextContext::new();
242        let text = "Hello from Frust";
243
244        let mut regular = style(28.0);
245        regular.weight = FontWeight::REGULAR;
246        let regular_layout = cx.layout(text, &regular, None);
247
248        let mut bold = style(28.0);
249        bold.weight = FontWeight::BOLD;
250        let bold_layout = cx.layout(text, &bold, None);
251
252        assert_ne!(
253            regular_layout.size().width,
254            bold_layout.size().width,
255            "a bold weight should select different (typically wider) glyphs \
256             on a system font that supports weight variation"
257        );
258    }
259
260    #[test]
261    fn italic_style_lays_out_without_error() {
262        let mut cx = TextContext::new();
263        let mut italic = style(24.0);
264        italic.style = FontStyle::Italic;
265        let layout = cx.layout("Hello", &italic, None);
266        assert!(layout.size().width > 0.0, "italic text should still shape");
267    }
268}