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// Sum the advance widths for a string given a font and scale. Used to centre
115// text. Newlines carry no advance and are skipped (a multi-line label is
116// measured as if the lines were concatenated).
117/// Advance width of `content` in scaled pixels for the given font, for placing a
118/// caret or sizing a field's text. Newlines carry no advance.
119pub 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
131// One glyph's advance in scaled pixels, substituting the space glyph's advance
132// for a missing metric.
133fn 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
145// The content a label actually draws: its text broken to `wrap_width` and
146// capped at `max_lines`. Wrapping measures in the label's own pixel space with
147// `label.scale`, which gives the same breaks as measuring in window pixels: a
148// screen-owned label scales its advances and its wrap width by the same overlay
149// factor. A centered label has no container (it is fitted to the viewport), so
150// it is left alone. Borrows the authored content whenever no line breaks or
151// truncates, so a fitting label allocates nothing.
152fn 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
194// Greedily pack `line`'s words into `out` as subslices of `line`, breaking at
195// spaces. A word too wide to fit a line of its own is split mid-word, since
196// leaving it whole would put it back outside the container wrapping exists to
197// respect. Widths accumulate one glyph advance at a time in authored order,
198// matching a from-scratch measure of the same text.
199fn 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    // The line under construction, `line[start..end]`, and its measured width.
202    let (mut start, mut end) = (0usize, 0usize);
203    let mut current_width = 0.0_f32;
204    // Byte offset of the next word (words are separated by single spaces).
205    let mut pos = 0usize;
206    for word in line.split(' ') {
207        let word_end = pos + word.len();
208        // The candidate: the word appended to the current line (joined by the
209        // space between them), or the word alone when the line is empty.
210        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        // The word now starts a line of its own; split it if even that overflows.
232        (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            // The longest head (at least one char) that fits the width.
244            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
262// `line` shortened until it and a trailing ellipsis fit `width`: the longest
263// prefix whose width plus the ellipsis fits, found in one forward scan. Prefix
264// widths accumulate one glyph advance at a time in authored order, with the
265// ellipsis advances added after, matching a from-scratch measure of the same
266// candidate. A zero width (capping lines without wrapping them) leaves the
267// line as it is.
268fn 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
296// Baseline position relative to a label's top-left `y`, so the cap-height band
297// is vertically centered within the line box `[y, y + line_height]`. Pinning the
298// baseline to the box bottom (the old behaviour) left a large gap above the
299// glyphs; centering the cap band makes short UI text sit centered in its box.
300fn 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
305// The visible glyphs' vertical extent above and below the first line's baseline,
306// in scaled pixels: how far the tallest glyph rises (ascent) and the lowest
307// glyph drops (descent). A tight background box and the layout measurement both
308// hug this, so the box wraps the ink with `padding` on every side instead of the
309// full em line box.
310fn 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
328/// Measure a label's background-box extent for layout: a box hugging the visible
329/// glyphs grown by the label's padding on every side, plus one line height per
330/// extra `\n`-split line. Mirrors the background-box math in `build_text_calls`.
331/// `top_inset` is the gap from the box top down to the text origin (the label's
332/// `y`), which `LayoutContainer` uses to place the box. Returns `None` for a
333/// hidden label, or one with no font to draw with at all, so a
334/// `LayoutContainer` drops it and reserves no space.
335pub 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        // Box top is `base_off - top_above - pad` below the origin; the inset is
356        // the origin's distance below the box top.
357        top_inset: top_above + pad - base_off,
358    })
359}
360
361/// Build one TextDrawCall per TextLabel, laying out character quads using the
362/// loaded font metrics. When `win_w` and `win_h` are both > 0.0, labels with
363/// `centered = true` are repositioned to the centre of the viewport. `clips`
364/// maps an element id to a reference-space clip band; a label found there has
365/// its call scissored to that band (mapped to the window), so a scrollable
366/// panel's off-band rows do not bleed over its chrome.
367pub 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
380/// `build_text_calls`, appending onto an existing draw list so a caller
381/// assembling a frame from several element groups reuses one buffer (and, in
382/// steady state, the pooled geometry of the spent frame it recycled).
383pub 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    // Screen-owned labels are overlay UI authored in the reference canvas; map
393    // them to the live window so menus scale with the window. HUD labels
394    // (view == None) keep literal window pixels.
395    let overlay = OverlayTransform::from_viewport([win_w, win_h]);
396    // Alternate mappings a view-owned label may opt into via `fit`.
397    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        // Everything below reads the laid-out content, not the authored string,
408        // so alignment, the background box, and the glyph run agree on the lines
409        // that are actually drawn.
410        let content = laid_out(label, font);
411        // One quad per glyph plus the optional background box; the byte length
412        // upper-bounds the glyph count.
413        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        // For centered labels, auto-scale to fill ~85% of the viewport while
419        // preserving the text's aspect ratio. The label's scale field is used
420        // for non-centered labels only.
421        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            // The anchor point and scale: a view-owned label maps through its
436            // `fit` transform, a HUD label stays in literal window pixels.
437            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            // Horizontal alignment shifts the anchor by the rendered width,
449            // measured with the real metrics so centered UI text sits exactly
450            // on its anchor at any scale (centering by the widest line).
451            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        // baseline: positioned so the cap-height band is centered within the line
470        // box, so short UI text sits vertically centered rather than pinned to
471        // the box bottom. Advanced by one line height on each newline so
472        // multi-line labels lay out down the screen.
473        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        // Background box: a filled quad behind the glyphs, sized to hug the
479        // visible glyphs grown by `padding` on every side (not the full em line
480        // box, which left a large gap above the caps). Emitted first so the
481        // glyphs composite on top. It carries a sentinel UV (a negative u) that
482        // the text shader reads as "solid fill", with the box alpha passed
483        // through in v. Empty content draws nothing at all (so a blanked label
484        // fully disappears).
485        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            // atlas_w/atlas_h are in supersampled atlas texels; divide by the
537            // supersample factor to get the glyph's logical size before scaling
538            // to the screen. The UVs below still address the full texel extent.
539            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
593// Map a reference-space clip band `[x, y, width, height]` to a window-space
594// rectangle through the overlay transform, so the backend can scissor to it.
595pub(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    // No clip bands: every label draws unclipped.
609    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            // 1x: the unit tests express glyph sizes directly in atlas texels.
640            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    // A font whose every glyph advances 10px, so a wrap width in pixels reads
666    // directly as a character count.
667    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        // 5 glyphs per line: "aaa bbb" is 7 wide, so the words split.
689        assert_eq!(wrapped("aaa bbb", 50.0, 0), ["aaa", "bbb"]);
690        // Exactly filling a line does not spill onto the next one.
691        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        // Every produced line is inside the width, which is the whole point.
704        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        // Three lines' worth of text into a two-line box.
713        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    // Wrapping has to reach the glyph run, the alignment measure, and the
730    // background box together, or a wrapped label draws its box around the
731    // unwrapped text.
732    #[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        // Two lines of five glyphs, not one line of eight.
743        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    // A world assembled in code has no compiled Font for its labels to name, so
767    // the renderer registers a fallback face and every font-less label draws
768    // with it.
769    #[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        // With no fallback there is no face to lay the glyphs out with.
778        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    // The fallback only stands in for a label that resolves to no face of its
803    // own; a label naming a loaded font keeps it.
804    #[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    // A label naming a font that never loaded falls back rather than vanishing,
818    // so a missing face costs styling instead of the text itself.
819    #[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        // 4 box verts prepended + 4 glyph verts; 6 box indices + 6 glyph.
885        assert_eq!(calls[0].vertices.len(), 8);
886        assert_eq!(calls[0].indices.len(), 12);
887        // The box quad comes first: sentinel u (< 0), box alpha carried in v.
888        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        // Glyph verts keep real, non-negative atlas UVs.
893        assert!(calls[0].vertices[4].uv[0] >= 0.0);
894    }
895
896    #[test]
897    fn derive_cap_px_uses_uppercase_reference() {
898        // 'H' is the cap-height reference, even when a lowercase glyph is taller.
899        let mut m = HashMap::new();
900        m.insert('H' as u32, make_glyph(8, 10, 9.0)); // bearing_y = 10
901        m.insert('g' as u32, make_glyph(8, 14, 9.0)); // taller, but lowercase
902        assert!((derive_cap_px(&m, 16.0) - 10.0).abs() < 1e-4);
903        // With no glyphs, fall back to a fraction of the em.
904        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        // The box wraps the visible glyph with `padding` above and below (instead
911        // of the full em line box, which left a large gap above the caps).
912        let g = make_glyph(10, 12, 11.0); // bearing_y = 12, no descent
913        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        // Verts 0..4 are the box; 4..8 the glyph quad.
928        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        // A blanked label (e.g. a toggled-off HUD chip) draws no box.
950        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        // Two spaces then 'A': only 'A' produces geometry.
970        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        // 'A' quad starts after 2 × advance_px(space) = 16.0
982        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        // A glyph whose atlas dimensions are 0×0 is invisible but still advances x.
989        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); // only 'A'
1013        // 'A' starts at x = advance_px('X') = 5.0
1014        assert!((calls[0].vertices[0].pos[0] - 5.0).abs() < 1e-4);
1015    }
1016
1017    #[test]
1018    fn newline_starts_a_new_line() {
1019        // "A\nA": the second glyph resets x to the label origin and drops
1020        // down by one line height (font size_px * scale = 16).
1021        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        // Two glyphs -> two quads -> 8 vertices, 12 indices.
1035        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        // x resets to the label origin on the new line.
1040        assert!((first.pos[0] - second.pos[0]).abs() < 1e-4);
1041        // y drops by exactly one line height.
1042        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        // Viewport 200×100; glyph advance=20, size_px=16, cap_px=12 ('A' bearing).
1057        // Auto-scale: sw = 200*0.85/20 = 8.5, sh = 100*0.85/16 = 5.3125 -> scale = 5.3125
1058        // tw = 20*5.3125 = 106.25, th = 16*5.3125 = 85.0
1059        // x0 = (200 - 106.25) / 2 = 46.875, y0 = (100 - 85.0) / 2 = 7.5
1060        // line_height = 16*5.3125 = 85; baseline centers the cap band:
1061        // baseline = 7.5 + (85 + 12*5.3125)/2 = 7.5 + 74.375 = 81.875
1062        // gx = x0 + bearing_x*scale = 46.875, gy = baseline - bearing_y*scale = 81.875 - 63.75 = 18.125
1063        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        // A view-owned (overlay) label is authored in the reference canvas and
1080        // mapped onto the window. At a 2x viewport its origin moves to the
1081        // forward-mapped position and its scale doubles. A HUD label (view ==
1082        // None) at the same coordinates stays put.
1083        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); // view == None
1088        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        // 2x reference viewport (1280x720 -> 2560x1440): scale 2, centered.
1093        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        // HUD label keeps its literal origin (x = 100).
1111        assert!((hud_calls[0].vertices[0].pos[0] - 100.0).abs() < 1e-3);
1112        // Overlay label: forward(100,100) at scale 2 -> x = 1280 + (100-640)*2 = 200.
1113        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        // Glyph width doubles (atlas_w 10 -> 20 on screen).
1119        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        // text width = 2 * advance(11) = 22, grown by padding on both sides.
1132        assert!((b.w - 30.0).abs() < 1e-4, "w={}", b.w);
1133        // The box hugs the glyphs: ascent(bearing_y=12) + descent(0) + 2*pad(4) = 20.
1134        assert!((b.h - 20.0).abs() < 1e-4, "h={}", b.h);
1135        assert!((b.pad - 4.0).abs() < 1e-4);
1136        // top_inset = ascent(12) + pad(4) - baseline_offset((16+12)/2=14) = 2.
1137        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        // Hidden label → None even with a loaded font.
1150        let mut hidden = make_label(FontHandle(0), "A", 0.0);
1151        hidden.visible = false;
1152        assert!(measure_label_box(&hidden, &fonts).is_none());
1153        // Visible label whose font isn't loaded → None.
1154        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        // Two 'A' glyphs, advance 10 each: rendered width = 20. A HUD label
1161        // (view == None) anchored at x = 100 keeps that x when left-aligned,
1162        // shifts left by half the width when centered, and by the full width
1163        // when right-aligned.
1164        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        // A label registered in `clips` gets its call scissored to the band,
1189        // mapped through the overlay transform to window space. At the reference
1190        // viewport the overlay is identity, so the scissor equals the band.
1191        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        // A label absent from `clips` (asset_id 0) draws unclipped.
1210        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        // At a 2x viewport a reference band is scaled and recentered.
1225        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        // forward(640,360) = center = (1280,720); the band doubles in size.
1228        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        // A view-owned label maps its anchor through its `fit` transform. At a
1237        // 4:3 viewport (taller than the 16:9 reference) Bottom pushes the label
1238        // below plain Fit, and Cover scales it up, so each branch yields a
1239        // distinct origin.
1240        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        // A code point with no metric advances the cursor by the space glyph's
1273        // advance, so an unknown character still occupies layout width.
1274        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        // '?' has no metric; it consumes one space advance before 'A'.
1279        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); // only 'A' draws a quad
1290        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        // measure_text_width (via measure_label_box) also substitutes the space
1296        // advance for an unknown glyph, keeping layout width stable.
1297        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        // The '?' contributes exactly one space advance (7) of extra width.
1306        assert!((wm - wk - 7.0).abs() < 1e-4, "wk={wk} wm={wm}");
1307    }
1308}