1use crate::color::Color;
2use crate::font::{GlyphAtlas, RasterizedGlyph, ATLAS_FONT_SIZE, MAX_OUTLINE_TEXELS};
3use crate::vertex::{DrawList, Vertex};
4
5#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum TextAlign {
8 #[default]
10 Left,
11 Center,
13 Right,
15}
16
17#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct TextStyle {
22 pub size: f32,
23 pub color: Color,
24 pub outline: Option<(Color, f32)>,
31 pub align: TextAlign,
32}
33
34impl Default for TextStyle {
35 fn default() -> Self {
36 Self {
37 size: ATLAS_FONT_SIZE,
38 color: Color::WHITE,
39 outline: None,
40 align: TextAlign::default(),
41 }
42 }
43}
44
45impl TextStyle {
46 pub fn new(size: f32, color: Color) -> Self {
47 Self {
48 size,
49 color,
50 ..Self::default()
51 }
52 }
53
54 pub fn outlined(self, color: Color, width: f32) -> Self {
55 Self {
56 outline: Some((color, width)),
57 ..self
58 }
59 }
60
61 pub fn aligned(self, align: TextAlign) -> Self {
62 Self { align, ..self }
63 }
64
65 pub fn max_outline_width(size: f32) -> f32 {
70 MAX_OUTLINE_TEXELS * size / ATLAS_FONT_SIZE
71 }
72}
73
74#[derive(Debug, Clone, Copy, PartialEq)]
75struct Rect {
76 x0: f32,
77 y0: f32,
78 x1: f32,
79 y1: f32,
80}
81
82impl Rect {
83 fn expand(self, by: f32) -> Self {
84 Self {
85 x0: self.x0 - by,
86 y0: self.y0 - by,
87 x1: self.x1 + by,
88 y1: self.y1 + by,
89 }
90 }
91}
92
93pub(crate) fn emit(
98 draw_list: &mut DrawList,
99 atlas: &GlyphAtlas,
100 x: f32,
101 y: f32,
102 text: &str,
103 style: &TextStyle,
104) {
105 if style.size <= 0.0 || text.is_empty() {
106 return;
107 }
108 let scale = style.size / ATLAS_FONT_SIZE;
109
110 if let Some((color, width)) = style.outline {
111 let width = width.clamp(0.0, TextStyle::max_outline_width(style.size));
112 if width > 0.0 {
113 let texels = width / scale + 1.0;
114 let params = [width, scale];
115 let color = color.to_f32_array();
116 layout(atlas, x, y, text, style, |glyph, rect| {
117 let uv = glyph_uv(atlas, glyph, texels);
118 let rect = rect.expand(texels * scale);
119 draw_list.add_glyph_outline_quad(
120 Vertex::with_uv(rect.x0, rect.y0, color, uv.x0, uv.y0),
121 Vertex::with_uv(rect.x1, rect.y0, color, uv.x1, uv.y0),
122 Vertex::with_uv(rect.x1, rect.y1, color, uv.x1, uv.y1),
123 Vertex::with_uv(rect.x0, rect.y1, color, uv.x0, uv.y1),
124 params,
125 );
126 });
127 }
128 }
129
130 let color = style.color.to_f32_array();
131 layout(atlas, x, y, text, style, |glyph, rect| {
132 let uv = glyph_uv(atlas, glyph, 0.0);
133 draw_list.add_glyph_quad(
134 Vertex::with_uv(rect.x0, rect.y0, color, uv.x0, uv.y0),
135 Vertex::with_uv(rect.x1, rect.y0, color, uv.x1, uv.y0),
136 Vertex::with_uv(rect.x1, rect.y1, color, uv.x1, uv.y1),
137 Vertex::with_uv(rect.x0, rect.y1, color, uv.x0, uv.y1),
138 );
139 });
140}
141
142fn layout(
143 atlas: &GlyphAtlas,
144 x: f32,
145 y: f32,
146 text: &str,
147 style: &TextStyle,
148 mut place: impl FnMut(&RasterizedGlyph, Rect),
149) {
150 let scale = style.size / ATLAS_FONT_SIZE;
151 let mut line_y = y;
152
153 for line in text.split('\n') {
154 let mut cursor = x + align_offset(style.align, atlas.line_width(line) * scale);
155 for c in line.chars() {
156 let Some(glyph) = atlas.glyph(c) else {
157 continue;
158 };
159 if glyph.width > 0 && glyph.height > 0 {
160 let x0 = cursor + glyph.offset_x * scale;
161 let y0 = line_y + style.size - (glyph.offset_y + glyph.height as f32) * scale;
162 place(
163 glyph,
164 Rect {
165 x0,
166 y0,
167 x1: x0 + glyph.width as f32 * scale,
168 y1: y0 + glyph.height as f32 * scale,
169 },
170 );
171 }
172 cursor += glyph.advance * scale;
173 }
174 line_y += style.size;
175 }
176}
177
178fn align_offset(align: TextAlign, width: f32) -> f32 {
179 match align {
180 TextAlign::Left => 0.0,
181 TextAlign::Center => -width / 2.0,
182 TextAlign::Right => -width,
183 }
184}
185
186fn glyph_uv(atlas: &GlyphAtlas, glyph: &RasterizedGlyph, expand: f32) -> Rect {
187 let width = atlas.width as f32;
188 let height = atlas.height as f32;
189 Rect {
190 x0: (glyph.x as f32 - expand) / width,
191 y0: (glyph.y as f32 - expand) / height,
192 x1: (glyph.x as f32 + glyph.width as f32 + expand) / width,
193 y1: (glyph.y as f32 + glyph.height as f32 + expand) / height,
194 }
195}
196
197#[cfg(test)]
198mod tests {
199 use super::*;
200 use crate::vertex::CommandKind;
201
202 fn draw(text: &str, style: &TextStyle) -> DrawList {
203 let atlas = GlyphAtlas::new();
204 let mut list = DrawList::new();
205 emit(&mut list, &atlas, 100.0, 50.0, text, style);
206 list
207 }
208
209 fn kinds(list: &DrawList) -> Vec<CommandKind> {
210 list.commands.iter().map(|c| c.kind).collect()
211 }
212
213 fn bounds(vertices: &[Vertex]) -> Rect {
214 vertices.iter().fold(
215 Rect {
216 x0: f32::MAX,
217 y0: f32::MAX,
218 x1: f32::MIN,
219 y1: f32::MIN,
220 },
221 |acc, v| Rect {
222 x0: acc.x0.min(v.position[0]),
223 y0: acc.y0.min(v.position[1]),
224 x1: acc.x1.max(v.position[0]),
225 y1: acc.y1.max(v.position[1]),
226 },
227 )
228 }
229
230 #[test]
231 fn plain_text_emits_one_glyph_command() {
232 let list = draw("Hi", &TextStyle::new(16.0, Color::WHITE));
233 assert_eq!(kinds(&list), vec![CommandKind::Glyph]);
234 assert_eq!(list.vertices.len(), 8);
235 }
236
237 #[test]
238 fn outlined_text_emits_outline_before_fill() {
239 let style = TextStyle::new(16.0, Color::WHITE).outlined(Color::BLACK, 2.0);
240 let list = draw("Hi", &style);
241 assert_eq!(
242 kinds(&list),
243 vec![CommandKind::GlyphOutline, CommandKind::Glyph]
244 );
245 assert_eq!(list.vertices.len(), 16);
246 }
247
248 #[test]
249 fn a_whole_string_costs_two_draw_calls() {
250 let style = TextStyle::new(16.0, Color::WHITE).outlined(Color::BLACK, 1.5);
251 let list = draw("player one [100]", &style);
252 assert_eq!(list.commands.len(), 2);
253 }
254
255 #[test]
256 fn outline_quads_wrap_the_fill_quads() {
257 let style = TextStyle::new(16.0, Color::WHITE).outlined(Color::BLACK, 2.0);
258 let list = draw("H", &style);
259 let outline = bounds(&list.vertices[..4]);
260 let fill = bounds(&list.vertices[4..]);
261
262 assert!(outline.x0 < fill.x0);
263 assert!(outline.y0 < fill.y0);
264 assert!(outline.x1 > fill.x1);
265 assert!(outline.y1 > fill.y1);
266 }
267
268 #[test]
269 fn outline_carries_width_and_scale() {
270 let style = TextStyle::new(32.0, Color::WHITE).outlined(Color::BLACK, 3.0);
271 let list = draw("H", &style);
272 assert_eq!(list.vertices[0].params, [3.0, 2.0]);
273 assert_eq!(list.vertices[4].params, [0.0, 0.0]);
274 }
275
276 #[test]
277 fn outline_width_is_clamped_to_the_field_range() {
278 let style = TextStyle::new(16.0, Color::WHITE).outlined(Color::BLACK, 1000.0);
279 let list = draw("H", &style);
280 assert_eq!(
281 list.vertices[0].params[0],
282 TextStyle::max_outline_width(16.0)
283 );
284 }
285
286 #[test]
287 fn max_outline_width_scales_with_font_size() {
288 assert_eq!(TextStyle::max_outline_width(16.0), 7.0);
289 assert_eq!(TextStyle::max_outline_width(32.0), 14.0);
290 }
291
292 #[test]
293 fn outline_samples_stay_inside_the_atlas() {
294 let atlas = GlyphAtlas::new();
295 let style = TextStyle::new(8.0, Color::WHITE).outlined(Color::BLACK, 1000.0);
296 let mut list = DrawList::new();
297 emit(&mut list, &atlas, 0.0, 0.0, "Wg", &style);
298
299 for vertex in &list.vertices {
300 assert!(vertex.uv[0] >= 0.0 && vertex.uv[0] <= 1.0);
301 assert!(vertex.uv[1] >= 0.0 && vertex.uv[1] <= 1.0);
302 }
303 }
304
305 #[test]
306 fn zero_width_outline_is_skipped() {
307 let style = TextStyle::new(16.0, Color::WHITE).outlined(Color::BLACK, 0.0);
308 let list = draw("H", &style);
309 assert_eq!(kinds(&list), vec![CommandKind::Glyph]);
310 }
311
312 #[test]
313 fn non_positive_size_draws_nothing() {
314 let list = draw("H", &TextStyle::new(0.0, Color::WHITE));
315 assert!(list.commands.is_empty());
316 let list = draw("H", &TextStyle::new(-4.0, Color::WHITE));
317 assert!(list.commands.is_empty());
318 }
319
320 #[test]
321 fn blank_input_draws_nothing() {
322 assert!(draw("", &TextStyle::default()).commands.is_empty());
323 assert!(draw(" ", &TextStyle::default()).commands.is_empty());
324 }
325
326 #[test]
327 fn centered_text_straddles_the_position() {
328 let atlas = GlyphAtlas::new();
329 let left = draw("centered", &TextStyle::default());
330 let centered = draw("centered", &TextStyle::default().aligned(TextAlign::Center));
331
332 let width = atlas.line_width("centered");
333 let shift = bounds(&left.vertices).x0 - bounds(¢ered.vertices).x0;
334 assert!((shift - width / 2.0).abs() < 0.001);
335 }
336
337 #[test]
338 fn right_aligned_text_ends_at_the_position() {
339 let atlas = GlyphAtlas::new();
340 let left = draw("right", &TextStyle::default());
341 let right = draw("right", &TextStyle::default().aligned(TextAlign::Right));
342
343 let width = atlas.line_width("right");
344 let shift = bounds(&left.vertices).x0 - bounds(&right.vertices).x0;
345 assert!((shift - width).abs() < 0.001);
346 }
347
348 #[test]
349 fn each_line_aligns_independently() {
350 let list = draw(
351 "wide line here\nx",
352 &TextStyle::default().aligned(TextAlign::Center),
353 );
354
355 let atlas = GlyphAtlas::new();
356 let long = atlas.line_width("wide line here");
357 let short = atlas.line_width("x");
358
359 let first_line_start = list.vertices[0].position[0];
360 let last_line_start = list.vertices[list.vertices.len() - 4].position[0];
361
362 assert!((first_line_start - (100.0 - long / 2.0)).abs() < 2.0);
363 assert!((last_line_start - (100.0 - short / 2.0)).abs() < 2.0);
364 }
365
366 #[test]
367 fn newlines_advance_by_the_font_size() {
368 let list = draw("a\na", &TextStyle::new(16.0, Color::WHITE));
369 let first = list.vertices[0].position[1];
370 let second = list.vertices[4].position[1];
371 assert!((second - first - 16.0).abs() < 0.001);
372 }
373
374 #[test]
375 fn scaling_the_size_scales_the_quads() {
376 let small = draw("A", &TextStyle::new(16.0, Color::WHITE));
377 let large = draw("A", &TextStyle::new(32.0, Color::WHITE));
378 let small = bounds(&small.vertices);
379 let large = bounds(&large.vertices);
380 assert!(((large.x1 - large.x0) - (small.x1 - small.x0) * 2.0).abs() < 0.001);
381 }
382
383 #[test]
384 fn default_style_matches_the_atlas_size() {
385 let style = TextStyle::default();
386 assert_eq!(style.size, ATLAS_FONT_SIZE);
387 assert_eq!(style.align, TextAlign::Left);
388 assert!(style.outline.is_none());
389 }
390}