concinnity-engine 0.19.9

Runtime engine for Concinnity: ECS schedule, graphics, spawn, streaming
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
// Transient overlay element synthesis: the floating dropdown list and the
// text-input field box/caret. Both are built as plain Sprites + TextLabels and
// fed through the same shapers as the authored overlay elements.

use crate::components::{Sprite, TextInput, TextLabel};
use crate::ecs::asset_id::AssetId;
use crate::gfx::text;

// Persistent buffers for the synthesised elements, kept on the overlay system
// so a steady-state frame reuses their capacity (including every label's
// content String) instead of reallocating.
#[derive(Debug, Default)]
pub(super) struct WidgetScratch {
    pub(super) sprites: Vec<Sprite>,
    pub(super) labels: Vec<TextLabel>,
    // Spent content Strings reclaimed from the previous build's labels.
    strings: Vec<String>,
}

impl WidgetScratch {
    // Start a new build: drop the previous elements, pooling their Strings.
    fn begin(&mut self) {
        self.sprites.clear();
        for label in self.labels.drain(..) {
            let mut s = label.content;
            s.clear();
            self.strings.push(s);
        }
    }

    // An owned copy of `content`, backed by a pooled String when one is spare.
    fn string(&mut self, content: &str) -> String {
        let mut s = self.strings.pop().unwrap_or_default();
        s.push_str(content);
        s
    }
}

// Build the transient overlay Sprites + TextLabels for an open dropdown list
// into `out`, replacing its previous contents. These are fed through the same
// sprite / text shapers as the menu but with no clip bands, so the list draws
// unclipped on top of the menu (escaping the scroll band's scissor). Geometry
// is reference space for a screen-owned row (and window pixels otherwise),
// matching the input hit-test in `ui`.
pub(super) fn build_dropdown_overlay(
    screen: &crate::ecs::DropdownView,
    loaded_fonts: &text::FontSet,
    out: &mut WidgetScratch,
) {
    // Panel fill (near-opaque so rows behind it do not show through), a framing
    // border, and the selected / hovered row highlights.
    const PANEL_BG: [f32; 4] = [0.06, 0.08, 0.14, 0.98];
    const BORDER: [f32; 4] = [0.28, 0.34, 0.52, 1.0];
    const SELECTED_BG: [f32; 4] = [0.14, 0.20, 0.34, 1.0];
    const HOVER_BG: [f32; 4] = [0.22, 0.30, 0.48, 1.0];
    const TRACK: [f32; 4] = [0.12, 0.15, 0.25, 0.95];
    const THUMB: [f32; 4] = [0.45, 0.52, 0.70, 0.9];
    const TEXT_PAD: f32 = 10.0;
    const BORDER_PX: f32 = 2.0;

    use crate::ui::dropdown;
    let count = screen.options.len();
    let layout = dropdown::layout(screen.anchor, count);
    // The layout windows a long list; rows show options `first..`, so the
    // selected / hovered option indices map to row indices (off-window ones
    // simply draw no highlight).
    let first = screen.first.min(dropdown::max_first(count));
    let row_of = |option: usize| {
        option
            .checked_sub(first)
            .filter(|r| *r < layout.items.len())
    };
    out.begin();
    let mk_sprite = |rect: [f32; 4], tint: [f32; 4]| Sprite {
        asset_id: AssetId::default(),
        x: rect[0],
        y: rect[1],
        width: rect[2],
        height: rect[3],
        texture: None,
        tint,
        follow_cursor: false,
        visible: true,
        screen: screen.screen,
        fit: crate::components::SpriteFit::Fit,
        corner_radius: 0.0,
        border_width: 0.0,
        border_color: [0.0, 0.0, 0.0, 1.0],
    };

    // Border quad (a little larger, drawn first) then the panel fill on top.
    let [lx, ly, lw, lh] = layout.list;
    out.sprites.push(mk_sprite(
        [
            lx - BORDER_PX,
            ly - BORDER_PX,
            lw + 2.0 * BORDER_PX,
            lh + 2.0 * BORDER_PX,
        ],
        BORDER,
    ));
    out.sprites.push(mk_sprite(layout.list, PANEL_BG));
    // The currently-applied option, then the hovered one on top of it (each
    // only when its option is inside the shown window).
    if let Some(rect) = row_of(screen.selected).and_then(|r| layout.items.get(r)) {
        out.sprites.push(mk_sprite(*rect, SELECTED_BG));
    }
    if let Some(rect) = screen
        .hovered
        .and_then(row_of)
        .and_then(|r| layout.items.get(r))
    {
        out.sprites.push(mk_sprite(*rect, HOVER_BG));
    }
    // A scrolled list gets a scrollbar inside its right edge: a faint
    // full-height track with the draggable thumb over it.
    if let Some(rect) = dropdown::track_rect(&layout, count) {
        out.sprites.push(mk_sprite(rect, TRACK));
    }
    if let Some(rect) = dropdown::thumb_rect(&layout, first, count) {
        out.sprites.push(mk_sprite(rect, THUMB));
    }

    // One text label per SHOWN option, vertically centered in its row (the text
    // draws after the sprites, so it sits over the highlights).
    let line_h = loaded_fonts
        .resolve(screen.font)
        .map(|f| f.size_px * screen.scale)
        .unwrap_or(0.0);
    for (opt, rect) in screen.options.iter().skip(first).zip(&layout.items) {
        let content = out.string(opt);
        out.labels.push(TextLabel {
            asset_id: AssetId::default(),
            font: screen.font,
            content,
            x: rect[0] + TEXT_PAD,
            y: rect[1] + (rect[3] - line_h) / 2.0,
            color: screen.color,
            scale: screen.scale,
            centered: false,
            align: crate::components::TextAlign::Left,
            fit: crate::components::SpriteFit::Fit,
            background: [0.0, 0.0, 0.0, 0.0],
            padding: 0.0,
            // An option longer than the list is cut with an ellipsis rather
            // than drawn out past the dropdown's edge.
            wrap_width: (rect[2] - 2.0 * TEXT_PAD).max(0.0),
            max_lines: 1,
            visible: true,
            screen: screen.screen,
        });
    }
}

