Skip to main content

concinnity_render/
text.rs

1//! Font atlas data and text draw-call assembly. No backend ownership; the
2//! renderer uploads the atlas textures; this module only builds the quad
3//! geometry from TextLabel components each frame.
4
5use 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
15/// One face's per-glyph metrics, keyed by Unicode code point.
16pub type FontMetrics = HashMap<u32, crate::font::GlyphMetrics>;
17
18/// Per-font data kept in memory after init() so step() can build text quads each frame.
19pub struct LoadedFont {
20    /// Index into the backend's text atlas texture array.
21    pub atlas_slot: usize,
22    /// Per-glyph metrics keyed by Unicode code point.
23    pub metrics: FontMetrics,
24    /// Atlas width in pixels.
25    pub atlas_w: u32,
26    /// Atlas height in pixels.
27    pub atlas_h: u32,
28    /// Rasterisation height (px) used to position glyphs vertically.
29    pub size_px: f32,
30    /// Cap height (logical px): the bearing of an uppercase reference glyph, used
31    /// to vertically center the visible text within its line box. The full em
32    /// (`size_px`) is taller than the visible glyphs, so centering on the em alone
33    /// leaves a gap above the caps; centering the cap band fixes that.
34    pub cap_px: f32,
35    /// Atlas supersample factor: glyph `atlas_w`/`atlas_h` are stored in atlas
36    /// texels, which are this many times larger than the glyph's size in logical
37    /// (layout) pixels. The on-screen quad divides by it so the text lays out at
38    /// its requested size while the extra texels supersample the glyph.
39    pub supersample: f32,
40}
41
42/// The faces a frame draws with: every font loaded at init, keyed by handle,
43/// plus the face a label naming no font of its own falls back to.
44///
45/// Nothing compiles a font for text that names none, whichever way the world was
46/// assembled, so the renderer registers the engine's built-in face as the
47/// fallback whenever some text needs it. A world whose text all names a Font
48/// leaves the fallback unset and pays nothing for it.
49#[derive(Default)]
50pub struct FontSet {
51    faces: HashMap<FontHandle, LoadedFont>,
52    fallback: Option<FontHandle>,
53}
54
55impl FontSet {
56    /// Register `font` under `handle`, replacing any face already there.
57    pub fn insert(&mut self, handle: FontHandle, font: LoadedFont) {
58        self.faces.insert(handle, font);
59    }
60
61    /// Draw labels naming no font with the face under `handle`.
62    pub fn set_fallback(&mut self, handle: FontHandle) {
63        self.fallback = Some(handle);
64    }
65
66    /// The face registered under `handle`.
67    pub fn get(&self, handle: FontHandle) -> Option<&LoadedFont> {
68        self.faces.get(&handle)
69    }
70
71    /// The face a label draws with: the one it names, or the fallback when it
72    /// names none (or names one that never loaded).
73    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    /// Number of loaded faces.
79    pub fn len(&self) -> usize {
80        self.faces.len()
81    }
82
83    /// Whether no face loaded at all, in which case no text draws.
84    pub fn is_empty(&self) -> bool {
85        self.faces.is_empty()
86    }
87
88    /// Atlas slot of an arbitrary loaded face, which the sprite shaper uses as
89    /// the texture slot for its untextured (sentinel-UV) quads.
90    pub fn any_atlas_slot(&self) -> Option<usize> {
91        self.faces.values().next().map(|f| f.atlas_slot)
92    }
93}
94
95/// Cap height (logical px) for vertical centering: the bearing of an uppercase
96/// reference glyph ('H'), falling back to the tallest uppercase glyph, then to a
97/// fraction of the em when no metrics are available.
98pub 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
114/// Width of the widest line of `content` in scaled pixels: what a label's
115/// background box and its centre / right alignment are both sized against.
116pub 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
123/// Advance width of `content` in scaled pixels for the given font, for placing a
124/// caret, sizing a field's text, or centring a line. Newlines carry no advance,
125/// so a multi-line string measures as if its lines were concatenated.
126pub 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
134// One glyph's advance in scaled pixels, substituting the space glyph's advance
135// for a missing metric.
136fn 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
148// The content a label actually draws: its text broken to `wrap_width` and
149// capped at `max_lines`. Wrapping measures in the label's own pixel space with
150// `label.scale`, which gives the same breaks as measuring in window pixels: a
151// screen-owned label scales its advances and its wrap width by the same overlay
152// factor. A centered label has no container (it is fitted to the viewport), so
153// it is left alone. Borrows the authored content whenever no line breaks or
154// truncates, so a fitting label allocates nothing.
155fn 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
197// Greedily pack `line`'s words into `out` as subslices of `line`, breaking at
198// spaces. A word too wide to fit a line of its own is split mid-word, since
199// leaving it whole would put it back outside the container wrapping exists to
200// respect. Widths accumulate one glyph advance at a time in authored order,
201// matching a from-scratch measure of the same text.
202fn 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    // The line under construction, `line[start..end]`, and its measured width.
205    let (mut start, mut end) = (0usize, 0usize);
206    let mut current_width = 0.0_f32;
207    // Byte offset of the next word (words are separated by single spaces).
208    let mut pos = 0usize;
209    for word in line.split(' ') {
210        let word_end = pos + word.len();
211        // The candidate: the word appended to the current line (joined by the
212        // space between them), or the word alone when the line is empty.
213        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        // The word now starts a line of its own; split it if even that overflows.
235        (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            // The longest head (at least one char) that fits the width.
247            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
265// `line` shortened until it and a trailing ellipsis fit `width`: the longest
266// prefix whose width plus the ellipsis fits, found in one forward scan. Prefix
267// widths accumulate one glyph advance at a time in authored order, with the
268// ellipsis advances added after, matching a from-scratch measure of the same
269// candidate. A zero width (capping lines without wrapping them) leaves the
270// line as it is.
271fn 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
293// Baseline position relative to a label's top-left `y`, so the cap-height band
294// is vertically centered within the line box `[y, y + line_height]`. Pinning the
295// baseline to the box bottom (the old behaviour) left a large gap above the
296// glyphs; centering the cap band makes short UI text sit centered in its box.
297fn 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
302// The visible glyphs' vertical extent above and below the first line's baseline,
303// in scaled pixels: how far the tallest glyph rises (ascent) and the lowest
304// glyph drops (descent). A tight background box and the layout measurement both
305// hug this, so the box wraps the ink with `padding` on every side instead of the
306// full em line box.
307fn 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
325/// Measure a label's background-box extent for layout: a box hugging the visible
326/// glyphs grown by the label's padding on every side, plus one line height per
327/// extra `\n`-split line. Mirrors the background-box math in `build_text_calls`.
328/// `top_inset` is the gap from the box top down to the text origin (the label's
329/// `y`), which `LayoutContainer` uses to place the box. Returns `None` for a
330/// hidden label, or one with no font to draw with at all, so a
331/// `LayoutContainer` drops it and reserves no space.
332pub 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        // Box top is `base_off - top_above - pad` below the origin; the inset is
350        // the origin's distance below the box top.
351        top_inset: top_above + pad - base_off,
352    })
353}
354
355/// Build one TextDrawCall per TextLabel, laying out character quads using the
356/// loaded font metrics. When `win_w` and `win_h` are both > 0.0, labels with
357/// `centered = true` are repositioned to the centre of the viewport. `clips`
358/// maps an element id to a reference-space clip band; a label found there has
359/// its call scissored to that band (mapped to the window), so a scrollable
360/// panel's off-band rows do not bleed over its chrome.
361pub 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::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
374/// `build_text_calls`, appending onto an existing draw list so a caller
375/// assembling a frame from several element groups reuses one buffer (and, in
376/// steady state, the pooled geometry of the spent frame it recycled).
377pub fn build_text_calls_into(
378    out: &mut crate::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    // Screen-owned labels are overlay UI authored in the reference canvas; map
387    // them to the live window so menus scale with the window. HUD labels
388    // (view == None) keep literal window pixels.
389    let overlay = OverlayTransform::from_viewport([win_w, win_h]);
390    // Alternate mappings a view-owned label may opt into via `fit`.
391    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        // Everything below reads the laid-out content, not the authored string,
402        // so alignment, the background box, and the glyph run agree on the lines
403        // that are actually drawn.
404        let content = laid_out(label, font);
405        // One quad per glyph plus the optional background box; the byte length
406        // upper-bounds the glyph count.
407        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        // For centered labels, auto-scale to fill ~85% of the viewport while
413        // preserving the text's aspect ratio. The label's scale field is used
414        // for non-centered labels only.
415        // Set when horizontal alignment measures it, so the background box
416        // below does not measure the same content a second time.
417        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            // The anchor point and scale: a view-owned label maps through its
433            // `fit` transform, a HUD label stays in literal window pixels.
434            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            // Horizontal alignment shifts the anchor by the rendered width,
446            // measured with the real metrics so centered UI text sits exactly
447            // on its anchor at any scale (centering by the widest line).
448            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        // baseline: positioned so the cap-height band is centered within the line
465        // box, so short UI text sits vertically centered rather than pinned to
466        // the box bottom. Advanced by one line height on each newline so
467        // multi-line labels lay out down the screen.
468        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        // Background box: a filled quad behind the glyphs, sized to hug the
474        // visible glyphs grown by `padding` on every side (not the full em line
475        // box, which left a large gap above the caps). Emitted first so the
476        // glyphs composite on top. It carries a sentinel UV (a negative u) that
477        // the text shader reads as "solid fill", with the box alpha passed
478        // through in v. Empty content draws nothing at all (so a blanked label
479        // fully disappears).
480        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            // atlas_w/atlas_h are in supersampled atlas texels; divide by the
529            // supersample factor to get the glyph's logical size before scaling
530            // to the screen. The UVs below still address the full texel extent.
531            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
585// Map a reference-space clip band `[x, y, width, height]` to a window-space
586// rectangle through the overlay transform, so the backend can scissor to it.
587pub(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::font::GlyphMetrics;
598
599    use alloc::string::ToString;
600    // No clip bands: every label draws unclipped.
601    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            // 1x: the unit tests express glyph sizes directly in atlas texels.
632            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    // A font whose every glyph advances 10px, so a wrap width in pixels reads
658    // directly as a character count.
659    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        // 5 glyphs per line: "aaa bbb" is 7 wide, so the words split.
681        assert_eq!(wrapped("aaa bbb", 50.0, 0), ["aaa", "bbb"]);
682        // Exactly filling a line does not spill onto the next one.
683        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        // Every produced line is inside the width, which is the whole point.
696        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        // Three lines' worth of text into a two-line box.
705        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    // Wrapping has to reach the glyph run, the alignment measure, and the
722    // background box together, or a wrapped label draws its box around the
723    // unwrapped text.
724    #[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        // Two lines of five glyphs, not one line of eight.
735        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    // A world assembled in code has no compiled Font for its labels to name, so
759    // the renderer registers a fallback face and every font-less label draws
760    // with it.
761    #[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        // With no fallback there is no face to lay the glyphs out with.
770        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    // The fallback only stands in for a label that resolves to no face of its
795    // own; a label naming a loaded font keeps it.
796    #[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    // A label naming a font that never loaded falls back rather than vanishing,
810    // so a missing face costs styling instead of the text itself.
811    #[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        // 4 box verts prepended + 4 glyph verts; 6 box indices + 6 glyph.
877        assert_eq!(calls[0].vertices.len(), 8);
878        assert_eq!(calls[0].indices.len(), 12);
879        // The box quad comes first: sentinel u (< 0), box alpha carried in v.
880        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        // Glyph verts keep real, non-negative atlas UVs.
885        assert!(calls[0].vertices[4].uv[0] >= 0.0);
886    }
887
888    #[test]
889    fn derive_cap_px_uses_uppercase_reference() {
890        // 'H' is the cap-height reference, even when a lowercase glyph is taller.
891        let mut m = HashMap::new();
892        m.insert('H' as u32, make_glyph(8, 10, 9.0)); // bearing_y = 10
893        m.insert('g' as u32, make_glyph(8, 14, 9.0)); // taller, but lowercase
894        assert!((derive_cap_px(&m, 16.0) - 10.0).abs() < 1e-4);
895        // With no glyphs, fall back to a fraction of the em.
896        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        // The box wraps the visible glyph with `padding` above and below (instead
903        // of the full em line box, which left a large gap above the caps).
904        let g = make_glyph(10, 12, 11.0); // bearing_y = 12, no descent
905        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        // Verts 0..4 are the box; 4..8 the glyph quad.
920        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        // A blanked label (e.g. a toggled-off HUD chip) draws no box.
942        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        // Two spaces then 'A': only 'A' produces geometry.
962        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        // 'A' quad starts after 2 × advance_px(space) = 16.0
974        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        // A glyph whose atlas dimensions are 0×0 is invisible but still advances x.
981        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); // only 'A'
1005        // 'A' starts at x = advance_px('X') = 5.0
1006        assert!((calls[0].vertices[0].pos[0] - 5.0).abs() < 1e-4);
1007    }
1008
1009    #[test]
1010    fn newline_starts_a_new_line() {
1011        // "A\nA": the second glyph resets x to the label origin and drops
1012        // down by one line height (font size_px * scale = 16).
1013        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        // Two glyphs -> two quads -> 8 vertices, 12 indices.
1027        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        // x resets to the label origin on the new line.
1032        assert!((first.pos[0] - second.pos[0]).abs() < 1e-4);
1033        // y drops by exactly one line height.
1034        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        // Viewport 200×100; glyph advance=20, size_px=16, cap_px=12 ('A' bearing).
1049        // Auto-scale: sw = 200*0.85/20 = 8.5, sh = 100*0.85/16 = 5.3125 -> scale = 5.3125
1050        // tw = 20*5.3125 = 106.25, th = 16*5.3125 = 85.0
1051        // x0 = (200 - 106.25) / 2 = 46.875, y0 = (100 - 85.0) / 2 = 7.5
1052        // line_height = 16*5.3125 = 85; baseline centers the cap band:
1053        // baseline = 7.5 + (85 + 12*5.3125)/2 = 7.5 + 74.375 = 81.875
1054        // gx = x0 + bearing_x*scale = 46.875, gy = baseline - bearing_y*scale = 81.875 - 63.75 = 18.125
1055        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        // A view-owned (overlay) label is authored in the reference canvas and
1072        // mapped onto the window. At a 2x viewport its origin moves to the
1073        // forward-mapped position and its scale doubles. A HUD label (view ==
1074        // None) at the same coordinates stays put.
1075        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); // view == None
1080        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        // 2x reference viewport (1280x720 -> 2560x1440): scale 2, centered.
1085        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        // HUD label keeps its literal origin (x = 100).
1103        assert!((hud_calls[0].vertices[0].pos[0] - 100.0).abs() < 1e-3);
1104        // Overlay label: forward(100,100) at scale 2 -> x = 1280 + (100-640)*2 = 200.
1105        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        // Glyph width doubles (atlas_w 10 -> 20 on screen).
1111        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        // text width = 2 * advance(11) = 22, grown by padding on both sides.
1124        assert!((b.w - 30.0).abs() < 1e-4, "w={}", b.w);
1125        // The box hugs the glyphs: ascent(bearing_y=12) + descent(0) + 2*pad(4) = 20.
1126        assert!((b.h - 20.0).abs() < 1e-4, "h={}", b.h);
1127        assert!((b.pad - 4.0).abs() < 1e-4);
1128        // top_inset = ascent(12) + pad(4) - baseline_offset((16+12)/2=14) = 2.
1129        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        // Hidden label → None even with a loaded font.
1142        let mut hidden = make_label(FontHandle(0), "A", 0.0);
1143        hidden.visible = false;
1144        assert!(measure_label_box(&hidden, &fonts).is_none());
1145        // Visible label whose font isn't loaded → None.
1146        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        // Two 'A' glyphs, advance 10 each: rendered width = 20. A HUD label
1153        // (view == None) anchored at x = 100 keeps that x when left-aligned,
1154        // shifts left by half the width when centered, and by the full width
1155        // when right-aligned.
1156        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        // A label registered in `clips` gets its call scissored to the band,
1181        // mapped through the overlay transform to window space. At the reference
1182        // viewport the overlay is identity, so the scissor equals the band.
1183        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        // A label absent from `clips` (asset_id 0) draws unclipped.
1202        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        // At a 2x viewport a reference band is scaled and recentered.
1217        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        // forward(640,360) = center = (1280,720); the band doubles in size.
1220        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        // A view-owned label maps its anchor through its `fit` transform. At a
1229        // 4:3 viewport (taller than the 16:9 reference) Bottom pushes the label
1230        // below plain Fit, and Cover scales it up, so each branch yields a
1231        // distinct origin.
1232        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        // A code point with no metric advances the cursor by the space glyph's
1265        // advance, so an unknown character still occupies layout width.
1266        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        // '?' has no metric; it consumes one space advance before 'A'.
1271        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); // only 'A' draws a quad
1282        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        // text_advance_width (via measure_label_box) also substitutes the space
1288        // advance for an unknown glyph, keeping layout width stable.
1289        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        // The '?' contributes exactly one space advance (7) of extra width.
1298        assert!((wm - wk - 7.0).abs() < 1e-4, "wk={wk} wm={wm}");
1299    }
1300}