1use crate::components::{LabelBox, SpriteFit, TextAlign, TextLabel};
6use crate::ecs::FontHandle;
7use crate::gfx::overlay::OverlayTransform;
8use crate::gfx::render_types::{TextDrawCall, TextVertex};
9use crate::render::overlay_maps::{ClipRects, OverlayLayers};
10use alloc::borrow::Cow;
11use alloc::string::String;
12use alloc::vec::Vec;
13use hashbrown::HashMap;
14
15pub type FontMetrics = HashMap<u32, crate::gfx::font::GlyphMetrics>;
17
18pub struct LoadedFont {
20 pub atlas_slot: usize,
22 pub metrics: FontMetrics,
24 pub atlas_w: u32,
26 pub atlas_h: u32,
28 pub size_px: f32,
30 pub cap_px: f32,
35 pub supersample: f32,
40}
41
42#[derive(Default)]
50pub struct FontSet {
51 faces: HashMap<FontHandle, LoadedFont>,
52 fallback: Option<FontHandle>,
53}
54
55impl FontSet {
56 pub fn insert(&mut self, handle: FontHandle, font: LoadedFont) {
58 self.faces.insert(handle, font);
59 }
60
61 pub fn set_fallback(&mut self, handle: FontHandle) {
63 self.fallback = Some(handle);
64 }
65
66 pub fn get(&self, handle: FontHandle) -> Option<&LoadedFont> {
68 self.faces.get(&handle)
69 }
70
71 pub fn resolve(&self, font: Option<FontHandle>) -> Option<&LoadedFont> {
74 font.and_then(|h| self.faces.get(&h))
75 .or_else(|| self.fallback.and_then(|h| self.faces.get(&h)))
76 }
77
78 pub fn len(&self) -> usize {
80 self.faces.len()
81 }
82
83 pub fn is_empty(&self) -> bool {
85 self.faces.is_empty()
86 }
87
88 pub fn any_atlas_slot(&self) -> Option<usize> {
91 self.faces.values().next().map(|f| f.atlas_slot)
92 }
93}
94
95pub fn derive_cap_px(metrics: &FontMetrics, size_px: f32) -> f32 {
99 if let Some(h) = metrics.get(&('H' as u32))
100 && h.bearing_y > 0.0
101 {
102 return h.bearing_y;
103 }
104 let max_upper = ('A'..='Z')
105 .filter_map(|c| metrics.get(&(c as u32)))
106 .map(|m| m.bearing_y)
107 .fold(0.0_f32, f32::max);
108 if max_upper > 0.0 {
109 return max_upper;
110 }
111 0.7 * size_px
112}
113
114pub fn widest_line_width(content: &str, font: &LoadedFont, scale: f32) -> f32 {
117 content
118 .split('\n')
119 .map(|line| text_advance_width(line, font, scale))
120 .fold(0.0_f32, f32::max)
121}
122
123pub fn text_advance_width(content: &str, font: &LoadedFont, scale: f32) -> f32 {
127 content
128 .chars()
129 .filter(|&ch| ch != '\n')
130 .map(|ch| advance_px(ch, font, scale))
131 .sum()
132}
133
134fn advance_px(ch: char, font: &LoadedFont, scale: f32) -> f32 {
137 font.metrics
138 .get(&(ch as u32))
139 .map(|m| m.advance_px * scale)
140 .unwrap_or_else(|| {
141 font.metrics
142 .get(&(b' ' as u32))
143 .map(|m| m.advance_px * scale)
144 .unwrap_or(0.0)
145 })
146}
147
148fn laid_out<'a>(label: &'a TextLabel, font: &LoadedFont) -> Cow<'a, str> {
156 if label.centered || (label.wrap_width <= 0.0 && label.max_lines == 0) {
157 return Cow::Borrowed(&label.content);
158 }
159 let mut lines: Vec<&str> = Vec::new();
160 let mut authored_lines = 0usize;
161 for authored in label.content.split('\n') {
162 authored_lines += 1;
163 if label.wrap_width > 0.0 {
164 wrap_line(authored, font, label.scale, label.wrap_width, &mut lines);
165 } else {
166 lines.push(authored);
167 }
168 }
169 let max = label.max_lines as usize;
170 let truncated = max > 0 && lines.len() > max;
171 if truncated {
172 lines.truncate(max);
173 }
174 if !truncated && lines.len() == authored_lines {
175 return Cow::Borrowed(&label.content);
176 }
177 let ellipsized = truncated
178 .then(|| lines.pop())
179 .flatten()
180 .map(|last| with_ellipsis(last, font, label.scale, label.wrap_width));
181 let mut out = String::with_capacity(label.content.len() + 4);
182 for (i, line) in lines.iter().enumerate() {
183 if i > 0 {
184 out.push('\n');
185 }
186 out.push_str(line);
187 }
188 if let Some(last) = ellipsized {
189 if !lines.is_empty() {
190 out.push('\n');
191 }
192 out.push_str(&last);
193 }
194 Cow::Owned(out)
195}
196
197fn wrap_line<'a>(line: &'a str, font: &LoadedFont, scale: f32, width: f32, out: &mut Vec<&'a str>) {
203 let advance = |ch: char| advance_px(ch, font, scale);
204 let (mut start, mut end) = (0usize, 0usize);
206 let mut current_width = 0.0_f32;
207 let mut pos = 0usize;
209 for word in line.split(' ') {
210 let word_end = pos + word.len();
211 let (cand_start, cand_width) = if end > start {
214 let mut w = current_width;
215 for ch in line[end..word_end].chars() {
216 w += advance(ch);
217 }
218 (start, w)
219 } else {
220 let mut w = 0.0_f32;
221 for ch in word.chars() {
222 w += advance(ch);
223 }
224 (pos, w)
225 };
226 if cand_width <= width {
227 (start, end, current_width) = (cand_start, word_end, cand_width);
228 pos = word_end + 1;
229 continue;
230 }
231 if end > start {
232 out.push(&line[start..end]);
233 }
234 (start, end) = (pos, word_end);
236 loop {
237 let (mut w, mut chars) = (0.0_f32, 0usize);
238 for ch in line[start..end].chars() {
239 w += advance(ch);
240 chars += 1;
241 }
242 if w <= width || chars <= 1 {
243 current_width = w;
244 break;
245 }
246 let mut acc = 0.0_f32;
248 let mut head_end = start;
249 for (i, ch) in line[start..end].char_indices() {
250 let next = acc + advance(ch);
251 if head_end > start && next > width {
252 break;
253 }
254 acc = next;
255 head_end = start + i + ch.len_utf8();
256 }
257 out.push(&line[start..head_end]);
258 start = head_end;
259 }
260 pos = word_end + 1;
261 }
262 out.push(&line[start..end]);
263}
264
265fn with_ellipsis(line: &str, font: &LoadedFont, scale: f32, width: f32) -> String {
272 const ELLIPSIS: &str = "...";
273 let mut out = String::with_capacity(line.len() + ELLIPSIS.len());
274 let mut end = line.len();
275 if width > 0.0 {
276 let ellipsis_w: f32 = ELLIPSIS.chars().map(|ch| advance_px(ch, font, scale)).sum();
277 end = 0;
278 let mut prefix_w = 0.0_f32;
279 for (i, ch) in line.char_indices() {
280 let w = prefix_w + advance_px(ch, font, scale);
281 if w + ellipsis_w > width {
282 break;
283 }
284 prefix_w = w;
285 end = i + ch.len_utf8();
286 }
287 }
288 out.push_str(&line[..end]);
289 out.push_str(ELLIPSIS);
290 out
291}
292
293fn baseline_offset(font: &LoadedFont, scale: f32) -> f32 {
298 let line_height = font.size_px * scale;
299 (line_height + font.cap_px * scale) / 2.0
300}
301
302fn content_v_extent(content: &str, font: &LoadedFont, scale: f32) -> (f32, f32) {
308 let mut top_above = 0.0_f32;
309 let mut bot_below = 0.0_f32;
310 for ch in content.chars() {
311 if ch == '\n' {
312 continue;
313 }
314 if let Some(m) = font.metrics.get(&(ch as u32)) {
315 if m.atlas_h == 0 {
316 continue;
317 }
318 top_above = top_above.max(m.bearing_y * scale);
319 bot_below = bot_below.max((m.atlas_h as f32 / font.supersample - m.bearing_y) * scale);
320 }
321 }
322 (top_above, bot_below)
323}
324
325pub fn measure_label_box(label: &TextLabel, loaded_fonts: &FontSet) -> Option<LabelBox> {
333 if !label.visible {
334 return None;
335 }
336 let font = loaded_fonts.resolve(label.font)?;
337 let scale = label.scale;
338 let line_height = font.size_px * scale;
339 let content = laid_out(label, font);
340 let lines = content.split('\n').count().max(1) as f32;
341 let text_w = widest_line_width(&content, font, scale);
342 let pad = label.padding;
343 let (top_above, bot_below) = content_v_extent(&content, font, scale);
344 let base_off = baseline_offset(font, scale);
345 Some(LabelBox {
346 w: text_w + 2.0 * pad,
347 h: top_above + bot_below + (lines - 1.0) * line_height + 2.0 * pad,
348 pad,
349 top_inset: top_above + pad - base_off,
352 })
353}
354
355pub fn build_text_calls(
362 labels: &[TextLabel],
363 loaded_fonts: &FontSet,
364 win_w: f32,
365 win_h: f32,
366 clips: &ClipRects,
367 layers: &OverlayLayers,
368) -> Vec<TextDrawCall> {
369 let mut out = crate::render::call_buffer::TextCallBuffer::default();
370 build_text_calls_into(&mut out, labels, loaded_fonts, win_w, win_h, clips, layers);
371 out.take()
372}
373
374pub fn build_text_calls_into(
378 out: &mut crate::render::call_buffer::TextCallBuffer,
379 labels: &[TextLabel],
380 loaded_fonts: &FontSet,
381 win_w: f32,
382 win_h: f32,
383 clips: &ClipRects,
384 layers: &OverlayLayers,
385) {
386 let overlay = OverlayTransform::from_viewport([win_w, win_h]);
390 let bottom = OverlayTransform::bottom_anchored_from_viewport([win_w, win_h]);
392 let cover = OverlayTransform::cover_from_viewport([win_w, win_h]);
393 for label in labels {
394 if !label.visible {
395 continue;
396 }
397 let font = match loaded_fonts.resolve(label.font) {
398 Some(f) => f,
399 None => continue,
400 };
401 let content = laid_out(label, font);
405 let quads = content.len() + 1;
408 let (mut vertices, mut indices) = out.geometry();
409 vertices.reserve(4 * quads);
410 indices.reserve(6 * quads);
411
412 let mut widest_line: Option<f32> = None;
418 let (x0, y0, scale) = if label.centered && win_w > 0.0 && win_h > 0.0 {
419 let w1 = text_advance_width(&content, font, 1.0);
420 let h1 = font.size_px;
421 let scale = if w1 > 0.0 && h1 > 0.0 {
422 let sw = win_w * 0.85 / w1;
423 let sh = win_h * 0.85 / h1;
424 sw.min(sh)
425 } else {
426 label.scale
427 };
428 let tw = text_advance_width(&content, font, scale);
429 let th = h1 * scale;
430 ((win_w - tw) / 2.0, (win_h - th) / 2.0, scale)
431 } else {
432 let (ax, ay, scale) = if label.screen.is_some() {
435 let t = match label.fit {
436 SpriteFit::Bottom => bottom,
437 SpriteFit::Cover => cover,
438 SpriteFit::Fit => overlay,
439 };
440 let (sx, sy) = t.forward(label.x, label.y);
441 (sx, sy, label.scale * t.scale())
442 } else {
443 (label.x, label.y, label.scale)
444 };
445 let x0 = match label.align {
449 TextAlign::Left => ax,
450 TextAlign::Center | TextAlign::Right => {
451 let w = widest_line_width(&content, font, scale);
452 widest_line = Some(w);
453 if label.align == TextAlign::Center {
454 ax - w / 2.0
455 } else {
456 ax - w
457 }
458 }
459 };
460 (x0, ay, scale)
461 };
462
463 let mut x_cursor = x0;
464 let line_height = font.size_px * scale;
469 let mut baseline = y0 + baseline_offset(font, scale);
470 let aw = font.atlas_w as f32;
471 let ah = font.atlas_h as f32;
472
473 if label.background[3] > 0.0 && !content.is_empty() {
481 let lines = content.split('\n').count().max(1) as f32;
482 let text_w = widest_line.unwrap_or_else(|| widest_line_width(&content, font, scale));
483 let pad = label.padding;
484 let (top_above, bot_below) = content_v_extent(&content, font, scale);
485 let last_baseline = baseline + (lines - 1.0) * line_height;
486 let (x0b, y0b) = (x0 - pad, baseline - top_above - pad);
487 let (x1b, y1b) = (x0 + text_w + pad, last_baseline + bot_below + pad);
488 let bg = [
489 label.background[0],
490 label.background[1],
491 label.background[2],
492 ];
493 let ba = label.background[3];
494 let box_vtx = |x: f32, y: f32| TextVertex {
495 pos: [x, y],
496 uv: [-1.0, ba],
497 color: bg,
498 mode: 0.0,
499 };
500 vertices.extend_from_slice(&[
501 box_vtx(x0b, y0b),
502 box_vtx(x1b, y0b),
503 box_vtx(x1b, y1b),
504 box_vtx(x0b, y1b),
505 ]);
506 indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
507 }
508
509 for ch in content.chars() {
510 if ch == '\n' {
511 x_cursor = x0;
512 baseline += line_height;
513 continue;
514 }
515 let m = match font.metrics.get(&(ch as u32)) {
516 Some(m) => m,
517 None => {
518 if let Some(sp) = font.metrics.get(&(b' ' as u32)) {
519 x_cursor += sp.advance_px * scale;
520 }
521 continue;
522 }
523 };
524 if m.atlas_w == 0 || m.atlas_h == 0 {
525 x_cursor += m.advance_px * scale;
526 continue;
527 }
528 let gw = m.atlas_w as f32 / font.supersample * scale;
532 let gh = m.atlas_h as f32 / font.supersample * scale;
533 let gx = x_cursor + m.bearing_x * scale;
534 let gy = baseline - m.bearing_y * scale;
535 let u0 = m.atlas_x as f32 / aw;
536 let v0 = m.atlas_y as f32 / ah;
537 let u1 = (m.atlas_x as f32 + m.atlas_w as f32) / aw;
538 let v1 = (m.atlas_y as f32 + m.atlas_h as f32) / ah;
539 let base = vertices.len() as u16;
540 vertices.extend_from_slice(&[
541 TextVertex {
542 pos: [gx, gy],
543 uv: [u0, v0],
544 color: label.color,
545 mode: 0.0,
546 },
547 TextVertex {
548 pos: [gx + gw, gy],
549 uv: [u1, v0],
550 color: label.color,
551 mode: 0.0,
552 },
553 TextVertex {
554 pos: [gx + gw, gy + gh],
555 uv: [u1, v1],
556 color: label.color,
557 mode: 0.0,
558 },
559 TextVertex {
560 pos: [gx, gy + gh],
561 uv: [u0, v1],
562 color: label.color,
563 mode: 0.0,
564 },
565 ]);
566 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
567 x_cursor += m.advance_px * scale;
568 }
569 if vertices.is_empty() {
570 out.park(vertices, indices);
571 } else {
572 out.calls.push(TextDrawCall {
573 vertices,
574 indices,
575 atlas_slot: font.atlas_slot,
576 clip_rect: clips
577 .get(&label.asset_id)
578 .map(|b| band_to_window(&overlay, *b)),
579 layer: layers.get(&label.asset_id).copied().unwrap_or(0),
580 });
581 }
582 }
583}
584
585pub(crate) fn band_to_window(overlay: &OverlayTransform, band: [f32; 4]) -> [f32; 4] {
588 let (x0, y0) = overlay.forward(band[0], band[1]);
589 let (x1, y1) = overlay.forward(band[0] + band[2], band[1] + band[3]);
590 [x0, y0, x1 - x0, y1 - y0]
591}
592
593#[cfg(test)]
594mod tests {
595 use super::*;
596 use crate::ecs::asset_id::AssetId;
597 use crate::gfx::font::GlyphMetrics;
598
599 use alloc::string::ToString;
600 fn no_clips() -> ClipRects {
602 ClipRects::new()
603 }
604 fn no_layers() -> OverlayLayers {
605 OverlayLayers::new()
606 }
607
608 fn make_glyph(atlas_w: u16, atlas_h: u16, advance_px: f32) -> GlyphMetrics {
609 GlyphMetrics {
610 char_code: 0,
611 atlas_x: 0,
612 atlas_y: 0,
613 atlas_w,
614 atlas_h,
615 advance_px,
616 bearing_x: 0.0,
617 bearing_y: atlas_h as f32,
618 }
619 }
620
621 fn make_font(chars: &[(char, GlyphMetrics)]) -> LoadedFont {
622 let metrics: FontMetrics = chars.iter().map(|(c, m)| (*c as u32, *m)).collect();
623 let cap_px = derive_cap_px(&metrics, 16.0);
624 LoadedFont {
625 atlas_slot: 0,
626 cap_px,
627 metrics,
628 atlas_w: 128,
629 atlas_h: 128,
630 size_px: 16.0,
631 supersample: 1.0,
633 }
634 }
635
636 fn make_label(font: FontHandle, content: &str, x: f32) -> TextLabel {
637 TextLabel {
638 asset_id: AssetId::default(),
639 font: Some(font),
640 content: content.to_string(),
641 x,
642 y: 0.0,
643 color: [1.0, 1.0, 1.0],
644 scale: 1.0,
645 centered: false,
646 align: crate::components::TextAlign::Left,
647 fit: crate::components::SpriteFit::Fit,
648 background: [0.0, 0.0, 0.0, 0.0],
649 padding: 0.0,
650 wrap_width: 0.0,
651 max_lines: 0,
652 visible: true,
653 screen: None,
654 }
655 }
656
657 fn even_font() -> LoadedFont {
660 let glyphs: Vec<(char, GlyphMetrics)> = ('a'..='z')
661 .chain(['A', ' ', '-', '.', '\''])
662 .map(|c| (c, make_glyph(8, 8, 10.0)))
663 .collect();
664 make_font(&glyphs)
665 }
666
667 fn wrapped(content: &str, width: f32, max_lines: u32) -> Vec<String> {
668 let font = even_font();
669 let mut label = make_label(FontHandle(0), content, 0.0);
670 label.wrap_width = width;
671 label.max_lines = max_lines;
672 laid_out(&label, &font)
673 .split('\n')
674 .map(String::from)
675 .collect()
676 }
677
678 #[test]
679 fn text_wraps_at_word_boundaries_within_its_width() {
680 assert_eq!(wrapped("aaa bbb", 50.0, 0), ["aaa", "bbb"]);
682 assert_eq!(wrapped("aa bb", 50.0, 0), ["aa bb"]);
684 assert_eq!(wrapped("aa bb cc", 50.0, 0), ["aa bb", "cc"]);
685 }
686
687 #[test]
688 fn authored_newlines_stay_breaks_and_wrap_within_themselves() {
689 assert_eq!(wrapped("aa\nbb cc dd", 50.0, 0), ["aa", "bb cc", "dd"]);
690 }
691
692 #[test]
693 fn a_word_too_long_for_a_line_splits_rather_than_overflowing() {
694 assert_eq!(wrapped("aaaaaaaa", 50.0, 0), ["aaaaa", "aaa"]);
695 let font = even_font();
697 for line in wrapped("aaaaaaaaaaaaaa bb", 50.0, 0) {
698 assert!(text_advance_width(&line, &font, 1.0) <= 50.0, "{line:?}");
699 }
700 }
701
702 #[test]
703 fn max_lines_cuts_the_overflow_with_an_ellipsis_that_still_fits() {
704 assert_eq!(wrapped("aa bb cc dd ee ff", 50.0, 0).len(), 3);
706 let lines = wrapped("aa bb cc dd ee ff", 50.0, 2);
707 assert_eq!(lines.len(), 2);
708 assert!(lines[1].ends_with("..."), "{lines:?}");
709 let font = even_font();
710 for line in &lines {
711 assert!(text_advance_width(line, &font, 1.0) <= 50.0, "{line:?}");
712 }
713 }
714
715 #[test]
716 fn text_that_fits_is_left_exactly_as_it_was() {
717 assert_eq!(wrapped("aa bb", 500.0, 4), ["aa bb"]);
718 assert_eq!(wrapped("", 50.0, 2), [""]);
719 }
720
721 #[test]
725 fn a_wrapped_label_draws_and_measures_the_lines_it_wrapped_to() {
726 let font_id = FontHandle(0);
727 let mut fonts = FontSet::default();
728 fonts.insert(font_id, even_font());
729 let mut label = make_label(font_id, "aa bb cc", 0.0);
730 label.wrap_width = 50.0;
731 label.background = [0.0, 0.0, 0.0, 1.0];
732
733 let boxed = measure_label_box(&label, &fonts).unwrap();
734 assert!(boxed.w <= 50.0, "{boxed:?}");
736 let calls = build_text_calls(
737 core::slice::from_ref(&label),
738 &fonts,
739 200.0,
740 200.0,
741 &no_clips(),
742 &no_layers(),
743 );
744 let right = calls[0]
745 .vertices
746 .iter()
747 .map(|v| v.pos[0])
748 .fold(f32::MIN, f32::max);
749 assert!(right <= 50.0, "glyphs ran past the wrap width: {right}");
750 }
751
752 #[test]
753 fn empty_labels_returns_empty_calls() {
754 let fonts = FontSet::default();
755 assert!(build_text_calls(&[], &fonts, 0.0, 0.0, &no_clips(), &no_layers()).is_empty());
756 }
757
758 #[test]
762 fn a_label_naming_no_font_draws_with_the_fallback() {
763 let g = make_glyph(8, 8, 10.0);
764 let mut fonts = FontSet::default();
765 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
766 let mut label = make_label(FontHandle(0), "A", 0.0);
767 label.font = None;
768
769 let calls = build_text_calls(
771 core::slice::from_ref(&label),
772 &fonts,
773 0.0,
774 0.0,
775 &no_clips(),
776 &no_layers(),
777 );
778 assert!(calls.is_empty());
779 assert!(measure_label_box(&label, &fonts).is_none());
780
781 fonts.set_fallback(FontHandle(0));
782 let calls = build_text_calls(
783 core::slice::from_ref(&label),
784 &fonts,
785 0.0,
786 0.0,
787 &no_clips(),
788 &no_layers(),
789 );
790 assert_eq!(calls.len(), 1);
791 assert!(measure_label_box(&label, &fonts).is_some());
792 }
793
794 #[test]
797 fn a_named_font_wins_over_the_fallback() {
798 let wide = make_glyph(8, 8, 20.0);
799 let narrow = make_glyph(8, 8, 5.0);
800 let mut fonts = FontSet::default();
801 fonts.insert(FontHandle(0), make_font(&[('A', wide)]));
802 fonts.insert(FontHandle(1), make_font(&[('A', narrow)]));
803 fonts.set_fallback(FontHandle(0));
804
805 let label = make_label(FontHandle(1), "AA", 0.0);
806 assert_eq!(measure_label_box(&label, &fonts).unwrap().w, 10.0);
807 }
808
809 #[test]
812 fn an_unloaded_font_falls_back() {
813 let g = make_glyph(8, 8, 10.0);
814 let mut fonts = FontSet::default();
815 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
816 fonts.set_fallback(FontHandle(0));
817
818 let label = make_label(FontHandle(99), "A", 0.0);
819 assert!(measure_label_box(&label, &fonts).is_some());
820 }
821
822 #[test]
823 fn unknown_font_produces_no_call() {
824 let fonts = FontSet::default();
825 let label = make_label(FontHandle(99), "hello", 0.0);
826 assert!(
827 build_text_calls(
828 core::slice::from_ref(&label),
829 &fonts,
830 0.0,
831 0.0,
832 &no_clips(),
833 &no_layers()
834 )
835 .is_empty()
836 );
837 }
838
839 #[test]
840 fn single_glyph_produces_quad() {
841 let g = make_glyph(10, 12, 11.0);
842 let mut fonts = FontSet::default();
843 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
844 let label = make_label(FontHandle(0), "A", 0.0);
845 let calls = build_text_calls(
846 core::slice::from_ref(&label),
847 &fonts,
848 0.0,
849 0.0,
850 &no_clips(),
851 &no_layers(),
852 );
853 assert_eq!(calls.len(), 1);
854 assert_eq!(calls[0].vertices.len(), 4);
855 assert_eq!(calls[0].indices.len(), 6);
856 assert_eq!(calls[0].atlas_slot, 0);
857 }
858
859 #[test]
860 fn background_prepends_a_box_quad() {
861 let g = make_glyph(10, 12, 11.0);
862 let mut fonts = FontSet::default();
863 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
864 let mut label = make_label(FontHandle(0), "A", 0.0);
865 label.background = [0.0, 0.3, 0.1, 0.85];
866 label.padding = 4.0;
867 let calls = build_text_calls(
868 core::slice::from_ref(&label),
869 &fonts,
870 0.0,
871 0.0,
872 &no_clips(),
873 &no_layers(),
874 );
875 assert_eq!(calls.len(), 1);
876 assert_eq!(calls[0].vertices.len(), 8);
878 assert_eq!(calls[0].indices.len(), 12);
879 for v in &calls[0].vertices[..4] {
881 assert!(v.uv[0] < 0.0, "box vert should carry the sentinel u");
882 assert!((v.uv[1] - 0.85).abs() < 1e-4, "box alpha travels in v");
883 }
884 assert!(calls[0].vertices[4].uv[0] >= 0.0);
886 }
887
888 #[test]
889 fn derive_cap_px_uses_uppercase_reference() {
890 let mut m = HashMap::new();
892 m.insert('H' as u32, make_glyph(8, 10, 9.0)); m.insert('g' as u32, make_glyph(8, 14, 9.0)); assert!((derive_cap_px(&m, 16.0) - 10.0).abs() < 1e-4);
895 let empty = HashMap::new();
897 assert!((derive_cap_px(&empty, 20.0) - 14.0).abs() < 1e-4);
898 }
899
900 #[test]
901 fn background_box_hugs_glyph_with_symmetric_padding() {
902 let g = make_glyph(10, 12, 11.0); let mut fonts = FontSet::default();
906 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
907 let mut label = make_label(FontHandle(0), "A", 0.0);
908 label.background = [0.1, 0.1, 0.1, 1.0];
909 label.padding = 4.0;
910 let calls = build_text_calls(
911 core::slice::from_ref(&label),
912 &fonts,
913 0.0,
914 0.0,
915 &no_clips(),
916 &no_layers(),
917 );
918 let v = &calls[0].vertices;
919 let (box_top, box_bot) = (v[0].pos[1], v[2].pos[1]);
921 let (glyph_top, glyph_bot) = (v[4].pos[1], v[6].pos[1]);
922 assert!(
923 (glyph_top - box_top - 4.0).abs() < 1e-4,
924 "top pad = {}",
925 glyph_top - box_top
926 );
927 assert!(
928 (box_bot - glyph_bot - 4.0).abs() < 1e-4,
929 "bottom pad = {}",
930 box_bot - glyph_bot
931 );
932 }
933
934 #[test]
935 fn background_with_empty_content_draws_nothing() {
936 let g = make_glyph(10, 12, 11.0);
937 let mut fonts = FontSet::default();
938 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
939 let mut label = make_label(FontHandle(0), "", 0.0);
940 label.background = [0.0, 0.3, 0.1, 0.85];
941 assert!(
943 build_text_calls(
944 core::slice::from_ref(&label),
945 &fonts,
946 0.0,
947 0.0,
948 &no_clips(),
949 &no_layers()
950 )
951 .is_empty()
952 );
953 }
954
955 #[test]
956 fn space_advances_cursor_without_quad() {
957 let space = make_glyph(0, 0, 8.0);
958 let g = make_glyph(10, 12, 11.0);
959 let mut fonts = FontSet::default();
960 fonts.insert(FontHandle(0), make_font(&[(' ', space), ('A', g)]));
961 let label = make_label(FontHandle(0), " A", 0.0);
963 let calls = build_text_calls(
964 core::slice::from_ref(&label),
965 &fonts,
966 0.0,
967 0.0,
968 &no_clips(),
969 &no_layers(),
970 );
971 assert_eq!(calls.len(), 1);
972 assert_eq!(calls[0].vertices.len(), 4);
973 let gx = calls[0].vertices[0].pos[0];
975 assert!((gx - 16.0).abs() < 1e-4, "expected gx=16.0, got {gx}");
976 }
977
978 #[test]
979 fn zero_size_glyph_advances_cursor_without_quad() {
980 let zero = GlyphMetrics {
982 char_code: b'X' as u32,
983 atlas_x: 0,
984 atlas_y: 0,
985 atlas_w: 0,
986 atlas_h: 0,
987 advance_px: 5.0,
988 bearing_x: 0.0,
989 bearing_y: 0.0,
990 };
991 let g = make_glyph(10, 12, 11.0);
992 let mut fonts = FontSet::default();
993 fonts.insert(FontHandle(0), make_font(&[('X', zero), ('A', g)]));
994 let label = make_label(FontHandle(0), "XA", 0.0);
995 let calls = build_text_calls(
996 core::slice::from_ref(&label),
997 &fonts,
998 0.0,
999 0.0,
1000 &no_clips(),
1001 &no_layers(),
1002 );
1003 assert_eq!(calls.len(), 1);
1004 assert_eq!(calls[0].vertices.len(), 4); assert!((calls[0].vertices[0].pos[0] - 5.0).abs() < 1e-4);
1007 }
1008
1009 #[test]
1010 fn newline_starts_a_new_line() {
1011 let g = make_glyph(10, 12, 11.0);
1014 let mut fonts = FontSet::default();
1015 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1016 let label = make_label(FontHandle(0), "A\nA", 0.0);
1017 let calls = build_text_calls(
1018 core::slice::from_ref(&label),
1019 &fonts,
1020 0.0,
1021 0.0,
1022 &no_clips(),
1023 &no_layers(),
1024 );
1025 assert_eq!(calls.len(), 1);
1026 assert_eq!(calls[0].vertices.len(), 8);
1028 assert_eq!(calls[0].indices.len(), 12);
1029 let first = &calls[0].vertices[0];
1030 let second = &calls[0].vertices[4];
1031 assert!((first.pos[0] - second.pos[0]).abs() < 1e-4);
1033 assert!(
1035 (second.pos[1] - first.pos[1] - 16.0).abs() < 1e-4,
1036 "expected +16 line height, got {}",
1037 second.pos[1] - first.pos[1]
1038 );
1039 }
1040
1041 #[test]
1042 fn centered_label_is_repositioned() {
1043 let g = make_glyph(10, 12, 20.0);
1044 let mut fonts = FontSet::default();
1045 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1046 let mut label = make_label(FontHandle(0), "A", 0.0);
1047 label.centered = true;
1048 let calls = build_text_calls(
1056 core::slice::from_ref(&label),
1057 &fonts,
1058 200.0,
1059 100.0,
1060 &no_clips(),
1061 &no_layers(),
1062 );
1063 assert_eq!(calls.len(), 1);
1064 let v = &calls[0].vertices[0];
1065 assert!((v.pos[0] - 46.875).abs() < 1e-3, "gx={}", v.pos[0]);
1066 assert!((v.pos[1] - 18.125).abs() < 1e-3, "gy={}", v.pos[1]);
1067 }
1068
1069 #[test]
1070 fn view_owned_label_scales_and_repositions_with_overlay() {
1071 let g = make_glyph(10, 12, 20.0);
1076 let mut fonts = FontSet::default();
1077 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1078
1079 let hud = make_label(FontHandle(0), "A", 100.0); let mut overlay_label = make_label(FontHandle(0), "A", 100.0);
1081 overlay_label.y = 100.0;
1082 overlay_label.screen = Some(AssetId(5));
1083
1084 let vp = (2560.0, 1440.0);
1086 let hud_calls = build_text_calls(
1087 core::slice::from_ref(&hud),
1088 &fonts,
1089 vp.0,
1090 vp.1,
1091 &no_clips(),
1092 &no_layers(),
1093 );
1094 let ovl_calls = build_text_calls(
1095 core::slice::from_ref(&overlay_label),
1096 &fonts,
1097 vp.0,
1098 vp.1,
1099 &no_clips(),
1100 &no_layers(),
1101 );
1102 assert!((hud_calls[0].vertices[0].pos[0] - 100.0).abs() < 1e-3);
1104 assert!(
1106 (ovl_calls[0].vertices[0].pos[0] - 200.0).abs() < 1e-3,
1107 "x={}",
1108 ovl_calls[0].vertices[0].pos[0]
1109 );
1110 let w = ovl_calls[0].vertices[1].pos[0] - ovl_calls[0].vertices[0].pos[0];
1112 assert!((w - 20.0).abs() < 1e-3, "w={w}");
1113 }
1114
1115 #[test]
1116 fn measure_label_box_grows_text_by_padding() {
1117 let g = make_glyph(10, 12, 11.0);
1118 let mut fonts = FontSet::default();
1119 fonts.insert(FontHandle(0), make_font(&[('A', g), ('B', g)]));
1120 let mut label = make_label(FontHandle(0), "AB", 0.0);
1121 label.padding = 4.0;
1122 let b = measure_label_box(&label, &fonts).unwrap();
1123 assert!((b.w - 30.0).abs() < 1e-4, "w={}", b.w);
1125 assert!((b.h - 20.0).abs() < 1e-4, "h={}", b.h);
1127 assert!((b.pad - 4.0).abs() < 1e-4);
1128 assert!(
1130 (b.top_inset - 2.0).abs() < 1e-4,
1131 "top_inset={}",
1132 b.top_inset
1133 );
1134 }
1135
1136 #[test]
1137 fn measure_label_box_skips_hidden_and_unloaded() {
1138 let g = make_glyph(10, 12, 11.0);
1139 let mut fonts = FontSet::default();
1140 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1141 let mut hidden = make_label(FontHandle(0), "A", 0.0);
1143 hidden.visible = false;
1144 assert!(measure_label_box(&hidden, &fonts).is_none());
1145 let orphan = make_label(FontHandle(99), "A", 0.0);
1147 assert!(measure_label_box(&orphan, &fonts).is_none());
1148 }
1149
1150 #[test]
1151 fn align_center_and_right_shift_the_anchor() {
1152 let g = make_glyph(10, 12, 10.0);
1157 let mut fonts = FontSet::default();
1158 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1159 let first_x = |align: TextAlign| {
1160 let mut l = make_label(FontHandle(0), "AA", 100.0);
1161 l.align = align;
1162 build_text_calls(
1163 core::slice::from_ref(&l),
1164 &fonts,
1165 0.0,
1166 0.0,
1167 &no_clips(),
1168 &no_layers(),
1169 )[0]
1170 .vertices[0]
1171 .pos[0]
1172 };
1173 assert!((first_x(TextAlign::Left) - 100.0).abs() < 1e-4);
1174 assert!((first_x(TextAlign::Center) - 90.0).abs() < 1e-4);
1175 assert!((first_x(TextAlign::Right) - 80.0).abs() < 1e-4);
1176 }
1177
1178 #[test]
1179 fn clip_band_scissors_the_call() {
1180 let g = make_glyph(10, 12, 11.0);
1184 let mut fonts = FontSet::default();
1185 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1186 let mut label = make_label(FontHandle(0), "A", 0.0);
1187 label.asset_id = AssetId(7);
1188 let mut clips = ClipRects::new();
1189 let band = [10.0, 20.0, 300.0, 40.0];
1190 clips.insert(AssetId(7), band);
1191 let calls = build_text_calls(
1192 core::slice::from_ref(&label),
1193 &fonts,
1194 1280.0,
1195 720.0,
1196 &clips,
1197 &no_layers(),
1198 );
1199 assert_eq!(calls.len(), 1);
1200 assert_eq!(calls[0].clip_rect, Some(band));
1201 let other = make_label(FontHandle(0), "A", 0.0);
1203 let unclipped = build_text_calls(
1204 core::slice::from_ref(&other),
1205 &fonts,
1206 1280.0,
1207 720.0,
1208 &clips,
1209 &no_layers(),
1210 );
1211 assert_eq!(unclipped[0].clip_rect, None);
1212 }
1213
1214 #[test]
1215 fn band_to_window_maps_through_the_overlay() {
1216 let overlay = OverlayTransform::from_viewport([2560.0, 1440.0]);
1218 let mapped = band_to_window(&overlay, [640.0, 360.0, 100.0, 50.0]);
1219 assert!((mapped[0] - 1280.0).abs() < 1e-3, "x={}", mapped[0]);
1221 assert!((mapped[1] - 720.0).abs() < 1e-3, "y={}", mapped[1]);
1222 assert!((mapped[2] - 200.0).abs() < 1e-3, "w={}", mapped[2]);
1223 assert!((mapped[3] - 100.0).abs() < 1e-3, "h={}", mapped[3]);
1224 }
1225
1226 #[test]
1227 fn fit_bottom_and_cover_map_view_owned_labels() {
1228 let g = make_glyph(10, 12, 11.0);
1233 let mut fonts = FontSet::default();
1234 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1235 let vp = (1024.0, 768.0);
1236 let first_y = |fit: SpriteFit| {
1237 let mut l = make_label(FontHandle(0), "A", 100.0);
1238 l.y = 600.0;
1239 l.screen = Some(AssetId(5));
1240 l.fit = fit;
1241 build_text_calls(
1242 core::slice::from_ref(&l),
1243 &fonts,
1244 vp.0,
1245 vp.1,
1246 &no_clips(),
1247 &no_layers(),
1248 )[0]
1249 .vertices[0]
1250 .pos[1]
1251 };
1252 let fit_y = first_y(SpriteFit::Fit);
1253 let bottom_y = first_y(SpriteFit::Bottom);
1254 let cover_y = first_y(SpriteFit::Cover);
1255 assert!(bottom_y > fit_y, "bottom={bottom_y} fit={fit_y}");
1256 assert!(
1257 (cover_y - fit_y).abs() > 1e-3,
1258 "cover={cover_y} fit={fit_y}"
1259 );
1260 }
1261
1262 #[test]
1263 fn missing_glyph_falls_back_to_space_advance() {
1264 let space = make_glyph(0, 0, 7.0);
1267 let g = make_glyph(10, 12, 11.0);
1268 let mut fonts = FontSet::default();
1269 fonts.insert(FontHandle(0), make_font(&[(' ', space), ('A', g)]));
1270 let label = make_label(FontHandle(0), "?A", 0.0);
1272 let calls = build_text_calls(
1273 core::slice::from_ref(&label),
1274 &fonts,
1275 0.0,
1276 0.0,
1277 &no_clips(),
1278 &no_layers(),
1279 );
1280 assert_eq!(calls.len(), 1);
1281 assert_eq!(calls[0].vertices.len(), 4); assert!((calls[0].vertices[0].pos[0] - 7.0).abs() < 1e-4);
1283 }
1284
1285 #[test]
1286 fn measure_uses_space_advance_for_missing_glyphs() {
1287 let space = make_glyph(0, 0, 7.0);
1290 let g = make_glyph(10, 12, 11.0);
1291 let mut fonts = FontSet::default();
1292 fonts.insert(FontHandle(0), make_font(&[(' ', space), ('A', g)]));
1293 let known = make_label(FontHandle(0), "A", 0.0);
1294 let with_missing = make_label(FontHandle(0), "?A", 0.0);
1295 let wk = measure_label_box(&known, &fonts).unwrap().w;
1296 let wm = measure_label_box(&with_missing, &fonts).unwrap().w;
1297 assert!((wm - wk - 7.0).abs() < 1e-4, "wk={wk} wm={wm}");
1299 }
1300}