Skip to main content

wisp/scene/
text.rs

1//! `Text` — bitmap-font glyph rendering.
2//!
3//! M0.15 ships the bitmap atlas backed by [`font8x8`]. Each glyph is an 8×8
4//! bitmap; the atlas is a 16×16 grid of cells (128×128 pixels total). Vector
5//! fonts via `fontdue` come in a later chunk if/when we need anti-aliased
6//! type at multiple sizes.
7
8use std::sync::Arc;
9
10use font8x8::legacy::BASIC_LEGACY;
11
12use crate::application::Application;
13use crate::color::Color;
14use crate::scene::container::Container;
15use crate::texture::Texture;
16
17/// Per-glyph metrics — UV rect in the atlas.
18#[derive(Clone, Copy, Debug, PartialEq)]
19pub struct GlyphMetrics {
20    /// Left U coordinate in the atlas.
21    pub u_min: f32,
22    /// Top V coordinate in the atlas.
23    pub v_min: f32,
24    /// Right U coordinate in the atlas.
25    pub u_max: f32,
26    /// Bottom V coordinate in the atlas.
27    pub v_max: f32,
28}
29
30/// Bitmap font — owns a glyph atlas and per-codepoint metrics. Cheaply
31/// cloneable; the underlying atlas is `Arc`-wrapped.
32#[derive(Clone)]
33pub struct Font {
34    inner: Arc<FontInner>,
35}
36
37struct FontInner {
38    atlas: Texture,
39    glyphs: [Option<GlyphMetrics>; 128],
40    /// Pixels per glyph cell (8 for font8x8).
41    cell_pixels: u32,
42}
43
44impl Font {
45    /// Build the embedded 8×8 ASCII bitmap font.
46    ///
47    /// The returned `Font` carries its own atlas texture and glyph metrics —
48    /// no external font files required.
49    #[must_use]
50    #[allow(
51        clippy::cast_precision_loss,
52        reason = "all values bounded by 128 (atlas dim) — fit losslessly in f32"
53    )]
54    pub fn bitmap_8x8(app: &Application) -> Self {
55        const CELL: u32 = 8;
56        const COLS: u32 = 16;
57        const ROWS: u32 = 16;
58        let atlas_w = COLS * CELL;
59        let atlas_h = ROWS * CELL;
60
61        let mut bytes = vec![0u8; (atlas_w * atlas_h * 4) as usize];
62        let mut glyphs: [Option<GlyphMetrics>; 128] = [None; 128];
63
64        for c in 0u32..128 {
65            let glyph_rows = BASIC_LEGACY[c as usize];
66            let cell_x = (c % COLS) * CELL;
67            let cell_y = (c / COLS) * CELL;
68
69            for row in 0u32..CELL {
70                let bits = glyph_rows[row as usize];
71                for col in 0u32..CELL {
72                    let on = ((bits >> col) & 1) != 0;
73                    let px = (cell_y + row) * atlas_w + (cell_x + col);
74                    let idx = (px * 4) as usize;
75                    if on {
76                        bytes[idx] = 255;
77                        bytes[idx + 1] = 255;
78                        bytes[idx + 2] = 255;
79                        bytes[idx + 3] = 255;
80                    }
81                }
82            }
83
84            // UVs: cell (cell_x, cell_y) of size (CELL, CELL).
85            let atlas_width_f = atlas_w as f32;
86            let atlas_height_f = atlas_h as f32;
87            glyphs[c as usize] = Some(GlyphMetrics {
88                u_min: cell_x as f32 / atlas_width_f,
89                v_min: cell_y as f32 / atlas_height_f,
90                u_max: (cell_x + CELL) as f32 / atlas_width_f,
91                v_max: (cell_y + CELL) as f32 / atlas_height_f,
92            });
93        }
94
95        let atlas = Texture::from_rgba(app, atlas_w, atlas_h, &bytes);
96
97        Self {
98            inner: Arc::new(FontInner {
99                atlas,
100                glyphs,
101                cell_pixels: CELL,
102            }),
103        }
104    }
105
106    /// Pixels per cell side (8 for font8x8).
107    #[must_use]
108    pub fn cell_pixels(&self) -> u32 {
109        self.inner.cell_pixels
110    }
111
112    pub(crate) fn atlas(&self) -> &Texture {
113        &self.inner.atlas
114    }
115
116    pub(crate) fn glyph(&self, c: char) -> Option<GlyphMetrics> {
117        let cp = u32::from(c);
118        if cp >= 128 {
119            return None;
120        }
121        self.inner.glyphs[cp as usize]
122    }
123}
124
125impl std::fmt::Debug for Font {
126    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
127        f.debug_struct("Font")
128            .field("cell_pixels", &self.inner.cell_pixels)
129            .finish_non_exhaustive()
130    }
131}
132
133/// Text node — bitmap glyph string anchored at its container's origin.
134///
135/// Layout flows left-to-right, top-to-bottom. Newlines (`\n`) advance
136/// `cursor.y` by `line_height` (= `cell_size` × `cell_pixels`).
137#[derive(Debug, Clone)]
138pub struct Text {
139    /// Transform / visibility container.
140    pub container: Container,
141    /// String to render. Newlines start a new line.
142    pub content: String,
143    /// Bitmap font supplying the glyph atlas.
144    pub font: Font,
145    /// Text color tint.
146    pub color: Color,
147    /// NDC units per atlas pixel. Default `0.02` ≈ glyphs ~16% of NDC tall.
148    pub cell_size: f32,
149}
150
151impl Text {
152    /// Construct a text node with default white color and `cell_size = 0.02`.
153    #[must_use]
154    pub fn new(font: Font, content: impl Into<String>) -> Self {
155        Self {
156            container: Container::default(),
157            content: content.into(),
158            font,
159            color: Color::WHITE,
160            cell_size: 0.02,
161        }
162    }
163
164    /// Builder: set color.
165    #[must_use]
166    pub fn with_color(mut self, color: Color) -> Self {
167        self.color = color;
168        self
169    }
170
171    /// Builder: set NDC-per-atlas-pixel scale.
172    #[must_use]
173    pub fn with_cell_size(mut self, size: f32) -> Self {
174        self.cell_size = size;
175        self
176    }
177}
178
179#[cfg(test)]
180mod tests {
181    use super::*;
182    use crate::application::{AppConfig, Application};
183
184    fn boot() -> Application {
185        pollster::block_on(Application::new(AppConfig::default())).expect("init")
186    }
187
188    #[test]
189    fn font_has_ascii_glyphs() {
190        let app = boot();
191        let font = Font::bitmap_8x8(&app);
192        assert!(font.glyph('A').is_some());
193        assert!(font.glyph('z').is_some());
194        assert!(font.glyph(' ').is_some());
195        // Non-ASCII falls outside the 128-codepoint atlas.
196        assert!(font.glyph('é').is_none());
197    }
198
199    #[test]
200    fn text_defaults() {
201        let app = boot();
202        let font = Font::bitmap_8x8(&app);
203        let text = Text::new(font, "Hello");
204        assert_eq!(text.content, "Hello");
205        assert_eq!(text.color, Color::WHITE);
206        assert!((text.cell_size - 0.02).abs() < f32::EPSILON);
207    }
208
209    #[test]
210    fn builders_set_fields() {
211        let app = boot();
212        let font = Font::bitmap_8x8(&app);
213        let text = Text::new(font, "X")
214            .with_color(Color::RED)
215            .with_cell_size(0.05);
216        assert_eq!(text.color, Color::RED);
217        assert!((text.cell_size - 0.05).abs() < f32::EPSILON);
218    }
219}