// The visible slice of a single-line field's text, fit to its box width and
// appended to `out`. `avail` is the drawable text width; `advance` measures the
// rendered width of a prefix with the real font metrics. Returns the x offset
// to add to the field's left text edge (always >= 0, so nothing bleeds left)
// and the caret's x offset from that same edge. A field that fits passes
// through untouched; one that overflows is truncated from the head with an
// ellipsis while unfocused, or horizontally scrolled to keep the caret in
// screen while focused.
fn fit_line(
    content: &str,
    caret_byte: usize,
    avail: f32,
    focused: bool,
    advance: impl Fn(&str) -> f32,
    out: &mut String,
) -> (f32, f32) {
    const ELLIPSIS: &str = "...";
    let caret_byte = caret_byte.min(content.len());
    let full = advance(content);
    if avail <= 0.0 || full <= avail {
        out.push_str(content);
        return (0.0, advance(&content[..caret_byte]));
    }
    if focused {
        // Pin the caret to the right edge once the text runs past the box, easing
        // back to the left as the caret returns.
        let caret_w = advance(&content[..caret_byte]);
        let scroll = (caret_w - avail).max(0.0);
        // Byte boundaries: the start, then the end of each char.
        let bounds = || {
            core::iter::once(0usize).chain(content.char_indices().map(|(b, c)| b + c.len_utf8()))
        };
        // Drop chars fully scrolled off the left so nothing bleeds past that edge.
        let start = bounds()
            .find(|&b| advance(&content[..b]) >= scroll)
            .unwrap_or(0);
        // Keep chars up to the last boundary still inside the box.
        let mut end = None;
        for b in bounds() {
            if advance(&content[..b]) - scroll <= avail {
                end = Some(b);
            }
        }
        let end = end.unwrap_or(content.len()).max(start);
        out.push_str(content.get(start..end).unwrap_or(""));
        (advance(&content[..start]) - scroll, caret_w - scroll)
    } else {
        // Truncate from the head, leaving room for an ellipsis.
        let ell = advance(ELLIPSIS);
        let mut end = 0usize;
        for (b, c) in content.char_indices() {
            let nb = b + c.len_utf8();
            if advance(&content[..nb]) + ell > avail {
                break;
            }
            end = nb;
        }
        out.push_str(&content[..end]);
        out.push_str(ELLIPSIS);
        (0.0, 0.0)
    }
}

