1use glam::Vec2;
29
30use super::{WispText, WispTextAlign, WispTextEngine, WispTextLayout, WispTextMetrics};
31use crate::color::Color;
32use crate::scene::text::{Font, GlyphMetrics};
33
34#[derive(Debug, Clone, Copy, PartialEq)]
36pub struct AtlasGlyphInstance {
37 pub origin: Vec2,
42 pub width: f32,
44 pub height: f32,
46 pub uvs: GlyphMetrics,
48 pub color: Color,
50}
51
52#[derive(Debug, Clone)]
55pub struct AtlasTextLayout {
56 pub glyphs: Vec<AtlasGlyphInstance>,
59 metrics: WispTextMetrics,
60}
61
62impl AtlasTextLayout {
63 #[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#[derive(Debug, Clone)]
80pub struct AtlasTextEngine {
81 font: Font,
82}
83
84impl AtlasTextEngine {
85 #[must_use]
87 pub fn new(font: Font) -> Self {
88 Self { font }
89 }
90
91 #[must_use]
93 pub fn font(&self) -> &Font {
94 &self.font
95 }
96
97 #[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 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 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 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 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 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 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 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}