Skip to main content

procmod_overlay/
text.rs

1use crate::color::Color;
2use crate::font::{GlyphAtlas, RasterizedGlyph, ATLAS_FONT_SIZE, MAX_OUTLINE_TEXELS};
3use crate::vertex::{DrawList, Vertex};
4
5/// Horizontal placement of a line relative to the position passed to the draw call.
6#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
7pub enum TextAlign {
8    /// The position is the left edge of the line.
9    #[default]
10    Left,
11    /// The position is the horizontal center of the line.
12    Center,
13    /// The position is the right edge of the line.
14    Right,
15}
16
17/// How a string is drawn: size, color, optional outline, and alignment.
18///
19/// Each line of a multi-line string is aligned independently.
20#[derive(Debug, Clone, Copy, PartialEq)]
21pub struct TextStyle {
22    pub size: f32,
23    pub color: Color,
24    /// Outline color and width in pixels.
25    ///
26    /// The outline is rasterized from the glyph's distance field, so its thickness is
27    /// uniform around curves and corners. Width is clamped to
28    /// [`TextStyle::max_outline_width`]. Outlines of adjacent glyphs overlap slightly
29    /// where they meet, which is only visible if the outline color is translucent.
30    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    /// Widest outline available at the given font size, in pixels.
66    ///
67    /// The distance field baked into the glyph atlas has a fixed range, so the usable
68    /// outline width grows with the font size. Wider requests are clamped to this value.
69    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
93/// Append the quads for a string to the draw list.
94///
95/// Outlines are emitted for the whole string before any fill, so a glyph's outline never
96/// covers the neighbour it overlaps.
97pub(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(&centered.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}