1mod 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 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 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 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 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 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 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 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 #[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"; 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, ®ular, 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}