Skip to main content

bevy_gizmos/
stroke_text.rs

1//! This module draws text gizmos using a stroke font.
2
3use crate::simplex_stroke_font::*;
4use crate::{gizmos::GizmoBuffer, prelude::GizmoConfigGroup};
5use bevy_color::Color;
6use bevy_math::{vec2, Isometry2d, Isometry3d, Vec2};
7use core::ops::Range;
8
9/// A stroke font containing glyphs for the 95 printable ASCII codes.
10pub struct StrokeFont<'a> {
11    /// Baseline-to-baseline line height ratio.
12    pub line_height: f32,
13    /// Full glyph height (cap + descender) in font units.
14    pub height: f32,
15    /// Cap height in font units.
16    pub cap_height: f32,
17    /// Advance used for unsupported glyphs.
18    pub advance: i8,
19    /// Raw glyph point positions.
20    pub positions: &'a [[i8; 2]],
21    /// Stroke ranges into `positions`.
22    pub strokes: &'a [Range<usize>],
23    /// Glyph advances and stroke ranges.
24    pub glyphs: &'a [(i8, Range<usize>); 95],
25}
26
27impl<'a> StrokeFont<'a> {
28    /// Builds a `StrokeTextLayout` for `sections` at the requested `font_size`.
29    pub fn layout(
30        &'a self,
31        sections: &'a [(&'a str, Color)],
32        font_size: f32,
33    ) -> StrokeTextLayout<'a> {
34        let scale = font_size / SIMPLEX_CAP_HEIGHT;
35        let glyph_height = SIMPLEX_HEIGHT * scale;
36        let line_height = LINE_HEIGHT * glyph_height;
37        let margin_top = line_height - glyph_height;
38        let space_advance = SIMPLEX_GLYPHS[0].0 as f32 * scale;
39        let tab_advance = space_advance * TAB_WIDTH_IN_SPACES as f32;
40        StrokeTextLayout {
41            font: self,
42            sections,
43            scale,
44            line_height,
45            margin_top,
46            space_advance,
47            tab_advance,
48        }
49    }
50
51    fn get_glyph_index(&self, c: char) -> Option<usize> {
52        let code = c as u32;
53        if (0x20..=0x7E).contains(&code) {
54            Some(code as usize - 0x20)
55        } else {
56            None
57        }
58    }
59
60    /// Get the advance and stroke point ranges for a glyph.
61    pub fn get_glyph(&self, c: char) -> Option<(i8, Range<usize>)> {
62        Some(self.glyphs[self.get_glyph_index(c)?].clone())
63    }
64
65    /// Get the advance for a glyph.
66    pub fn get_glyph_advance(&self, c: char) -> Option<i8> {
67        Some(self.glyphs[self.get_glyph_index(c)?].0)
68    }
69}
70
71/// Stroke text layout
72pub struct StrokeTextLayout<'a> {
73    /// The unscaled font
74    font: &'a StrokeFont<'a>,
75    /// The text sections with per-section colors.
76    sections: &'a [(&'a str, Color)],
77    /// Scale applied to the raw glyph positions.
78    scale: f32,
79    /// Height of each line of text.
80    line_height: f32,
81    /// Space between top of line and cap height.
82    margin_top: f32,
83    /// Width of a space.
84    space_advance: f32,
85    /// Width of a tab stop.
86    tab_advance: f32,
87}
88
89impl<'a> StrokeTextLayout<'a> {
90    /// Computes the width and height of the text layout.
91    ///
92    /// Returns the layout size in pixels.
93    pub fn measure(&self) -> Vec2 {
94        let mut layout_size = vec2(0., self.line_height);
95
96        let mut line_width = 0.;
97        for (c, _) in colored_chars(self.sections) {
98            if c == '\n' {
99                layout_size.x = layout_size.x.max(line_width);
100                line_width = 0.;
101                layout_size.y += self.line_height;
102                continue;
103            }
104
105            if c == '\t' {
106                line_width = next_tab_stop(line_width, self.tab_advance);
107                continue;
108            }
109
110            line_width += self
111                .font
112                .get_glyph_advance(c)
113                .map(|advance| advance as f32 * self.scale)
114                .unwrap_or(self.space_advance);
115        }
116
117        layout_size.x = layout_size.x.max(line_width);
118        layout_size
119    }
120
121    /// Returns an iterator over the font strokes for this text layout, grouped into polylines
122    /// of `Vec2` points, each paired with its color from the text sections.
123    pub fn render(&'a self) -> impl Iterator<Item = (Color, impl Iterator<Item = Vec2> + 'a)> + 'a {
124        let mut chars = colored_chars(self.sections);
125        let mut x = 0.0_f32;
126        let mut y = -self.margin_top;
127        let mut current_strokes: Range<usize> = 0..0;
128        let mut current_x = 0.0_f32;
129        let mut current_color = Color::WHITE;
130
131        core::iter::from_fn(move || loop {
132            for stroke_index in current_strokes.by_ref() {
133                let stroke = self.font.strokes[stroke_index].clone();
134                if stroke.len() < 2 {
135                    continue;
136                }
137
138                // If this stroke is a closed loop, append one extra point to add a join at the seam.
139                let join = (self.font.positions[stroke.start]
140                    == self.font.positions[stroke.end - 1])
141                    .then_some(stroke.start + 1);
142
143                let color = current_color;
144                return Some((
145                    color,
146                    stroke.chain(join).map(move |index| {
147                        let [p, q] = self.font.positions[index];
148                        Vec2::new(
149                            current_x + self.scale * p as f32,
150                            y - self.scale * (self.font.cap_height - q as f32),
151                        )
152                    }),
153                ));
154            }
155
156            let (c, char_color) = chars.next()?;
157
158            if c == '\n' {
159                x = 0.0;
160                y -= self.line_height;
161                continue;
162            }
163
164            if c == '\t' {
165                x = next_tab_stop(x, self.tab_advance);
166                continue;
167            }
168
169            let Some((advance, strokes)) = self.font.get_glyph(c) else {
170                x += self.space_advance;
171                continue;
172            };
173            current_color = char_color;
174            current_strokes = strokes;
175            current_x = x;
176
177            x += advance as f32 * self.scale;
178        })
179    }
180}
181
182impl<Config, Clear> GizmoBuffer<Config, Clear>
183where
184    Config: GizmoConfigGroup,
185    Clear: 'static + Send + Sync,
186{
187    /// Draw text using a stroke font with the given isometry applied.
188    ///
189    /// Only ASCII characters in the range 32–126 are supported.
190    ///
191    /// # Arguments
192    ///
193    /// - `isometry`: defines the translation and rotation of the text.
194    /// - `text`: the text to be drawn.
195    /// - `size`: the size of the text in pixels.
196    /// - `anchor`: normalized anchor point relative to the text bounds,
197    ///   where `(0, 0)` is centered, `(-0.5, 0.5)` is top-left,
198    ///   and `(0.5, -0.5)` is bottom-right.
199    /// - `color`: the color of the text.
200    ///
201    /// # Example
202    /// ```
203    /// # use bevy_gizmos::prelude::*;
204    /// # use bevy_math::prelude::*;
205    /// # use bevy_color::Color;
206    /// fn system(mut gizmos: Gizmos) {
207    ///     gizmos.text(Isometry3d::IDENTITY, "text gizmo", 25., Vec2::ZERO, Color::WHITE);
208    /// }
209    /// # bevy_ecs::system::assert_is_system(system);
210    /// ```
211    pub fn text(
212        &mut self,
213        isometry: impl Into<Isometry3d>,
214        text: &str,
215        font_size: f32,
216        anchor: Vec2,
217        color: impl Into<Color>,
218    ) {
219        let color: Color = color.into();
220        self.text_sections(isometry, &[(text, color)], font_size, anchor);
221    }
222
223    /// Draw text using a stroke font with the given isometry applied, coloring each section
224    /// independently.
225    ///
226    /// Only ASCII characters in the range 32–126 are supported.
227    ///
228    /// # Arguments
229    ///
230    /// - `isometry`: defines the translation and rotation of the text.
231    /// - `sections`: a slice of `(text, color)` pairs. Each section's characters are drawn
232    ///   in its color. Sections are concatenated left-to-right on the same baseline.
233    /// - `font_size`: the size of the text in pixels.
234    /// - `anchor`: normalized anchor point relative to the combined text bounds,
235    ///   where `(0, 0)` is centered, `(-0.5, 0.5)` is top-left,
236    ///   and `(0.5, -0.5)` is bottom-right.
237    ///
238    /// # Example
239    /// ```
240    /// # use bevy_gizmos::prelude::*;
241    /// # use bevy_math::prelude::*;
242    /// # use bevy_color::Color;
243    /// fn system(mut gizmos: Gizmos) {
244    ///     gizmos.text_sections(
245    ///         Isometry3d::IDENTITY,
246    ///         &[("Hello ", Color::WHITE), ("World!", Color::srgb(1., 0.3, 0.))],
247    ///         25.,
248    ///         Vec2::ZERO,
249    ///     );
250    /// }
251    /// # bevy_ecs::system::assert_is_system(system);
252    /// ```
253    pub fn text_sections(
254        &mut self,
255        isometry: impl Into<Isometry3d>,
256        sections: &[(&str, Color)],
257        font_size: f32,
258        anchor: Vec2,
259    ) {
260        let isometry: Isometry3d = isometry.into();
261        let layout = SIMPLEX_STROKE_FONT.layout(sections, font_size);
262        let layout_anchor = layout.measure() * (vec2(-0.5, 0.5) - anchor);
263        for (color, points) in layout.render() {
264            self.linestrip(
265                points.map(|point| isometry * (layout_anchor + point).extend(0.)),
266                color,
267            );
268        }
269    }
270
271    /// Draw text using a stroke font in 2d with the given isometry applied.
272    ///
273    /// Only ASCII characters in the range 32–126 are supported.
274    ///
275    /// # Arguments
276    ///
277    /// - `isometry`: defines the translation and rotation of the text.
278    /// - `text`: the text to be drawn.
279    /// - `size`: the size of the text.
280    /// - `anchor`: normalized anchor point relative to the text bounds,
281    ///   where `(0., 0.)` is centered, `(-0.5, 0.5)` is top-left,
282    ///   and `(0.5, -0.5)` is bottom-right.
283    /// - `color`: the color of the text.
284    ///
285    /// # Example
286    /// ```
287    /// # use bevy_gizmos::prelude::*;
288    /// # use bevy_math::prelude::*;
289    /// # use bevy_color::Color;
290    /// fn system(mut gizmos: Gizmos) {
291    ///     gizmos.text_2d(Isometry2d::IDENTITY, "2D text gizmo", 25., Vec2::ZERO, Color::WHITE);
292    /// }
293    /// # bevy_ecs::system::assert_is_system(system);
294    /// ```
295    pub fn text_2d(
296        &mut self,
297        isometry: impl Into<Isometry2d>,
298        text: &str,
299        font_size: f32,
300        anchor: Vec2,
301        color: impl Into<Color>,
302    ) {
303        let color: Color = color.into();
304        self.text_sections_2d(isometry, &[(text, color)], font_size, anchor);
305    }
306
307    /// Draw text using a stroke font in 2d with the given isometry applied, coloring each section
308    /// independently.
309    ///
310    /// Only ASCII characters in the range 32–126 are supported.
311    ///
312    /// # Arguments
313    ///
314    /// - `isometry`: defines the translation and rotation of the text.
315    /// - `sections`: a slice of `(text, color)` pairs. Each section's characters are drawn
316    ///   in its color. Sections are concatenated left-to-right on the same baseline.
317    /// - `font_size`: the size of the text.
318    /// - `anchor`: normalized anchor point relative to the combined text bounds,
319    ///   where `(0., 0.)` is centered, `(-0.5, 0.5)` is top-left,
320    ///   and `(0.5, -0.5)` is bottom-right.
321    ///
322    /// # Example
323    /// ```
324    /// # use bevy_gizmos::prelude::*;
325    /// # use bevy_math::prelude::*;
326    /// # use bevy_color::Color;
327    /// fn system(mut gizmos: Gizmos) {
328    ///     gizmos.text_sections_2d(
329    ///         Isometry2d::IDENTITY,
330    ///         &[("Hello ", Color::WHITE), ("World!", Color::srgb(1., 0.3, 0.))],
331    ///         25.,
332    ///         Vec2::ZERO,
333    ///     );
334    /// }
335    /// # bevy_ecs::system::assert_is_system(system);
336    /// ```
337    pub fn text_sections_2d(
338        &mut self,
339        isometry: impl Into<Isometry2d>,
340        sections: &[(&str, Color)],
341        font_size: f32,
342        anchor: Vec2,
343    ) {
344        let isometry: Isometry2d = isometry.into();
345        let layout = SIMPLEX_STROKE_FONT.layout(sections, font_size);
346        let layout_anchor = layout.measure() * (vec2(-0.5, 0.5) - anchor);
347        for (color, points) in layout.render() {
348            self.linestrip_2d(
349                points.map(|point| isometry * (layout_anchor + point)),
350                color,
351            );
352        }
353    }
354}
355
356/// Iterates the characters across all sections, each paired with its section color.
357fn colored_chars<'a>(sections: &'a [(&'a str, Color)]) -> impl Iterator<Item = (char, Color)> + 'a {
358    sections
359        .iter()
360        .flat_map(|&(text, color)| text.chars().map(move |c| (c, color)))
361}
362
363fn next_tab_stop(x: f32, tab_advance: f32) -> f32 {
364    let tab_index = (x / tab_advance).floor() as i64 + 1;
365    tab_index as f32 * tab_advance
366}