Skip to main content

wisp/text/
atlas.rs

1//! Atlas text backend (M-TEXT.4 / AUT-78).
2//!
3//! `AtlasText` is the simple/static/performance text path — bitmap font
4//! atlases, batchable per-atlas, deterministic, no shaping or `BiDi`. It
5//! preserves the M0.15 bitmap path (`scene::Text` + `text_pipeline`)
6//! while expressing the same data through the wisp text trait surface
7//! ([`WispTextEngine`], [`WispTextLayout`]).
8//!
9//! For styled, wrapped, or user-typed text use `FlexibleText` (Cosmic
10//! Text + Glyphon, lands in M-TEXT.2/.3). The two backends are
11//! complementary, not competitive — see `_docs/wisp-book/src/wisp/text/atlas-vs-flexible.md`
12//! for the comparison table.
13//!
14//! Layout semantics:
15//!
16//! - `style.size_ndc` is the cell side length in NDC. font8x8 cells are
17//!   square, so glyph width = glyph height = `size_ndc`.
18//! - Glyphs advance horizontally by `size_ndc + style.letter_spacing_ndc`.
19//! - Newlines (`\n`) advance the y cursor by
20//!   `size_ndc * style.line_height`.
21//! - `style.align` shifts whole lines (Left = no shift,
22//!   Center = `(max_w - line_w)/2`, Right = `max_w - line_w`).
23//! - `text.max_width_ndc` is **ignored** by `AtlasText` (no word
24//!   wrapping). Captions / soft-wrap belong to `FlexibleText`.
25//! - Codepoints absent from the bitmap atlas (anything ≥ 128) are
26//!   silently dropped — same behavior as the M0.15 `scene::Text` node.
27
28use glam::Vec2;
29
30use super::{WispText, WispTextAlign, WispTextEngine, WispTextLayout, WispTextMetrics};
31use crate::color::Color;
32use crate::scene::text::{Font, GlyphMetrics};
33
34/// One laid-out glyph — NDC-positioned quad plus atlas UV.
35#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct AtlasGlyphInstance {
37    /// Top-left of the glyph quad in NDC (x increases right, y the
38    /// renderer's chosen convention — `AtlasText` preserves the
39    /// `scene::Text` semantics where the layout flows top-down from
40    /// `text.position` with line index as a positive offset).
41    pub origin: Vec2,
42    /// Width in NDC (= `style.size_ndc`).
43    pub width: f32,
44    /// Height in NDC (= `style.size_ndc`).
45    pub height: f32,
46    /// Atlas UV rect.
47    pub uvs: GlyphMetrics,
48    /// Solid tint pulled from `style.color` for convenience.
49    pub color: Color,
50}
51
52/// Result of [`AtlasTextEngine::layout`] — a flat glyph list plus
53/// metrics.
54#[derive(Debug, Clone)]
55pub struct AtlasTextLayout {
56    /// One entry per drawable glyph (whitespace skipped, missing
57    /// codepoints dropped).
58    pub glyphs: Vec<AtlasGlyphInstance>,
59    metrics: WispTextMetrics,
60}
61
62impl AtlasTextLayout {
63    /// Borrow the underlying glyph instances.
64    #[must_use]
65    pub fn glyphs(&self) -> &[AtlasGlyphInstance] {
66        &self.glyphs
67    }
68}
69
70impl WispTextLayout for AtlasTextLayout {
71    fn metrics(&self) -> WispTextMetrics {
72        self.metrics
73    }
74}
75
76/// Bitmap atlas text engine. Wraps a [`Font`] (M0.15 8×8 bitmap or
77/// any future atlas) and lays out a [`WispText`] into per-glyph NDC
78/// quads.
79#[derive(Debug, Clone)]
80pub struct AtlasTextEngine {
81    font: Font,
82}
83
84impl AtlasTextEngine {
85    /// Build the engine from a font.
86    #[must_use]
87    pub fn new(font: Font) -> Self {
88        Self { font }
89    }
90
91    /// Borrow the font (so the renderer can reach the atlas texture).
92    #[must_use]
93    pub fn font(&self) -> &Font {
94        &self.font
95    }
96
97    /// Layout `text` into a concrete [`AtlasTextLayout`]. The trait
98    /// version (`<Self as WispTextEngine>::layout`) boxes this for
99    /// dyn dispatch; this method preserves the concrete type so the
100    /// renderer side can read `glyphs()` directly without downcasting.
101    #[must_use]
102    pub fn layout_concrete(&self, text: &WispText) -> AtlasTextLayout {
103        layout_atlas(&self.font, text)
104    }
105}
106
107impl WispTextEngine for AtlasTextEngine {
108    fn layout(&self, text: &WispText) -> Box<dyn WispTextLayout> {
109        Box::new(layout_atlas(&self.font, text))
110    }
111}
112
113fn layout_atlas(font: &Font, text: &WispText) -> AtlasTextLayout {
114    let style = text.style;
115    let cell_w = style.size_ndc;
116    let cell_h = style.size_ndc;
117    let advance = cell_w + style.letter_spacing_ndc;
118    let line_step = cell_h * style.line_height;
119
120    let lines: Vec<&str> = text.content.split('\n').collect();
121    let mut line_widths: Vec<f32> = Vec::with_capacity(lines.len());
122    for line in &lines {
123        let glyph_count_usize = line.chars().filter(|c| font.glyph(*c).is_some()).count();
124        #[expect(
125            clippy::cast_precision_loss,
126            reason = "line glyph counts are small (< 2^23 in practice)"
127        )]
128        let glyph_count = glyph_count_usize as f32;
129        let width = if glyph_count > 0.0 {
130            (glyph_count - 1.0).max(0.0) * advance + cell_w
131        } else {
132            0.0
133        };
134        line_widths.push(width);
135    }
136    let max_width = line_widths.iter().copied().fold(0.0_f32, f32::max);
137
138    let mut glyphs: Vec<AtlasGlyphInstance> = Vec::new();
139    for (line_idx, line) in lines.iter().enumerate() {
140        let line_width = line_widths[line_idx];
141        let x_offset = match style.align {
142            WispTextAlign::Left => 0.0,
143            WispTextAlign::Center => (max_width - line_width) * 0.5,
144            WispTextAlign::Right => max_width - line_width,
145        };
146        let mut x = text.position.x + x_offset;
147        #[expect(
148            clippy::cast_precision_loss,
149            reason = "line_idx bounded by line count — fits losslessly in f32"
150        )]
151        let line_y = text.position.y + (line_idx as f32) * line_step;
152        for c in line.chars() {
153            if let Some(uvs) = font.glyph(c) {
154                glyphs.push(AtlasGlyphInstance {
155                    origin: Vec2::new(x, line_y),
156                    width: cell_w,
157                    height: cell_h,
158                    uvs,
159                    color: style.color,
160                });
161            }
162            x += advance;
163        }
164    }
165
166    #[expect(
167        clippy::cast_possible_truncation,
168        reason = "line_count is small (< 2^32) by construction"
169    )]
170    let line_count = lines.len() as u32;
171    let extra_lines = lines.len().saturating_sub(1);
172    #[expect(
173        clippy::cast_precision_loss,
174        reason = "line counts are small (< 2^23 in practice)"
175    )]
176    let extra_lines_f = extra_lines as f32;
177    let total_height = if lines.is_empty() {
178        0.0
179    } else {
180        cell_h + extra_lines_f * line_step
181    };
182
183    AtlasTextLayout {
184        glyphs,
185        metrics: WispTextMetrics {
186            line_count,
187            max_width_ndc: max_width,
188            total_height_ndc: total_height,
189            baseline_ndc: cell_h,
190        },
191    }
192}
193
194#[cfg(test)]
195mod tests {
196    use super::*;
197    use crate::application::{AppConfig, Application};
198    use crate::text::{WispFontWeight, WispText, WispTextStyle};
199
200    fn boot() -> Application {
201        pollster::block_on(Application::new(AppConfig::default())).expect("init")
202    }
203
204    fn engine() -> AtlasTextEngine {
205        let app = boot();
206        AtlasTextEngine::new(Font::bitmap_8x8(&app))
207    }
208
209    #[test]
210    fn empty_text_yields_no_glyphs_and_metric_zero_width() {
211        let eng = engine();
212        let layout = eng.layout_concrete(&WispText::new(""));
213        assert!(layout.glyphs().is_empty());
214        assert_eq!(layout.metrics().line_count, 1);
215        assert!(layout.metrics().max_width_ndc.abs() < f32::EPSILON);
216    }
217
218    #[test]
219    fn single_line_emits_one_glyph_per_ascii_char() {
220        let eng = engine();
221        let layout = eng.layout_concrete(&WispText::new("Hello"));
222        assert_eq!(layout.glyphs().len(), 5);
223        assert_eq!(layout.metrics().line_count, 1);
224        // 5 chars at default 0.06 size, no letter spacing → width = 5 * 0.06 = 0.30.
225        let expected = 0.30_f32;
226        assert!(
227            (layout.metrics().max_width_ndc - expected).abs() < 1e-5,
228            "got {} expected {expected}",
229            layout.metrics().max_width_ndc
230        );
231    }
232
233    #[test]
234    fn newline_starts_a_new_line_and_advances_y() {
235        let eng = engine();
236        let layout = eng.layout_concrete(&WispText::new("ab\ncd").with_position(Vec2::ZERO));
237        assert_eq!(layout.metrics().line_count, 2);
238        assert_eq!(layout.glyphs().len(), 4);
239        // First glyph at y=0; third glyph (start of line 2) at y = 0.06 * 1.2 = 0.072.
240        let expected_step = 0.06_f32 * 1.2;
241        let g0 = layout.glyphs()[0];
242        let g2 = layout.glyphs()[2];
243        assert!(g0.origin.y.abs() < 1e-6);
244        assert!(
245            (g2.origin.y - expected_step).abs() < 1e-5,
246            "g2.y={} expected={expected_step}",
247            g2.origin.y
248        );
249    }
250
251    #[test]
252    fn non_ascii_codepoints_are_dropped_silently() {
253        let eng = engine();
254        let layout = eng.layout_concrete(&WispText::new("aé"));
255        // 'é' is past 128, dropped; only 'a' survives.
256        assert_eq!(layout.glyphs().len(), 1);
257    }
258
259    #[test]
260    fn center_align_shifts_short_line_to_match_long_line() {
261        let eng = engine();
262        let style = WispTextStyle::default().with_align(WispTextAlign::Center);
263        // "ab" (2 chars wide) on top of "abcd" (4 chars wide).
264        let layout = eng.layout_concrete(
265            &WispText::new("ab\nabcd")
266                .with_style(style)
267                .with_position(Vec2::ZERO),
268        );
269        assert_eq!(layout.metrics().line_count, 2);
270        // Line widths: 2 * 0.06 = 0.12, 4 * 0.06 = 0.24. Center shift for first
271        // line = (0.24 - 0.12) / 2 = 0.06. So first glyph x = 0.06.
272        let g0 = layout.glyphs()[0];
273        assert!(
274            (g0.origin.x - 0.06).abs() < 1e-5,
275            "g0.x={} expected 0.06",
276            g0.origin.x
277        );
278    }
279
280    #[test]
281    fn weight_and_italic_do_not_change_atlas_layout() {
282        // AtlasText is bitmap; weight + style are no-ops at this layer
283        // (FlexibleText surfaces them). This locks that contract.
284        let eng = engine();
285        let plain = eng.layout_concrete(&WispText::new("test"));
286        let bold_italic = eng.layout_concrete(
287            &WispText::new("test").with_style(
288                WispTextStyle::default()
289                    .with_weight(WispFontWeight::Bold)
290                    .italic(),
291            ),
292        );
293        assert_eq!(plain.glyphs().len(), bold_italic.glyphs().len());
294        for (a, b) in plain.glyphs().iter().zip(bold_italic.glyphs().iter()) {
295            assert!((a.origin.x - b.origin.x).abs() < 1e-6);
296            assert!((a.origin.y - b.origin.y).abs() < 1e-6);
297        }
298    }
299
300    #[test]
301    fn metrics_total_height_matches_line_count() {
302        let eng = engine();
303        let layout = eng.layout_concrete(&WispText::new("a\nb\nc"));
304        // 3 lines. height = 0.06 + 2 * 0.06 * 1.2 = 0.06 + 0.144 = 0.204.
305        let expected = 0.06_f32 + 2.0 * 0.06 * 1.2;
306        assert!(
307            (layout.metrics().total_height_ndc - expected).abs() < 1e-5,
308            "total={} expected={expected}",
309            layout.metrics().total_height_ndc
310        );
311    }
312}