1use crate::components::{LabelBox, SpriteFit, TextAlign, TextLabel};
6use crate::ecs::FontHandle;
7use crate::overlay_maps::{ClipRects, OverlayLayers};
8use crate::render_types::{TextDrawCall, TextVertex};
9use alloc::borrow::Cow;
10use alloc::string::String;
11use alloc::vec::Vec;
12use concinnity_core::gfx::overlay::OverlayTransform;
13use hashbrown::HashMap;
14
15pub type FontMetrics = HashMap<u32, crate::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 text_advance_width(content: &str, font: &LoadedFont, scale: f32) -> f32 {
120 measure_text_width(content, font, scale)
121}
122
123fn measure_text_width(content: &str, font: &LoadedFont, scale: f32) -> f32 {
124 content
125 .chars()
126 .filter(|&ch| ch != '\n')
127 .map(|ch| advance_px(ch, font, scale))
128 .sum()
129}
130
131fn advance_px(ch: char, font: &LoadedFont, scale: f32) -> f32 {
134 font.metrics
135 .get(&(ch as u32))
136 .map(|m| m.advance_px * scale)
137 .unwrap_or_else(|| {
138 font.metrics
139 .get(&(b' ' as u32))
140 .map(|m| m.advance_px * scale)
141 .unwrap_or(0.0)
142 })
143}
144
145fn laid_out<'a>(label: &'a TextLabel, font: &LoadedFont) -> Cow<'a, str> {
153 if label.centered || (label.wrap_width <= 0.0 && label.max_lines == 0) {
154 return Cow::Borrowed(&label.content);
155 }
156 let mut lines: Vec<&str> = Vec::new();
157 let mut authored_lines = 0usize;
158 for authored in label.content.split('\n') {
159 authored_lines += 1;
160 if label.wrap_width > 0.0 {
161 wrap_line(authored, font, label.scale, label.wrap_width, &mut lines);
162 } else {
163 lines.push(authored);
164 }
165 }
166 let max = label.max_lines as usize;
167 let truncated = max > 0 && lines.len() > max;
168 if truncated {
169 lines.truncate(max);
170 }
171 if !truncated && lines.len() == authored_lines {
172 return Cow::Borrowed(&label.content);
173 }
174 let ellipsized = truncated
175 .then(|| lines.pop())
176 .flatten()
177 .map(|last| with_ellipsis(last, font, label.scale, label.wrap_width));
178 let mut out = String::with_capacity(label.content.len() + 4);
179 for (i, line) in lines.iter().enumerate() {
180 if i > 0 {
181 out.push('\n');
182 }
183 out.push_str(line);
184 }
185 if let Some(last) = ellipsized {
186 if !lines.is_empty() {
187 out.push('\n');
188 }
189 out.push_str(&last);
190 }
191 Cow::Owned(out)
192}
193
194fn wrap_line<'a>(line: &'a str, font: &LoadedFont, scale: f32, width: f32, out: &mut Vec<&'a str>) {
200 let advance = |ch: char| advance_px(ch, font, scale);
201 let (mut start, mut end) = (0usize, 0usize);
203 let mut current_width = 0.0_f32;
204 let mut pos = 0usize;
206 for word in line.split(' ') {
207 let word_end = pos + word.len();
208 let (cand_start, cand_width) = if end > start {
211 let mut w = current_width;
212 for ch in line[end..word_end].chars() {
213 w += advance(ch);
214 }
215 (start, w)
216 } else {
217 let mut w = 0.0_f32;
218 for ch in word.chars() {
219 w += advance(ch);
220 }
221 (pos, w)
222 };
223 if cand_width <= width {
224 (start, end, current_width) = (cand_start, word_end, cand_width);
225 pos = word_end + 1;
226 continue;
227 }
228 if end > start {
229 out.push(&line[start..end]);
230 }
231 (start, end) = (pos, word_end);
233 loop {
234 let (mut w, mut chars) = (0.0_f32, 0usize);
235 for ch in line[start..end].chars() {
236 w += advance(ch);
237 chars += 1;
238 }
239 if w <= width || chars <= 1 {
240 current_width = w;
241 break;
242 }
243 let mut acc = 0.0_f32;
245 let mut head_end = start;
246 for (i, ch) in line[start..end].char_indices() {
247 let next = acc + advance(ch);
248 if head_end > start && next > width {
249 break;
250 }
251 acc = next;
252 head_end = start + i + ch.len_utf8();
253 }
254 out.push(&line[start..head_end]);
255 start = head_end;
256 }
257 pos = word_end + 1;
258 }
259 out.push(&line[start..end]);
260}
261
262fn with_ellipsis(line: &str, font: &LoadedFont, scale: f32, width: f32) -> String {
269 const ELLIPSIS: &str = "...";
270 let mut out = String::with_capacity(line.len() + ELLIPSIS.len());
271 let mut end = line.len();
272 if width > 0.0 {
273 let fits = |prefix_w: f32| {
274 let mut w = prefix_w;
275 for ch in ELLIPSIS.chars() {
276 w += advance_px(ch, font, scale);
277 }
278 w <= width
279 };
280 end = 0;
281 let mut prefix_w = 0.0_f32;
282 for (i, ch) in line.char_indices() {
283 let w = prefix_w + advance_px(ch, font, scale);
284 if !fits(w) {
285 break;
286 }
287 prefix_w = w;
288 end = i + ch.len_utf8();
289 }
290 }
291 out.push_str(&line[..end]);
292 out.push_str(ELLIPSIS);
293 out
294}
295
296fn baseline_offset(font: &LoadedFont, scale: f32) -> f32 {
301 let line_height = font.size_px * scale;
302 (line_height + font.cap_px * scale) / 2.0
303}
304
305fn content_v_extent(content: &str, font: &LoadedFont, scale: f32) -> (f32, f32) {
311 let mut top_above = 0.0_f32;
312 let mut bot_below = 0.0_f32;
313 for ch in content.chars() {
314 if ch == '\n' {
315 continue;
316 }
317 if let Some(m) = font.metrics.get(&(ch as u32)) {
318 if m.atlas_h == 0 {
319 continue;
320 }
321 top_above = top_above.max(m.bearing_y * scale);
322 bot_below = bot_below.max((m.atlas_h as f32 / font.supersample - m.bearing_y) * scale);
323 }
324 }
325 (top_above, bot_below)
326}
327
328pub fn measure_label_box(label: &TextLabel, loaded_fonts: &FontSet) -> Option<LabelBox> {
336 if !label.visible {
337 return None;
338 }
339 let font = loaded_fonts.resolve(label.font)?;
340 let scale = label.scale;
341 let line_height = font.size_px * scale;
342 let content = laid_out(label, font);
343 let lines = content.split('\n').count().max(1) as f32;
344 let text_w = content
345 .split('\n')
346 .map(|line| measure_text_width(line, font, scale))
347 .fold(0.0_f32, f32::max);
348 let pad = label.padding;
349 let (top_above, bot_below) = content_v_extent(&content, font, scale);
350 let base_off = baseline_offset(font, scale);
351 Some(LabelBox {
352 w: text_w + 2.0 * pad,
353 h: top_above + bot_below + (lines - 1.0) * line_height + 2.0 * pad,
354 pad,
355 top_inset: top_above + pad - base_off,
358 })
359}
360
361pub fn build_text_calls(
368 labels: &[TextLabel],
369 loaded_fonts: &FontSet,
370 win_w: f32,
371 win_h: f32,
372 clips: &ClipRects,
373 layers: &OverlayLayers,
374) -> Vec<TextDrawCall> {
375 let mut out = crate::call_buffer::TextCallBuffer::default();
376 build_text_calls_into(&mut out, labels, loaded_fonts, win_w, win_h, clips, layers);
377 out.take()
378}
379
380pub fn build_text_calls_into(
384 out: &mut crate::call_buffer::TextCallBuffer,
385 labels: &[TextLabel],
386 loaded_fonts: &FontSet,
387 win_w: f32,
388 win_h: f32,
389 clips: &ClipRects,
390 layers: &OverlayLayers,
391) {
392 let overlay = OverlayTransform::from_viewport([win_w, win_h]);
396 let bottom = OverlayTransform::bottom_anchored_from_viewport([win_w, win_h]);
398 let cover = OverlayTransform::cover_from_viewport([win_w, win_h]);
399 for label in labels {
400 if !label.visible {
401 continue;
402 }
403 let font = match loaded_fonts.resolve(label.font) {
404 Some(f) => f,
405 None => continue,
406 };
407 let content = laid_out(label, font);
411 let quads = content.len() + 1;
414 let (mut vertices, mut indices) = out.geometry();
415 vertices.reserve(4 * quads);
416 indices.reserve(6 * quads);
417
418 let (x0, y0, scale) = if label.centered && win_w > 0.0 && win_h > 0.0 {
422 let w1 = measure_text_width(&content, font, 1.0);
423 let h1 = font.size_px;
424 let scale = if w1 > 0.0 && h1 > 0.0 {
425 let sw = win_w * 0.85 / w1;
426 let sh = win_h * 0.85 / h1;
427 sw.min(sh)
428 } else {
429 label.scale
430 };
431 let tw = measure_text_width(&content, font, scale);
432 let th = h1 * scale;
433 ((win_w - tw) / 2.0, (win_h - th) / 2.0, scale)
434 } else {
435 let (ax, ay, scale) = if label.screen.is_some() {
438 let t = match label.fit {
439 SpriteFit::Bottom => bottom,
440 SpriteFit::Cover => cover,
441 SpriteFit::Fit => overlay,
442 };
443 let (sx, sy) = t.forward(label.x, label.y);
444 (sx, sy, label.scale * t.scale())
445 } else {
446 (label.x, label.y, label.scale)
447 };
448 let x0 = match label.align {
452 TextAlign::Left => ax,
453 TextAlign::Center | TextAlign::Right => {
454 let w = content
455 .split('\n')
456 .map(|line| measure_text_width(line, font, scale))
457 .fold(0.0_f32, f32::max);
458 if label.align == TextAlign::Center {
459 ax - w / 2.0
460 } else {
461 ax - w
462 }
463 }
464 };
465 (x0, ay, scale)
466 };
467
468 let mut x_cursor = x0;
469 let line_height = font.size_px * scale;
474 let mut baseline = y0 + baseline_offset(font, scale);
475 let aw = font.atlas_w as f32;
476 let ah = font.atlas_h as f32;
477
478 if label.background[3] > 0.0 && !content.is_empty() {
486 let lines = content.split('\n').count().max(1) as f32;
487 let text_w = content
488 .split('\n')
489 .map(|line| measure_text_width(line, font, scale))
490 .fold(0.0_f32, f32::max);
491 let pad = label.padding;
492 let (top_above, bot_below) = content_v_extent(&content, font, scale);
493 let last_baseline = baseline + (lines - 1.0) * line_height;
494 let (x0b, y0b) = (x0 - pad, baseline - top_above - pad);
495 let (x1b, y1b) = (x0 + text_w + pad, last_baseline + bot_below + pad);
496 let bg = [
497 label.background[0],
498 label.background[1],
499 label.background[2],
500 ];
501 let ba = label.background[3];
502 let box_vtx = |x: f32, y: f32| TextVertex {
503 pos: [x, y],
504 uv: [-1.0, ba],
505 color: bg,
506 mode: 0.0,
507 };
508 vertices.extend_from_slice(&[
509 box_vtx(x0b, y0b),
510 box_vtx(x1b, y0b),
511 box_vtx(x1b, y1b),
512 box_vtx(x0b, y1b),
513 ]);
514 indices.extend_from_slice(&[0, 1, 2, 0, 2, 3]);
515 }
516
517 for ch in content.chars() {
518 if ch == '\n' {
519 x_cursor = x0;
520 baseline += line_height;
521 continue;
522 }
523 let m = match font.metrics.get(&(ch as u32)) {
524 Some(m) => m,
525 None => {
526 if let Some(sp) = font.metrics.get(&(b' ' as u32)) {
527 x_cursor += sp.advance_px * scale;
528 }
529 continue;
530 }
531 };
532 if m.atlas_w == 0 || m.atlas_h == 0 {
533 x_cursor += m.advance_px * scale;
534 continue;
535 }
536 let gw = m.atlas_w as f32 / font.supersample * scale;
540 let gh = m.atlas_h as f32 / font.supersample * scale;
541 let gx = x_cursor + m.bearing_x * scale;
542 let gy = baseline - m.bearing_y * scale;
543 let u0 = m.atlas_x as f32 / aw;
544 let v0 = m.atlas_y as f32 / ah;
545 let u1 = (m.atlas_x as f32 + m.atlas_w as f32) / aw;
546 let v1 = (m.atlas_y as f32 + m.atlas_h as f32) / ah;
547 let base = vertices.len() as u16;
548 vertices.extend_from_slice(&[
549 TextVertex {
550 pos: [gx, gy],
551 uv: [u0, v0],
552 color: label.color,
553 mode: 0.0,
554 },
555 TextVertex {
556 pos: [gx + gw, gy],
557 uv: [u1, v0],
558 color: label.color,
559 mode: 0.0,
560 },
561 TextVertex {
562 pos: [gx + gw, gy + gh],
563 uv: [u1, v1],
564 color: label.color,
565 mode: 0.0,
566 },
567 TextVertex {
568 pos: [gx, gy + gh],
569 uv: [u0, v1],
570 color: label.color,
571 mode: 0.0,
572 },
573 ]);
574 indices.extend_from_slice(&[base, base + 1, base + 2, base, base + 2, base + 3]);
575 x_cursor += m.advance_px * scale;
576 }
577 if vertices.is_empty() {
578 out.park(vertices, indices);
579 } else {
580 out.calls.push(TextDrawCall {
581 vertices,
582 indices,
583 atlas_slot: font.atlas_slot,
584 clip_rect: clips
585 .get(&label.asset_id)
586 .map(|b| band_to_window(&overlay, *b)),
587 layer: layers.get(&label.asset_id).copied().unwrap_or(0),
588 });
589 }
590 }
591}
592
593pub(crate) fn band_to_window(overlay: &OverlayTransform, band: [f32; 4]) -> [f32; 4] {
596 let (x0, y0) = overlay.forward(band[0], band[1]);
597 let (x1, y1) = overlay.forward(band[0] + band[2], band[1] + band[3]);
598 [x0, y0, x1 - x0, y1 - y0]
599}
600
601#[cfg(test)]
602mod tests {
603 use super::*;
604 use crate::ecs::asset_id::AssetId;
605 use crate::font::GlyphMetrics;
606
607 use alloc::string::ToString;
608 fn no_clips() -> ClipRects {
610 ClipRects::new()
611 }
612 fn no_layers() -> OverlayLayers {
613 OverlayLayers::new()
614 }
615
616 fn make_glyph(atlas_w: u16, atlas_h: u16, advance_px: f32) -> GlyphMetrics {
617 GlyphMetrics {
618 char_code: 0,
619 atlas_x: 0,
620 atlas_y: 0,
621 atlas_w,
622 atlas_h,
623 advance_px,
624 bearing_x: 0.0,
625 bearing_y: atlas_h as f32,
626 }
627 }
628
629 fn make_font(chars: &[(char, GlyphMetrics)]) -> LoadedFont {
630 let metrics: FontMetrics = chars.iter().map(|(c, m)| (*c as u32, *m)).collect();
631 let cap_px = derive_cap_px(&metrics, 16.0);
632 LoadedFont {
633 atlas_slot: 0,
634 cap_px,
635 metrics,
636 atlas_w: 128,
637 atlas_h: 128,
638 size_px: 16.0,
639 supersample: 1.0,
641 }
642 }
643
644 fn make_label(font: FontHandle, content: &str, x: f32) -> TextLabel {
645 TextLabel {
646 asset_id: AssetId::default(),
647 font: Some(font),
648 content: content.to_string(),
649 x,
650 y: 0.0,
651 color: [1.0, 1.0, 1.0],
652 scale: 1.0,
653 centered: false,
654 align: crate::components::TextAlign::Left,
655 fit: crate::components::SpriteFit::Fit,
656 background: [0.0, 0.0, 0.0, 0.0],
657 padding: 0.0,
658 wrap_width: 0.0,
659 max_lines: 0,
660 visible: true,
661 screen: None,
662 }
663 }
664
665 fn even_font() -> LoadedFont {
668 let glyphs: Vec<(char, GlyphMetrics)> = ('a'..='z')
669 .chain(['A', ' ', '-', '.', '\''])
670 .map(|c| (c, make_glyph(8, 8, 10.0)))
671 .collect();
672 make_font(&glyphs)
673 }
674
675 fn wrapped(content: &str, width: f32, max_lines: u32) -> Vec<String> {
676 let font = even_font();
677 let mut label = make_label(FontHandle(0), content, 0.0);
678 label.wrap_width = width;
679 label.max_lines = max_lines;
680 laid_out(&label, &font)
681 .split('\n')
682 .map(String::from)
683 .collect()
684 }
685
686 #[test]
687 fn text_wraps_at_word_boundaries_within_its_width() {
688 assert_eq!(wrapped("aaa bbb", 50.0, 0), ["aaa", "bbb"]);
690 assert_eq!(wrapped("aa bb", 50.0, 0), ["aa bb"]);
692 assert_eq!(wrapped("aa bb cc", 50.0, 0), ["aa bb", "cc"]);
693 }
694
695 #[test]
696 fn authored_newlines_stay_breaks_and_wrap_within_themselves() {
697 assert_eq!(wrapped("aa\nbb cc dd", 50.0, 0), ["aa", "bb cc", "dd"]);
698 }
699
700 #[test]
701 fn a_word_too_long_for_a_line_splits_rather_than_overflowing() {
702 assert_eq!(wrapped("aaaaaaaa", 50.0, 0), ["aaaaa", "aaa"]);
703 let font = even_font();
705 for line in wrapped("aaaaaaaaaaaaaa bb", 50.0, 0) {
706 assert!(measure_text_width(&line, &font, 1.0) <= 50.0, "{line:?}");
707 }
708 }
709
710 #[test]
711 fn max_lines_cuts_the_overflow_with_an_ellipsis_that_still_fits() {
712 assert_eq!(wrapped("aa bb cc dd ee ff", 50.0, 0).len(), 3);
714 let lines = wrapped("aa bb cc dd ee ff", 50.0, 2);
715 assert_eq!(lines.len(), 2);
716 assert!(lines[1].ends_with("..."), "{lines:?}");
717 let font = even_font();
718 for line in &lines {
719 assert!(measure_text_width(line, &font, 1.0) <= 50.0, "{line:?}");
720 }
721 }
722
723 #[test]
724 fn text_that_fits_is_left_exactly_as_it_was() {
725 assert_eq!(wrapped("aa bb", 500.0, 4), ["aa bb"]);
726 assert_eq!(wrapped("", 50.0, 2), [""]);
727 }
728
729 #[test]
733 fn a_wrapped_label_draws_and_measures_the_lines_it_wrapped_to() {
734 let font_id = FontHandle(0);
735 let mut fonts = FontSet::default();
736 fonts.insert(font_id, even_font());
737 let mut label = make_label(font_id, "aa bb cc", 0.0);
738 label.wrap_width = 50.0;
739 label.background = [0.0, 0.0, 0.0, 1.0];
740
741 let boxed = measure_label_box(&label, &fonts).unwrap();
742 assert!(boxed.w <= 50.0, "{boxed:?}");
744 let calls = build_text_calls(
745 core::slice::from_ref(&label),
746 &fonts,
747 200.0,
748 200.0,
749 &no_clips(),
750 &no_layers(),
751 );
752 let right = calls[0]
753 .vertices
754 .iter()
755 .map(|v| v.pos[0])
756 .fold(f32::MIN, f32::max);
757 assert!(right <= 50.0, "glyphs ran past the wrap width: {right}");
758 }
759
760 #[test]
761 fn empty_labels_returns_empty_calls() {
762 let fonts = FontSet::default();
763 assert!(build_text_calls(&[], &fonts, 0.0, 0.0, &no_clips(), &no_layers()).is_empty());
764 }
765
766 #[test]
770 fn a_label_naming_no_font_draws_with_the_fallback() {
771 let g = make_glyph(8, 8, 10.0);
772 let mut fonts = FontSet::default();
773 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
774 let mut label = make_label(FontHandle(0), "A", 0.0);
775 label.font = None;
776
777 let calls = build_text_calls(
779 core::slice::from_ref(&label),
780 &fonts,
781 0.0,
782 0.0,
783 &no_clips(),
784 &no_layers(),
785 );
786 assert!(calls.is_empty());
787 assert!(measure_label_box(&label, &fonts).is_none());
788
789 fonts.set_fallback(FontHandle(0));
790 let calls = build_text_calls(
791 core::slice::from_ref(&label),
792 &fonts,
793 0.0,
794 0.0,
795 &no_clips(),
796 &no_layers(),
797 );
798 assert_eq!(calls.len(), 1);
799 assert!(measure_label_box(&label, &fonts).is_some());
800 }
801
802 #[test]
805 fn a_named_font_wins_over_the_fallback() {
806 let wide = make_glyph(8, 8, 20.0);
807 let narrow = make_glyph(8, 8, 5.0);
808 let mut fonts = FontSet::default();
809 fonts.insert(FontHandle(0), make_font(&[('A', wide)]));
810 fonts.insert(FontHandle(1), make_font(&[('A', narrow)]));
811 fonts.set_fallback(FontHandle(0));
812
813 let label = make_label(FontHandle(1), "AA", 0.0);
814 assert_eq!(measure_label_box(&label, &fonts).unwrap().w, 10.0);
815 }
816
817 #[test]
820 fn an_unloaded_font_falls_back() {
821 let g = make_glyph(8, 8, 10.0);
822 let mut fonts = FontSet::default();
823 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
824 fonts.set_fallback(FontHandle(0));
825
826 let label = make_label(FontHandle(99), "A", 0.0);
827 assert!(measure_label_box(&label, &fonts).is_some());
828 }
829
830 #[test]
831 fn unknown_font_produces_no_call() {
832 let fonts = FontSet::default();
833 let label = make_label(FontHandle(99), "hello", 0.0);
834 assert!(
835 build_text_calls(
836 core::slice::from_ref(&label),
837 &fonts,
838 0.0,
839 0.0,
840 &no_clips(),
841 &no_layers()
842 )
843 .is_empty()
844 );
845 }
846
847 #[test]
848 fn single_glyph_produces_quad() {
849 let g = make_glyph(10, 12, 11.0);
850 let mut fonts = FontSet::default();
851 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
852 let label = make_label(FontHandle(0), "A", 0.0);
853 let calls = build_text_calls(
854 core::slice::from_ref(&label),
855 &fonts,
856 0.0,
857 0.0,
858 &no_clips(),
859 &no_layers(),
860 );
861 assert_eq!(calls.len(), 1);
862 assert_eq!(calls[0].vertices.len(), 4);
863 assert_eq!(calls[0].indices.len(), 6);
864 assert_eq!(calls[0].atlas_slot, 0);
865 }
866
867 #[test]
868 fn background_prepends_a_box_quad() {
869 let g = make_glyph(10, 12, 11.0);
870 let mut fonts = FontSet::default();
871 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
872 let mut label = make_label(FontHandle(0), "A", 0.0);
873 label.background = [0.0, 0.3, 0.1, 0.85];
874 label.padding = 4.0;
875 let calls = build_text_calls(
876 core::slice::from_ref(&label),
877 &fonts,
878 0.0,
879 0.0,
880 &no_clips(),
881 &no_layers(),
882 );
883 assert_eq!(calls.len(), 1);
884 assert_eq!(calls[0].vertices.len(), 8);
886 assert_eq!(calls[0].indices.len(), 12);
887 for v in &calls[0].vertices[..4] {
889 assert!(v.uv[0] < 0.0, "box vert should carry the sentinel u");
890 assert!((v.uv[1] - 0.85).abs() < 1e-4, "box alpha travels in v");
891 }
892 assert!(calls[0].vertices[4].uv[0] >= 0.0);
894 }
895
896 #[test]
897 fn derive_cap_px_uses_uppercase_reference() {
898 let mut m = HashMap::new();
900 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);
903 let empty = HashMap::new();
905 assert!((derive_cap_px(&empty, 20.0) - 14.0).abs() < 1e-4);
906 }
907
908 #[test]
909 fn background_box_hugs_glyph_with_symmetric_padding() {
910 let g = make_glyph(10, 12, 11.0); let mut fonts = FontSet::default();
914 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
915 let mut label = make_label(FontHandle(0), "A", 0.0);
916 label.background = [0.1, 0.1, 0.1, 1.0];
917 label.padding = 4.0;
918 let calls = build_text_calls(
919 core::slice::from_ref(&label),
920 &fonts,
921 0.0,
922 0.0,
923 &no_clips(),
924 &no_layers(),
925 );
926 let v = &calls[0].vertices;
927 let (box_top, box_bot) = (v[0].pos[1], v[2].pos[1]);
929 let (glyph_top, glyph_bot) = (v[4].pos[1], v[6].pos[1]);
930 assert!(
931 (glyph_top - box_top - 4.0).abs() < 1e-4,
932 "top pad = {}",
933 glyph_top - box_top
934 );
935 assert!(
936 (box_bot - glyph_bot - 4.0).abs() < 1e-4,
937 "bottom pad = {}",
938 box_bot - glyph_bot
939 );
940 }
941
942 #[test]
943 fn background_with_empty_content_draws_nothing() {
944 let g = make_glyph(10, 12, 11.0);
945 let mut fonts = FontSet::default();
946 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
947 let mut label = make_label(FontHandle(0), "", 0.0);
948 label.background = [0.0, 0.3, 0.1, 0.85];
949 assert!(
951 build_text_calls(
952 core::slice::from_ref(&label),
953 &fonts,
954 0.0,
955 0.0,
956 &no_clips(),
957 &no_layers()
958 )
959 .is_empty()
960 );
961 }
962
963 #[test]
964 fn space_advances_cursor_without_quad() {
965 let space = make_glyph(0, 0, 8.0);
966 let g = make_glyph(10, 12, 11.0);
967 let mut fonts = FontSet::default();
968 fonts.insert(FontHandle(0), make_font(&[(' ', space), ('A', g)]));
969 let label = make_label(FontHandle(0), " A", 0.0);
971 let calls = build_text_calls(
972 core::slice::from_ref(&label),
973 &fonts,
974 0.0,
975 0.0,
976 &no_clips(),
977 &no_layers(),
978 );
979 assert_eq!(calls.len(), 1);
980 assert_eq!(calls[0].vertices.len(), 4);
981 let gx = calls[0].vertices[0].pos[0];
983 assert!((gx - 16.0).abs() < 1e-4, "expected gx=16.0, got {gx}");
984 }
985
986 #[test]
987 fn zero_size_glyph_advances_cursor_without_quad() {
988 let zero = GlyphMetrics {
990 char_code: b'X' as u32,
991 atlas_x: 0,
992 atlas_y: 0,
993 atlas_w: 0,
994 atlas_h: 0,
995 advance_px: 5.0,
996 bearing_x: 0.0,
997 bearing_y: 0.0,
998 };
999 let g = make_glyph(10, 12, 11.0);
1000 let mut fonts = FontSet::default();
1001 fonts.insert(FontHandle(0), make_font(&[('X', zero), ('A', g)]));
1002 let label = make_label(FontHandle(0), "XA", 0.0);
1003 let calls = build_text_calls(
1004 core::slice::from_ref(&label),
1005 &fonts,
1006 0.0,
1007 0.0,
1008 &no_clips(),
1009 &no_layers(),
1010 );
1011 assert_eq!(calls.len(), 1);
1012 assert_eq!(calls[0].vertices.len(), 4); assert!((calls[0].vertices[0].pos[0] - 5.0).abs() < 1e-4);
1015 }
1016
1017 #[test]
1018 fn newline_starts_a_new_line() {
1019 let g = make_glyph(10, 12, 11.0);
1022 let mut fonts = FontSet::default();
1023 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1024 let label = make_label(FontHandle(0), "A\nA", 0.0);
1025 let calls = build_text_calls(
1026 core::slice::from_ref(&label),
1027 &fonts,
1028 0.0,
1029 0.0,
1030 &no_clips(),
1031 &no_layers(),
1032 );
1033 assert_eq!(calls.len(), 1);
1034 assert_eq!(calls[0].vertices.len(), 8);
1036 assert_eq!(calls[0].indices.len(), 12);
1037 let first = &calls[0].vertices[0];
1038 let second = &calls[0].vertices[4];
1039 assert!((first.pos[0] - second.pos[0]).abs() < 1e-4);
1041 assert!(
1043 (second.pos[1] - first.pos[1] - 16.0).abs() < 1e-4,
1044 "expected +16 line height, got {}",
1045 second.pos[1] - first.pos[1]
1046 );
1047 }
1048
1049 #[test]
1050 fn centered_label_is_repositioned() {
1051 let g = make_glyph(10, 12, 20.0);
1052 let mut fonts = FontSet::default();
1053 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1054 let mut label = make_label(FontHandle(0), "A", 0.0);
1055 label.centered = true;
1056 let calls = build_text_calls(
1064 core::slice::from_ref(&label),
1065 &fonts,
1066 200.0,
1067 100.0,
1068 &no_clips(),
1069 &no_layers(),
1070 );
1071 assert_eq!(calls.len(), 1);
1072 let v = &calls[0].vertices[0];
1073 assert!((v.pos[0] - 46.875).abs() < 1e-3, "gx={}", v.pos[0]);
1074 assert!((v.pos[1] - 18.125).abs() < 1e-3, "gy={}", v.pos[1]);
1075 }
1076
1077 #[test]
1078 fn view_owned_label_scales_and_repositions_with_overlay() {
1079 let g = make_glyph(10, 12, 20.0);
1084 let mut fonts = FontSet::default();
1085 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1086
1087 let hud = make_label(FontHandle(0), "A", 100.0); let mut overlay_label = make_label(FontHandle(0), "A", 100.0);
1089 overlay_label.y = 100.0;
1090 overlay_label.screen = Some(AssetId(5));
1091
1092 let vp = (2560.0, 1440.0);
1094 let hud_calls = build_text_calls(
1095 core::slice::from_ref(&hud),
1096 &fonts,
1097 vp.0,
1098 vp.1,
1099 &no_clips(),
1100 &no_layers(),
1101 );
1102 let ovl_calls = build_text_calls(
1103 core::slice::from_ref(&overlay_label),
1104 &fonts,
1105 vp.0,
1106 vp.1,
1107 &no_clips(),
1108 &no_layers(),
1109 );
1110 assert!((hud_calls[0].vertices[0].pos[0] - 100.0).abs() < 1e-3);
1112 assert!(
1114 (ovl_calls[0].vertices[0].pos[0] - 200.0).abs() < 1e-3,
1115 "x={}",
1116 ovl_calls[0].vertices[0].pos[0]
1117 );
1118 let w = ovl_calls[0].vertices[1].pos[0] - ovl_calls[0].vertices[0].pos[0];
1120 assert!((w - 20.0).abs() < 1e-3, "w={w}");
1121 }
1122
1123 #[test]
1124 fn measure_label_box_grows_text_by_padding() {
1125 let g = make_glyph(10, 12, 11.0);
1126 let mut fonts = FontSet::default();
1127 fonts.insert(FontHandle(0), make_font(&[('A', g), ('B', g)]));
1128 let mut label = make_label(FontHandle(0), "AB", 0.0);
1129 label.padding = 4.0;
1130 let b = measure_label_box(&label, &fonts).unwrap();
1131 assert!((b.w - 30.0).abs() < 1e-4, "w={}", b.w);
1133 assert!((b.h - 20.0).abs() < 1e-4, "h={}", b.h);
1135 assert!((b.pad - 4.0).abs() < 1e-4);
1136 assert!(
1138 (b.top_inset - 2.0).abs() < 1e-4,
1139 "top_inset={}",
1140 b.top_inset
1141 );
1142 }
1143
1144 #[test]
1145 fn measure_label_box_skips_hidden_and_unloaded() {
1146 let g = make_glyph(10, 12, 11.0);
1147 let mut fonts = FontSet::default();
1148 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1149 let mut hidden = make_label(FontHandle(0), "A", 0.0);
1151 hidden.visible = false;
1152 assert!(measure_label_box(&hidden, &fonts).is_none());
1153 let orphan = make_label(FontHandle(99), "A", 0.0);
1155 assert!(measure_label_box(&orphan, &fonts).is_none());
1156 }
1157
1158 #[test]
1159 fn align_center_and_right_shift_the_anchor() {
1160 let g = make_glyph(10, 12, 10.0);
1165 let mut fonts = FontSet::default();
1166 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1167 let first_x = |align: TextAlign| {
1168 let mut l = make_label(FontHandle(0), "AA", 100.0);
1169 l.align = align;
1170 build_text_calls(
1171 core::slice::from_ref(&l),
1172 &fonts,
1173 0.0,
1174 0.0,
1175 &no_clips(),
1176 &no_layers(),
1177 )[0]
1178 .vertices[0]
1179 .pos[0]
1180 };
1181 assert!((first_x(TextAlign::Left) - 100.0).abs() < 1e-4);
1182 assert!((first_x(TextAlign::Center) - 90.0).abs() < 1e-4);
1183 assert!((first_x(TextAlign::Right) - 80.0).abs() < 1e-4);
1184 }
1185
1186 #[test]
1187 fn clip_band_scissors_the_call() {
1188 let g = make_glyph(10, 12, 11.0);
1192 let mut fonts = FontSet::default();
1193 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1194 let mut label = make_label(FontHandle(0), "A", 0.0);
1195 label.asset_id = AssetId(7);
1196 let mut clips = ClipRects::new();
1197 let band = [10.0, 20.0, 300.0, 40.0];
1198 clips.insert(AssetId(7), band);
1199 let calls = build_text_calls(
1200 core::slice::from_ref(&label),
1201 &fonts,
1202 1280.0,
1203 720.0,
1204 &clips,
1205 &no_layers(),
1206 );
1207 assert_eq!(calls.len(), 1);
1208 assert_eq!(calls[0].clip_rect, Some(band));
1209 let other = make_label(FontHandle(0), "A", 0.0);
1211 let unclipped = build_text_calls(
1212 core::slice::from_ref(&other),
1213 &fonts,
1214 1280.0,
1215 720.0,
1216 &clips,
1217 &no_layers(),
1218 );
1219 assert_eq!(unclipped[0].clip_rect, None);
1220 }
1221
1222 #[test]
1223 fn band_to_window_maps_through_the_overlay() {
1224 let overlay = OverlayTransform::from_viewport([2560.0, 1440.0]);
1226 let mapped = band_to_window(&overlay, [640.0, 360.0, 100.0, 50.0]);
1227 assert!((mapped[0] - 1280.0).abs() < 1e-3, "x={}", mapped[0]);
1229 assert!((mapped[1] - 720.0).abs() < 1e-3, "y={}", mapped[1]);
1230 assert!((mapped[2] - 200.0).abs() < 1e-3, "w={}", mapped[2]);
1231 assert!((mapped[3] - 100.0).abs() < 1e-3, "h={}", mapped[3]);
1232 }
1233
1234 #[test]
1235 fn fit_bottom_and_cover_map_view_owned_labels() {
1236 let g = make_glyph(10, 12, 11.0);
1241 let mut fonts = FontSet::default();
1242 fonts.insert(FontHandle(0), make_font(&[('A', g)]));
1243 let vp = (1024.0, 768.0);
1244 let first_y = |fit: SpriteFit| {
1245 let mut l = make_label(FontHandle(0), "A", 100.0);
1246 l.y = 600.0;
1247 l.screen = Some(AssetId(5));
1248 l.fit = fit;
1249 build_text_calls(
1250 core::slice::from_ref(&l),
1251 &fonts,
1252 vp.0,
1253 vp.1,
1254 &no_clips(),
1255 &no_layers(),
1256 )[0]
1257 .vertices[0]
1258 .pos[1]
1259 };
1260 let fit_y = first_y(SpriteFit::Fit);
1261 let bottom_y = first_y(SpriteFit::Bottom);
1262 let cover_y = first_y(SpriteFit::Cover);
1263 assert!(bottom_y > fit_y, "bottom={bottom_y} fit={fit_y}");
1264 assert!(
1265 (cover_y - fit_y).abs() > 1e-3,
1266 "cover={cover_y} fit={fit_y}"
1267 );
1268 }
1269
1270 #[test]
1271 fn missing_glyph_falls_back_to_space_advance() {
1272 let space = make_glyph(0, 0, 7.0);
1275 let g = make_glyph(10, 12, 11.0);
1276 let mut fonts = FontSet::default();
1277 fonts.insert(FontHandle(0), make_font(&[(' ', space), ('A', g)]));
1278 let label = make_label(FontHandle(0), "?A", 0.0);
1280 let calls = build_text_calls(
1281 core::slice::from_ref(&label),
1282 &fonts,
1283 0.0,
1284 0.0,
1285 &no_clips(),
1286 &no_layers(),
1287 );
1288 assert_eq!(calls.len(), 1);
1289 assert_eq!(calls[0].vertices.len(), 4); assert!((calls[0].vertices[0].pos[0] - 7.0).abs() < 1e-4);
1291 }
1292
1293 #[test]
1294 fn measure_uses_space_advance_for_missing_glyphs() {
1295 let space = make_glyph(0, 0, 7.0);
1298 let g = make_glyph(10, 12, 11.0);
1299 let mut fonts = FontSet::default();
1300 fonts.insert(FontHandle(0), make_font(&[(' ', space), ('A', g)]));
1301 let known = make_label(FontHandle(0), "A", 0.0);
1302 let with_missing = make_label(FontHandle(0), "?A", 0.0);
1303 let wk = measure_label_box(&known, &fonts).unwrap().w;
1304 let wm = measure_label_box(&with_missing, &fonts).unwrap().w;
1305 assert!((wm - wk - 7.0).abs() < 1e-4, "wk={wk} wm={wm}");
1307 }
1308}