// Synthesise the transient Sprites + TextLabels that draw a TextInput field
// into `out`, replacing its previous contents: a background box, the typed
// content (or the dimmer placeholder while empty and unfocused), and a caret
// bar while focused. Fed through the same shapers as the authored overlay
// elements, carrying the field's `screen` / `fit` so screen mapping and
// visibility apply. Mirrors `build_dropdown_overlay`. The text is fit to the
// box (`fit_line`) so a long value never bleeds past the field's edges.
pub(super) fn build_text_input_overlay(
    ti: &TextInput,
    loaded_fonts: &text::FontSet,
    caret_visible: bool,
    out: &mut WidgetScratch,
) {
    const CARET_W: f32 = 2.0;
    let font = loaded_fonts.resolve(ti.font);
    let line_h = font
        .map(|f| f.size_px * ti.scale)
        .unwrap_or(ti.height * 0.6);
    // Text baseline math centres the cap band in `[y, y + line_h]`, so placing
    // the line box's top here vertically centres the text in the field.
    let text_y = ti.y + (ti.height - line_h) / 2.0;

    out.begin();
    out.sprites.push(Sprite {
        asset_id: AssetId::default(),
        x: ti.x,
        y: ti.y,
        width: ti.width,
        height: ti.height,
        texture: None,
        tint: ti.background,
        follow_cursor: false,
        visible: true,
        screen: ti.screen,
        fit: ti.fit,
        corner_radius: ti.corner_radius,
        border_width: 0.0,
        border_color: [0.0, 0.0, 0.0, 1.0],
    });

    // Placeholder only while empty and unfocused; otherwise the live content.
    let showing_placeholder = ti.content.is_empty() && !ti.focused;
    let (raw, color) = if showing_placeholder {
        (ti.placeholder.as_str(), ti.placeholder_color)
    } else {
        (ti.content.as_str(), ti.text_color)
    };
    // The byte offset of the caret within the live content (only consulted for the
    // focused, content-showing case; harmless otherwise).
    let caret_byte = {
        let caret = ti.caret.min(ti.content.chars().count());
        ti.content
            .char_indices()
            .nth(caret)
            .map(|(b, _)| b)
            .unwrap_or(ti.content.len())
    };
    // Fit the text to the box. Without a loaded font we cannot measure, so pass it
    // through (it will not be rendered until a font loads).
    let avail = (ti.width - 2.0 * ti.padding - CARET_W).max(0.0);
    let mut content = out.string("");
    let (x_offset, caret_off) = match font {
        Some(f) => fit_line(
            raw,
            caret_byte,
            avail,
            ti.focused,
            |s| text::text_advance_width(s, f, ti.scale),
            &mut content,
        ),
        None => {
            content.push_str(raw);
            (0.0, 0.0)
        }
    };

    out.labels.push(TextLabel {
        asset_id: AssetId::default(),
        font: ti.font,
        content,
        x: ti.x + ti.padding + x_offset,
        y: text_y,
        color,
        scale: ti.scale,
        centered: false,
        align: crate::components::TextAlign::Left,
        fit: ti.fit,
        background: [0.0, 0.0, 0.0, 0.0],
        padding: 0.0,
        // A field's text is scrolled horizontally by `x_offset`, not wrapped.
        wrap_width: 0.0,
        max_lines: 0,
        visible: true,
        screen: ti.screen,
    });

    // Inline completion: the ghost suffix in the placeholder colour just past
    // the caret, only while the field holds focus and the content fits the box
    // (scrolled overflow leaves it no stable anchor). Clipped to the remaining
    // width, dropped entirely when no character fits.
    if ti.focused
        && !ti.ghost.is_empty()
        && !showing_placeholder
        && let Some(f) = font
    {
        let advance = |s: &str| text::text_advance_width(s, f, ti.scale);
        let content_w = advance(&ti.content);
        if content_w <= avail {
            let room = avail - content_w - CARET_W;
            let mut end = 0usize;
            for (b, c) in ti.ghost.char_indices() {
                let nb = b + c.len_utf8();
                if advance(&ti.ghost[..nb]) > room {
                    break;
                }
                end = nb;
            }
            if end > 0 {
                let content = out.string(&ti.ghost[..end]);
                out.labels.push(TextLabel {
                    asset_id: AssetId::default(),
                    font: ti.font,
                    content,
                    x: ti.x + ti.padding + content_w + CARET_W,
                    y: text_y,
                    color: ti.placeholder_color,
                    scale: ti.scale,
                    centered: false,
                    align: crate::components::TextAlign::Left,
                    fit: ti.fit,
                    background: [0.0, 0.0, 0.0, 0.0],
                    padding: 0.0,
                    wrap_width: 0.0,
                    max_lines: 0,
                    visible: true,
                    screen: ti.screen,
                });
            }
        }
    }

    // Caret: a thin bar at the caret's fit position, only while the field holds
    // focus and the font loaded, and only on the visible half of the blink cycle.
    if ti.focused && font.is_some() && caret_visible {
        let caret_x = ti.x + ti.padding + caret_off;
        out.sprites.push(Sprite {
            asset_id: AssetId::default(),
            x: caret_x,
            y: text_y,
            width: CARET_W,
            height: line_h,
            texture: None,
            tint: [ti.caret_color[0], ti.caret_color[1], ti.caret_color[2], 1.0],
            follow_cursor: false,
            visible: true,
            screen: ti.screen,
            fit: ti.fit,
            corner_radius: 0.0,
            border_width: 0.0,
            border_color: [0.0, 0.0, 0.0, 1.0],
        });
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::ecs::{DropdownView, FontHandle};

    // Tuple-returning shims over the scratch-filling builders, so the tests
    // read the built elements as owned lists.
    fn fit_line(
        content: &str,
        caret_byte: usize,
        avail: f32,
        focused: bool,
        advance: impl Fn(&str) -> f32,
    ) -> (String, f32, f32) {
        let mut out = String::new();
        let (x_offset, caret_off) =
            super::fit_line(content, caret_byte, avail, focused, advance, &mut out);
        (out, x_offset, caret_off)
    }

    fn build_dropdown_overlay(
        screen: &DropdownView,
        loaded_fonts: &text::FontSet,
    ) -> (Vec<Sprite>, Vec<TextLabel>) {
        let mut out = WidgetScratch::default();
        super::build_dropdown_overlay(screen, loaded_fonts, &mut out);
        (out.sprites, out.labels)
    }

    fn build_text_input_overlay(
        ti: &TextInput,
        loaded_fonts: &text::FontSet,
        caret_visible: bool,
    ) -> (Vec<Sprite>, Vec<TextLabel>) {
        let mut out = WidgetScratch::default();
        super::build_text_input_overlay(ti, loaded_fonts, caret_visible, &mut out);
        (out.sprites, out.labels)
    }

    // A fixed-width mock metric (every char 10px) makes `fit_line` widths exact.
    fn mock_advance(s: &str) -> f32 {
        s.chars().count() as f32 * 10.0
    }

    const FONT: FontHandle = FontHandle(0);

    fn make_glyph(advance_px: f32) -> crate::gfx::font::GlyphMetrics {
        crate::gfx::font::GlyphMetrics {
            char_code: 0,
            atlas_x: 0,
            atlas_y: 0,
            atlas_w: 8,
            atlas_h: 12,
            advance_px,
            bearing_x: 0.0,
            bearing_y: 12.0,
        }
    }

    // A fixed-width synthetic font (every glyph 10px in a 16px em) makes the
    // built geometry exact.
    fn loaded_fonts() -> text::FontSet {
        let metrics: crate::gfx::text::FontMetrics = ('a'..='z')
            .chain('A'..='Z')
            .chain('0'..='9')
            .chain(['.', ' '])
            .map(|c| (c as u32, make_glyph(10.0)))
            .collect();
        let cap_px = text::derive_cap_px(&metrics, 16.0);
        let mut fonts = text::FontSet::default();
        fonts.insert(
            FONT,
            text::LoadedFont {
                atlas_slot: 0,
                cap_px,
                metrics,
                atlas_w: 128,
                atlas_h: 128,
                size_px: 16.0,
                supersample: 1.0,
            },
        );
        fonts
    }

    fn no_fonts() -> text::FontSet {
        text::FontSet::default()
    }

    // A list anchored to a 200x40 control at (400, 100), which has room to open
    // downward from y = 140.
    fn dropdown_view(options: &[&str]) -> DropdownView {
        DropdownView {
            anchor: [400.0, 100.0, 200.0, 40.0],
            options: options.iter().map(|s| s.to_string()).collect(),
            selected: 0,
            first: 0,
            hovered: None,
            screen: Some(AssetId(5)),
            font: Some(FONT),
            scale: 1.0,
            color: [1.0, 1.0, 1.0],
        }
    }

    fn text_input() -> TextInput {
        TextInput {
            font: Some(FONT),
            placeholder: "type here".to_string(),
            ..Default::default()
        }
    }

    fn rect(s: &Sprite) -> [f32; 4] {
        [s.x, s.y, s.width, s.height]
    }

    // A reused scratch replaces its previous build wholesale, pooling the spent
    // label Strings, so back-to-back builds produce identical elements.
    #[test]
    fn a_reused_scratch_builds_the_same_elements_as_a_fresh_one() {
        let view = dropdown_view(&["aa", "bb"]);
        let fonts = loaded_fonts();
        let mut scratch = WidgetScratch::default();
        super::build_dropdown_overlay(&view, &fonts, &mut scratch);
        let sprites = scratch.sprites.len();
        let contents: Vec<String> = scratch.labels.iter().map(|l| l.content.clone()).collect();

        // A different build in between leaves nothing of the dropdown behind.
        super::build_text_input_overlay(&text_input(), &fonts, true, &mut scratch);
        super::build_dropdown_overlay(&view, &fonts, &mut scratch);
        assert_eq!(scratch.sprites.len(), sprites);
        let again: Vec<String> = scratch.labels.iter().map(|l| l.content.clone()).collect();
        assert_eq!(again, contents);
    }

    // Text that fits the box is returned untouched, with the caret at its measured
    // position.
    #[test]
    fn fit_line_passes_through_text_that_fits() {
        let (text, xoff, caret) = fit_line("abc", 3, 100.0, false, mock_advance);
        assert_eq!(text, "abc");
        assert_eq!(xoff, 0.0);
        assert_eq!(caret, 30.0);
    }

    // An unfocused overflow is truncated from the head with an ellipsis that fits
    // inside the box.
    #[test]
    fn fit_line_truncates_unfocused_overflow_with_ellipsis() {
        // avail 65, ellipsis "..." = 30px: keep chars while width + 30 <= 65 (3 chars).
        let (text, xoff, _) = fit_line("abcdefghij", 0, 65.0, false, mock_advance);
        assert_eq!(text, "abc...");
        assert_eq!(xoff, 0.0);
    }

    // A focused overflow scrolls so the caret (at the end here) stays at the box's
    // right edge, dropping the head that ran off the left.
    #[test]
    fn fit_line_scrolls_focused_overflow_to_keep_the_caret_visible() {
        // 100px of text, 50px box, caret at end: scroll 50 -> show the last 5 chars.
        let (text, xoff, caret) = fit_line("abcdefghij", 10, 50.0, true, mock_advance);
        assert_eq!(text, "fghij");
        assert_eq!(xoff, 0.0);
        assert_eq!(caret, 50.0, "caret pinned to the right edge");
        assert!(caret <= 50.0, "caret never past the box");
    }

    // With the caret at the start, a focused overflow shows the head (no scroll).
    #[test]
    fn fit_line_focused_caret_at_start_shows_the_head() {
        let (text, xoff, caret) = fit_line("abcdefghij", 0, 50.0, true, mock_advance);
        assert_eq!(text, "abcde");
        assert_eq!(xoff, 0.0);
        assert_eq!(caret, 0.0);
    }

    // The list stacks back to front: the border quad, the panel fill over it, then
    // the selected row's highlight and the hovered row's on top. The option text
    // follows the sprites, so it draws over the highlights.
    #[test]
    fn build_dropdown_overlay_layers_border_panel_then_highlights() {
        let mut view = dropdown_view(&["aa", "bb", "cc"]);
        view.selected = 1;
        view.hovered = Some(2);
        let (sprites, labels) = build_dropdown_overlay(&view, &loaded_fonts());
        assert_eq!(sprites.len(), 4);
        // The border sits 2px outside the list rect on every side.
        assert_eq!(rect(&sprites[0]), [398.0, 138.0, 204.0, 124.0]);
        assert_eq!(rect(&sprites[1]), [400.0, 140.0, 200.0, 120.0]);
        // Highlights land on the selected row then the hovered one.
        assert_eq!(rect(&sprites[2]), [400.0, 180.0, 200.0, 40.0]);
        assert_eq!(rect(&sprites[3]), [400.0, 220.0, 200.0, 40.0]);
        assert_ne!(sprites[2].tint, sprites[3].tint);
        // Every sprite carries the view's screen, so it maps like the menu it drops from.
        assert!(sprites.iter().all(|s| s.screen == view.screen && s.visible));
        // One label per shown option, inset by the text pad and centred on a 16px line.
        assert_eq!(labels.len(), 3);
        assert_eq!(labels[0].content, "aa");
        assert_eq!((labels[0].x, labels[0].y), (410.0, 152.0));
        assert_eq!((labels[2].x, labels[2].y), (410.0, 232.0));
        assert!(labels.iter().all(|l| l.font == view.font && l.visible));
    }

    // A list longer than the layout window shows options from `first` onward:
    // selected / hovered options outside that window draw no highlight, and the
    // scrolled list gains a scrollbar track with its thumb over it.
    #[test]
    fn build_dropdown_overlay_windows_a_scrolled_list() {
        let options: Vec<String> = (0..16).map(|i| format!("o{i}")).collect();
        let refs: Vec<&str> = options.iter().map(|s| s.as_str()).collect();
        let mut view = dropdown_view(&refs);
        view.first = 8;
        view.selected = 0;
        view.hovered = Some(3);
        let (sprites, labels) = build_dropdown_overlay(&view, &loaded_fonts());
        // Border + panel + track + thumb: both highlighted options are off the window.
        assert_eq!(sprites.len(), 4);
        assert_eq!(labels.len(), 8);
        assert_eq!(labels[0].content, "o8");
        assert_eq!(labels[7].content, "o15");
        // The track spans the list's full height; the thumb rides inside it, here
        // at the bottom of its travel.
        let (track, thumb) = (&sprites[2], &sprites[3]);
        assert_eq!((track.y, track.height), (140.0, 320.0));
        assert!(thumb.x >= track.x && thumb.x + thumb.width <= track.x + track.width);
        assert_eq!(thumb.y + thumb.height, track.y + track.height);
    }

    // A list that fits needs no scrollbar, and an out-of-range scroll position
    // clamps rather than windowing rows away.
    #[test]
    fn build_dropdown_overlay_fitting_list_has_no_scrollbar() {
        let mut view = dropdown_view(&["aa", "bb", "cc"]);
        view.first = 99;
        let (sprites, labels) = build_dropdown_overlay(&view, &loaded_fonts());
        // Border + panel + the selected highlight only.
        assert_eq!(sprites.len(), 3);
        assert_eq!(labels.len(), 3);
        assert_eq!(labels[0].content, "aa");
    }

    // Without a loaded font there is no line height to centre on, so the text
    // falls back to the row's midpoint.
    #[test]
    fn build_dropdown_overlay_without_a_loaded_font_centres_on_a_zero_line() {
        let view = dropdown_view(&["aa"]);
        let (_, labels) = build_dropdown_overlay(&view, &no_fonts());
        assert_eq!(labels[0].y, 160.0);
    }

    // An empty, unfocused field shows the dimmer placeholder over a box mirroring
    // the field's geometry, with no caret.
    #[test]
    fn build_text_input_overlay_shows_the_placeholder_while_empty_and_unfocused() {
        let ti = text_input();
        let (sprites, labels) = build_text_input_overlay(&ti, &loaded_fonts(), true);
        assert_eq!(sprites.len(), 1);
        assert_eq!(rect(&sprites[0]), [ti.x, ti.y, ti.width, ti.height]);
        assert_eq!(sprites[0].tint, ti.background);
        assert_eq!(sprites[0].corner_radius, ti.corner_radius);
        assert_eq!(labels.len(), 1);
        assert_eq!(labels[0].content, "type here");
        assert_eq!(labels[0].color, ti.placeholder_color);
        // Text starts at the padding inset; the 16px line centres in the 40px box.
        assert_eq!((labels[0].x, labels[0].y), (8.0, 12.0));
    }

    // Content, or focus on an empty field, replaces the placeholder with the live
    // text in the content colour.
    #[test]
    fn build_text_input_overlay_shows_content_over_the_placeholder() {
        let mut ti = text_input();
        ti.content = "abc".to_string();
        let (_, labels) = build_text_input_overlay(&ti, &loaded_fonts(), false);
        assert_eq!(labels[0].content, "abc");
        assert_eq!(labels[0].color, ti.text_color);
        // Focusing an empty field drops the placeholder: it is being typed into.
        let focused = TextInput {
            focused: true,
            ..text_input()
        };
        let (_, labels) = build_text_input_overlay(&focused, &loaded_fonts(), false);
        assert_eq!(labels[0].content, "");
        assert_eq!(labels[0].color, focused.text_color);
    }

    // The caret bar draws only while the field holds focus and the blink is on its
    // visible half, sitting at the caret's fit position.
    #[test]
    fn build_text_input_overlay_draws_the_caret_only_on_a_focused_visible_blink() {
        let mut ti = text_input();
        ti.content = "abc".to_string();
        ti.focused = true;
        ti.caret = 3;
        let (sprites, _) = build_text_input_overlay(&ti, &loaded_fonts(), true);
        assert_eq!(sprites.len(), 2);
        // padding (8) + three 10px glyphs; a 2px bar spanning the 16px line box.
        assert_eq!(rect(&sprites[1]), [38.0, 12.0, 2.0, 16.0]);
        assert_eq!(sprites[1].tint[3], 1.0, "the caret always draws opaque");
        // The blink's dark half draws the box alone.
        assert_eq!(
            build_text_input_overlay(&ti, &loaded_fonts(), false)
                .0
                .len(),
            1
        );
        // So does an unfocused field.
        ti.focused = false;
        assert_eq!(
            build_text_input_overlay(&ti, &loaded_fonts(), true).0.len(),
            1
        );
    }

    // The ghost suffix draws after the caret in the placeholder colour, and
    // only while the field holds focus.
    #[test]
    fn build_text_input_overlay_draws_the_ghost_after_the_caret() {
        let mut ti = text_input();
        ti.content = "abc".to_string();
        ti.ghost = "def".to_string();
        ti.focused = true;
        ti.caret = 3;
        let (_, labels) = build_text_input_overlay(&ti, &loaded_fonts(), true);
        assert_eq!(labels.len(), 2);
        assert_eq!(labels[0].content, "abc");
        assert_eq!(labels[0].color, ti.text_color);
        assert_eq!(labels[1].content, "def");
        assert_eq!(labels[1].color, ti.placeholder_color);
        // padding (8) + three 10px glyphs + the caret bar's width (2).
        assert_eq!((labels[1].x, labels[1].y), (40.0, 12.0));
        ti.focused = false;
        assert_eq!(
            build_text_input_overlay(&ti, &loaded_fonts(), true).1.len(),
            1,
            "an unfocused field never shows the ghost"
        );
    }

    // The ghost clips to the box's remaining width, and disappears entirely
    // once the content itself overflows (a scrolled fit leaves it no anchor).
    #[test]
    fn build_text_input_overlay_clips_the_ghost_to_the_box() {
        let mut ti = text_input();
        // avail = 66 - 2*8 - 2 = 48: 30px of content leaves 16px, one glyph.
        ti.width = 66.0;
        ti.content = "abc".to_string();
        ti.ghost = "defgh".to_string();
        ti.focused = true;
        ti.caret = 3;
        let (_, labels) = build_text_input_overlay(&ti, &loaded_fonts(), true);
        assert_eq!(labels.len(), 2);
        assert_eq!(labels[1].content, "d");
        // avail = 22 < the 30px content: focused overflow scrolls, no ghost.
        ti.width = 40.0;
        let (_, labels) = build_text_input_overlay(&ti, &loaded_fonts(), true);
        assert_eq!(labels.len(), 1);
    }

    // Without a loaded font nothing can be measured: the text passes through
    // unfit, the line height falls back to a fraction of the field, and no caret
    // draws even while focused.
    #[test]
    fn build_text_input_overlay_without_a_loaded_font_passes_text_through() {
        let mut ti = text_input();
        // Long enough that a measured field would scroll or truncate it.
        ti.content = "abcdefghijklmnopqrstuvwxyz".to_string();
        ti.focused = true;
        let (sprites, labels) = build_text_input_overlay(&ti, &no_fonts(), true);
        assert_eq!(sprites.len(), 1);
        assert_eq!(labels[0].content, ti.content);
        // line_h = height * 0.6 = 24, centred in the 40px box.
        assert_eq!((labels[0].x, labels[0].y), (8.0, 8.0));
    }
}