Skip to main content

proof_engine/render/
text_renderer.rs

1//! Text layout and rendering — monospace glyph sequences with rich text support.
2//!
3//! `TextBlock` lays out a string as a grid of per-character glyphs in world or
4//! screen space.  Rich text markup `[color:r,g,b]text[/color]` is supported for
5//! color changes mid-string.  A typewriter effect is built in.
6
7use glam::{Vec2, Vec3, Vec4};
8use crate::glyph::{Glyph, GlyphPool, BlendMode, RenderLayer};
9
10// ── Text alignment ────────────────────────────────────────────────────────────
11
12#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
13pub enum TextAlign {
14    #[default]
15    Left,
16    Center,
17    Right,
18}
19
20#[derive(Clone, Copy, Debug, PartialEq, Eq, Default)]
21pub enum TextBaseline {
22    #[default]
23    Top,
24    Middle,
25    Bottom,
26}
27
28// ── Span (rich text segment) ──────────────────────────────────────────────────
29
30/// A run of text with uniform style.
31#[derive(Clone, Debug)]
32pub struct TextSpan {
33    pub text:     String,
34    pub color:    Vec4,
35    pub emission: f32,
36    pub scale:    Vec2,
37    pub layer:    RenderLayer,
38    pub blend:    BlendMode,
39}
40
41impl TextSpan {
42    pub fn plain(text: &str) -> Self {
43        Self {
44            text:     text.into(),
45            color:    Vec4::ONE,
46            emission: 0.0,
47            scale:    Vec2::ONE,
48            layer:    RenderLayer::UI,
49            blend:    BlendMode::Normal,
50        }
51    }
52
53    pub fn colored(text: &str, color: Vec4) -> Self {
54        Self { text: text.into(), color, ..Self::plain("") }
55    }
56
57    pub fn glowing(text: &str, color: Vec4, emission: f32) -> Self {
58        Self { text: text.into(), color, emission, ..Self::plain("") }
59    }
60}
61
62// ── Rich text parser ──────────────────────────────────────────────────────────
63
64/// Parses a simple markup string into a list of `TextSpan`s.
65///
66/// Supported tags:
67/// - `[color:r,g,b]text[/color]` — RGB color (0..1 floats)
68/// - `[rgba:r,g,b,a]text[/rgba]` — RGBA color
69/// - `[emit:v]text[/emit]` — emission strength
70/// - `[scale:x,y]text[/scale]` — per-span scale
71/// - `[bold]text[/bold]` — treated as scale:1.2,1.2
72/// - `[wave]text[/wave]` — marks span for wavy animation (emission > 0)
73pub fn parse_rich_text(markup: &str) -> Vec<TextSpan> {
74    let mut spans  = Vec::new();
75    let mut stack: Vec<TextSpan> = Vec::new();
76    let mut cursor = 0_usize;
77    let bytes = markup.as_bytes();
78
79    // Current style defaults
80    let mut color    = Vec4::ONE;
81    let mut emission = 0.0_f32;
82    let mut scale    = Vec2::ONE;
83
84    let mut text_buf = String::new();
85
86    macro_rules! flush {
87        () => {
88            if !text_buf.is_empty() {
89                spans.push(TextSpan {
90                    text:     std::mem::take(&mut text_buf),
91                    color, emission, scale,
92                    layer: RenderLayer::UI,
93                    blend: BlendMode::Normal,
94                });
95            }
96        }
97    }
98
99    while cursor < bytes.len() {
100        if bytes[cursor] == b'[' {
101            // Find closing ]
102            if let Some(end) = markup[cursor..].find(']') {
103                let tag = &markup[cursor+1 .. cursor+end];
104                cursor += end + 1;
105
106                if tag.starts_with('/') {
107                    // Closing tag — restore style from stack
108                    flush!();
109                    if let Some(saved) = stack.pop() {
110                        color    = saved.color;
111                        emission = saved.emission;
112                        scale    = saved.scale;
113                    }
114                } else if let Some(rest) = tag.strip_prefix("color:") {
115                    flush!();
116                    stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
117                    let parts: Vec<f32> = rest.split(',')
118                        .filter_map(|s| s.trim().parse().ok())
119                        .collect();
120                    if parts.len() >= 3 {
121                        color = Vec4::new(parts[0], parts[1], parts[2], 1.0);
122                    }
123                } else if let Some(rest) = tag.strip_prefix("rgba:") {
124                    flush!();
125                    stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
126                    let parts: Vec<f32> = rest.split(',')
127                        .filter_map(|s| s.trim().parse().ok())
128                        .collect();
129                    if parts.len() >= 4 {
130                        color = Vec4::new(parts[0], parts[1], parts[2], parts[3]);
131                    }
132                } else if let Some(rest) = tag.strip_prefix("emit:") {
133                    flush!();
134                    stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
135                    emission = rest.trim().parse().unwrap_or(0.0);
136                } else if let Some(rest) = tag.strip_prefix("scale:") {
137                    flush!();
138                    stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
139                    let parts: Vec<f32> = rest.split(',')
140                        .filter_map(|s| s.trim().parse().ok())
141                        .collect();
142                    if parts.len() >= 2 {
143                        scale = Vec2::new(parts[0], parts[1]);
144                    }
145                } else if tag == "bold" {
146                    flush!();
147                    stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
148                    scale = Vec2::new(1.15, 1.15);
149                } else if tag == "wave" {
150                    flush!();
151                    stack.push(TextSpan { color, emission, scale, ..TextSpan::plain("") });
152                    emission = 0.5;
153                    color    = Vec4::new(0.7, 0.9, 1.0, 1.0);
154                }
155                continue;
156            }
157        }
158
159        // Regular character
160        text_buf.push(markup[cursor..].chars().next().unwrap_or(' '));
161        cursor += markup[cursor..].chars().next().map(|c| c.len_utf8()).unwrap_or(1);
162    }
163    flush!();
164
165    if spans.is_empty() {
166        spans.push(TextSpan::plain(markup));
167    }
168    spans
169}
170
171// ── TextBlock ─────────────────────────────────────────────────────────────────
172
173/// A laid-out block of text.  Position is the top-left corner in world/screen space.
174#[derive(Clone, Debug)]
175pub struct TextBlock {
176    pub spans:       Vec<TextSpan>,
177    pub position:    Vec3,
178    pub char_width:  f32,
179    pub char_height: f32,
180    pub max_width:   Option<f32>,    // word wrap: max columns (chars)
181    pub align:       TextAlign,
182    pub baseline:    TextBaseline,
183    pub layer:       RenderLayer,
184    pub blend:       BlendMode,
185    pub visible:     bool,
186    pub z_offset:    f32,
187}
188
189impl TextBlock {
190    pub fn new(text: &str, position: Vec3) -> Self {
191        Self {
192            spans:       vec![TextSpan::plain(text)],
193            position,
194            char_width:  0.6,
195            char_height: 1.0,
196            max_width:   None,
197            align:       TextAlign::Left,
198            baseline:    TextBaseline::Top,
199            layer:       RenderLayer::UI,
200            blend:       BlendMode::Normal,
201            visible:     true,
202            z_offset:    0.0,
203        }
204    }
205
206    pub fn rich(markup: &str, position: Vec3) -> Self {
207        Self {
208            spans: parse_rich_text(markup),
209            ..Self::new("", position)
210        }
211    }
212
213    pub fn with_color(mut self, c: Vec4) -> Self {
214        for s in &mut self.spans { s.color = c; }
215        self
216    }
217
218    pub fn with_scale(mut self, w: f32, h: f32) -> Self {
219        self.char_width  = w;
220        self.char_height = h;
221        self
222    }
223
224    pub fn with_align(mut self, a: TextAlign) -> Self { self.align = a; self }
225    pub fn with_max_width(mut self, w: f32) -> Self { self.max_width = Some(w); self }
226    pub fn with_layer(mut self, l: RenderLayer) -> Self { self.layer = l; self }
227
228    /// Full text content (all spans concatenated).
229    pub fn full_text(&self) -> String {
230        self.spans.iter().map(|s| s.text.as_str()).collect()
231    }
232
233    /// Lay out characters into positions.  Returns (char, Vec3, Vec4, f32_emission) tuples.
234    pub fn layout(&self) -> Vec<CharLayout> {
235        let full = self.full_text();
236        let lines = self.wrap_lines(&full);
237        let total_height = lines.len() as f32 * self.char_height;
238
239        let baseline_offset = match self.baseline {
240            TextBaseline::Top    => 0.0,
241            TextBaseline::Middle => -total_height * 0.5,
242            TextBaseline::Bottom => -total_height,
243        };
244
245        let mut result = Vec::new();
246        let mut span_iter = SpanCharIter::new(&self.spans);
247
248        for (row, line) in lines.iter().enumerate() {
249            let line_width = line.chars().count() as f32 * self.char_width;
250            let x_offset = match self.align {
251                TextAlign::Left   => 0.0,
252                TextAlign::Center => -line_width * 0.5,
253                TextAlign::Right  => -line_width,
254            };
255
256            for (col, _ch) in line.chars().enumerate() {
257                let x = self.position.x + x_offset + col as f32 * self.char_width;
258                let y = self.position.y + baseline_offset - row as f32 * self.char_height;
259                let z = self.position.z + self.z_offset;
260
261                if let Some((ch, color, emission, scale)) = span_iter.next() {
262                    result.push(CharLayout {
263                        ch,
264                        position: Vec3::new(x, y, z),
265                        color,
266                        emission,
267                        scale,
268                        layer:    self.layer,
269                        blend:    self.blend,
270                    });
271                }
272            }
273        }
274        result
275    }
276
277    fn wrap_lines<'a>(&self, text: &'a str) -> Vec<String> {
278        if let Some(max_w) = self.max_width {
279            let max_chars = (max_w / self.char_width.max(0.01)) as usize;
280            wrap_text(text, max_chars)
281        } else {
282            text.lines().map(|l| l.to_string()).collect()
283        }
284    }
285
286    /// Spawn all characters into a GlyphPool.
287    pub fn spawn_into(&self, pool: &mut GlyphPool) -> Vec<crate::glyph::GlyphId> {
288        let mut ids = Vec::new();
289        if !self.visible { return ids; }
290        for cl in self.layout() {
291            let g = Glyph {
292                character:  cl.ch,
293                position:   cl.position,
294                color:      cl.color,
295                emission:   cl.emission,
296                scale:      cl.scale,
297                layer:      cl.layer,
298                blend_mode: cl.blend,
299                visible:    true,
300                ..Glyph::default()
301            };
302            ids.push(pool.spawn(g));
303        }
304        ids
305    }
306}
307
308/// A single character's layout result.
309#[derive(Clone, Debug)]
310pub struct CharLayout {
311    pub ch:       char,
312    pub position: Vec3,
313    pub color:    Vec4,
314    pub emission: f32,
315    pub scale:    Vec2,
316    pub layer:    RenderLayer,
317    pub blend:    BlendMode,
318}
319
320/// Iterates over characters in a span list, yielding per-character style info.
321struct SpanCharIter<'a> {
322    spans:      &'a [TextSpan],
323    span_idx:   usize,
324    char_idx:   usize,
325}
326
327impl<'a> SpanCharIter<'a> {
328    fn new(spans: &'a [TextSpan]) -> Self {
329        Self { spans, span_idx: 0, char_idx: 0 }
330    }
331
332    fn next(&mut self) -> Option<(char, Vec4, f32, Vec2)> {
333        loop {
334            let span = self.spans.get(self.span_idx)?;
335            let ch   = span.text.chars().nth(self.char_idx);
336            if let Some(ch) = ch {
337                self.char_idx += 1;
338                if ch == '\n' { continue; } // skip newlines — handled by layout
339                return Some((ch, span.color, span.emission, span.scale));
340            } else {
341                self.span_idx += 1;
342                self.char_idx  = 0;
343            }
344        }
345    }
346}
347
348/// Word-wrap `text` to at most `max_chars` per line.
349pub fn wrap_text(text: &str, max_chars: usize) -> Vec<String> {
350    let mut lines = Vec::new();
351    for paragraph in text.split('\n') {
352        if paragraph.is_empty() { lines.push(String::new()); continue; }
353        let words: Vec<&str> = paragraph.split_whitespace().collect();
354        let mut line = String::new();
355        for word in words {
356            if line.is_empty() {
357                // Long single word — hard break it
358                if word.len() > max_chars {
359                    let mut w = word;
360                    while w.len() > max_chars {
361                        lines.push(w[..max_chars].to_string());
362                        w = &w[max_chars..];
363                    }
364                    line = w.to_string();
365                } else {
366                    line = word.to_string();
367                }
368            } else if line.len() + 1 + word.len() <= max_chars {
369                line.push(' ');
370                line.push_str(word);
371            } else {
372                lines.push(std::mem::take(&mut line));
373                line = word.to_string();
374            }
375        }
376        if !line.is_empty() { lines.push(line); }
377    }
378    if lines.is_empty() { lines.push(String::new()); }
379    lines
380}
381
382// ── Typewriter text block ─────────────────────────────────────────────────────
383
384/// A TextBlock with a typewriter reveal effect.
385pub struct TypewriterBlock {
386    pub block:          TextBlock,
387    pub chars_per_sec:  f32,
388    revealed_chars:     usize,
389    accumulator:        f32,
390    pub complete:       bool,
391    pause_timer:        f32,
392    full_char_count:    usize,
393    /// Optional sound trigger on each revealed character.
394    pub on_char:        Option<Box<dyn Fn(char) + Send + Sync>>,
395}
396
397impl TypewriterBlock {
398    pub fn new(block: TextBlock, chars_per_sec: f32) -> Self {
399        let full = block.full_text().chars().count();
400        Self {
401            block,
402            chars_per_sec,
403            revealed_chars:  0,
404            accumulator:     0.0,
405            complete:        full == 0,
406            pause_timer:     0.0,
407            full_char_count: full,
408            on_char:         None,
409        }
410    }
411
412    pub fn with_char_callback(mut self, f: impl Fn(char) + Send + Sync + 'static) -> Self {
413        self.on_char = Some(Box::new(f));
414        self
415    }
416
417    pub fn tick(&mut self, dt: f32) {
418        if self.complete { return; }
419        if self.pause_timer > 0.0 {
420            self.pause_timer -= dt;
421            return;
422        }
423        self.accumulator += dt * self.chars_per_sec;
424        let new = self.accumulator as usize;
425        self.accumulator -= new as f32;
426
427        let full_text = self.block.full_text();
428        for _ in 0..new {
429            if self.revealed_chars >= self.full_char_count { break; }
430            let ch = full_text.chars().nth(self.revealed_chars).unwrap_or(' ');
431            self.revealed_chars += 1;
432            if let Some(f) = &self.on_char { f(ch); }
433            match ch {
434                '.' | '!' | '?' => self.pause_timer = 0.2,
435                ',' | ';' | ':' => self.pause_timer = 0.08,
436                _ => {}
437            }
438        }
439        if self.revealed_chars >= self.full_char_count {
440            self.complete = true;
441        }
442    }
443
444    pub fn skip(&mut self) {
445        self.revealed_chars = self.full_char_count;
446        self.complete       = true;
447    }
448
449    pub fn progress(&self) -> f32 {
450        if self.full_char_count == 0 { 1.0 }
451        else { self.revealed_chars as f32 / self.full_char_count as f32 }
452    }
453
454    /// Build a truncated TextBlock showing only revealed characters.
455    pub fn visible_block(&self) -> TextBlock {
456        if self.complete { return self.block.clone(); }
457        let full = self.block.full_text();
458        let visible: String = full.chars().take(self.revealed_chars).collect();
459        TextBlock {
460            spans: vec![TextSpan { text: visible, ..self.block.spans[0].clone() }],
461            ..self.block.clone()
462        }
463    }
464
465    /// Spawn visible characters into a GlyphPool.
466    pub fn spawn_into(&self, pool: &mut GlyphPool) -> Vec<crate::glyph::GlyphId> {
467        self.visible_block().spawn_into(pool)
468    }
469}
470
471// ── ScrollingText ─────────────────────────────────────────────────────────────
472
473/// A scrolling text display — think: terminal output, combat log, news ticker.
474pub struct ScrollingText {
475    pub lines:      Vec<String>,
476    pub max_lines:  usize,
477    /// How many lines are visible at once.
478    pub visible:    usize,
479    pub scroll_pos: usize,
480    pub position:   Vec3,
481    pub char_width: f32,
482    pub char_height: f32,
483    pub color:      Vec4,
484    pub layer:      RenderLayer,
485    /// Auto-scroll speed (lines per second). 0 = manual only.
486    pub auto_scroll: f32,
487    scroll_accum:   f32,
488}
489
490impl ScrollingText {
491    pub fn new(position: Vec3, visible: usize) -> Self {
492        Self {
493            lines:       Vec::new(),
494            max_lines:   1000,
495            visible,
496            scroll_pos:  0,
497            position,
498            char_width:  0.6,
499            char_height: 1.0,
500            color:       Vec4::ONE,
501            layer:       RenderLayer::UI,
502            auto_scroll: 0.0,
503            scroll_accum: 0.0,
504        }
505    }
506
507    pub fn push(&mut self, line: impl Into<String>) {
508        self.lines.push(line.into());
509        if self.lines.len() > self.max_lines {
510            self.lines.remove(0);
511        }
512        // Auto-jump to bottom
513        if self.lines.len() > self.visible {
514            self.scroll_pos = self.lines.len() - self.visible;
515        }
516    }
517
518    pub fn scroll_up(&mut self)   { self.scroll_pos = self.scroll_pos.saturating_sub(1); }
519    pub fn scroll_down(&mut self) {
520        let max = self.lines.len().saturating_sub(self.visible);
521        self.scroll_pos = (self.scroll_pos + 1).min(max);
522    }
523
524    pub fn tick(&mut self, dt: f32) {
525        if self.auto_scroll > 0.0 {
526            self.scroll_accum += dt * self.auto_scroll;
527            while self.scroll_accum >= 1.0 {
528                self.scroll_accum -= 1.0;
529                self.scroll_down();
530            }
531        }
532    }
533
534    pub fn spawn_into(&self, pool: &mut GlyphPool) -> Vec<crate::glyph::GlyphId> {
535        let mut ids = Vec::new();
536        let end = (self.scroll_pos + self.visible).min(self.lines.len());
537        for (row, line) in self.lines[self.scroll_pos..end].iter().enumerate() {
538            let y = self.position.y - row as f32 * self.char_height;
539            for (col, ch) in line.chars().enumerate() {
540                let x = self.position.x + col as f32 * self.char_width;
541                let g = Glyph {
542                    character:  ch,
543                    position:   Vec3::new(x, y, self.position.z),
544                    color:      self.color,
545                    layer:      self.layer,
546                    visible:    true,
547                    ..Glyph::default()
548                };
549                ids.push(pool.spawn(g));
550            }
551        }
552        ids
553    }
554}
555
556// ── Marquee ───────────────────────────────────────────────────────────────────
557
558/// Horizontally scrolling marquee text.
559pub struct Marquee {
560    pub text:     String,
561    pub position: Vec3,
562    pub width:    f32,         // display width in world units
563    pub char_w:   f32,
564    pub speed:    f32,         // world units per second
565    offset:       f32,         // current scroll offset
566    pub color:    Vec4,
567    pub layer:    RenderLayer,
568}
569
570impl Marquee {
571    pub fn new(text: impl Into<String>, position: Vec3, width: f32, speed: f32) -> Self {
572        Self {
573            text: text.into(),
574            position,
575            width,
576            char_w: 0.6,
577            speed,
578            offset: 0.0,
579            color: Vec4::ONE,
580            layer: RenderLayer::UI,
581        }
582    }
583
584    pub fn tick(&mut self, dt: f32) {
585        self.offset += dt * self.speed;
586        let total_width = self.text.chars().count() as f32 * self.char_w;
587        if self.offset > total_width { self.offset = 0.0; }
588    }
589
590    pub fn spawn_into(&self, pool: &mut GlyphPool) -> Vec<crate::glyph::GlyphId> {
591        let mut ids       = Vec::new();
592        let max_chars     = (self.width / self.char_w.max(0.01)) as usize + 1;
593        let total_chars   = self.text.chars().count();
594        if total_chars == 0 { return ids; }
595
596        let start_char = (self.offset / self.char_w) as usize;
597
598        for i in 0..max_chars {
599            let text_idx = (start_char + i) % total_chars;
600            let ch = self.text.chars().nth(text_idx).unwrap_or(' ');
601            let frac = (self.offset / self.char_w).fract();
602            let x = self.position.x + (i as f32 - frac) * self.char_w;
603            if x < self.position.x || x > self.position.x + self.width { continue; }
604            let g = Glyph {
605                character: ch,
606                position:  Vec3::new(x, self.position.y, self.position.z),
607                color:     self.color,
608                layer:     self.layer,
609                visible:   true,
610                ..Glyph::default()
611            };
612            ids.push(pool.spawn(g));
613        }
614        ids
615    }
616}
617
618// ── Tests ─────────────────────────────────────────────────────────────────────
619
620#[cfg(test)]
621mod tests {
622    use super::*;
623
624    #[test]
625    fn wrap_text_basic() {
626        let lines = wrap_text("Hello world foo bar baz", 10);
627        for l in &lines { assert!(l.len() <= 10, "Line too long: {}", l); }
628        assert!(lines.len() >= 2);
629    }
630
631    #[test]
632    fn wrap_text_empty() {
633        let lines = wrap_text("", 20);
634        assert_eq!(lines.len(), 1);
635    }
636
637    #[test]
638    fn wrap_text_single_word() {
639        let lines = wrap_text("Hello", 20);
640        assert_eq!(lines[0], "Hello");
641    }
642
643    #[test]
644    fn wrap_long_word() {
645        let lines = wrap_text("AAAAAAAAAA", 4);
646        assert_eq!(lines[0], "AAAA");
647        assert_eq!(lines[1], "AAAA");
648        assert_eq!(lines[2], "AA");
649    }
650
651    #[test]
652    fn parse_rich_text_plain() {
653        let spans = parse_rich_text("Hello");
654        assert_eq!(spans.len(), 1);
655        assert_eq!(spans[0].text, "Hello");
656    }
657
658    #[test]
659    fn parse_rich_text_color() {
660        let spans = parse_rich_text("[color:1.0,0.0,0.0]Red[/color] Normal");
661        assert!(spans.len() >= 2);
662        let red = &spans[0];
663        assert!((red.color.x - 1.0).abs() < 0.01);
664        assert!((red.color.y - 0.0).abs() < 0.01);
665    }
666
667    #[test]
668    fn parse_rich_text_nested() {
669        let spans = parse_rich_text("[color:0,1,0]Green [bold]BoldGreen[/bold][/color]");
670        assert!(spans.iter().any(|s| (s.color.y - 1.0).abs() < 0.01));
671    }
672
673    #[test]
674    fn text_block_layout() {
675        let block = TextBlock::new("Hello", Vec3::ZERO);
676        let layout = block.layout();
677        assert_eq!(layout.len(), 5); // 5 chars
678    }
679
680    #[test]
681    fn text_block_wrap() {
682        let block = TextBlock::new("Hello World", Vec3::ZERO)
683            .with_max_width(0.6 * 5.0); // 5 chars wide
684        let layout = block.layout();
685        // "Hello" on row 0, "World" on row 1 → max y different
686        let max_y = layout.iter().map(|c| c.position.y).fold(f32::MIN, f32::max);
687        let min_y = layout.iter().map(|c| c.position.y).fold(f32::MAX, f32::min);
688        assert!(max_y > min_y, "Expect multiple rows");
689    }
690
691    #[test]
692    fn typewriter_reveals_chars() {
693        let block  = TextBlock::new("Hello", Vec3::ZERO);
694        let mut tw = TypewriterBlock::new(block, 20.0);
695        tw.tick(0.1);  // 2 chars
696        assert!(tw.revealed_chars > 0);
697        assert!(!tw.complete);
698    }
699
700    #[test]
701    fn typewriter_completes() {
702        let block  = TextBlock::new("Hi", Vec3::ZERO);
703        let mut tw = TypewriterBlock::new(block, 100.0);
704        tw.tick(1.0);
705        assert!(tw.complete);
706    }
707
708    #[test]
709    fn typewriter_skip() {
710        let block  = TextBlock::new("Long text", Vec3::ZERO);
711        let mut tw = TypewriterBlock::new(block, 2.0);
712        tw.skip();
713        assert!(tw.complete);
714        assert_eq!(tw.progress(), 1.0);
715    }
716
717    #[test]
718    fn scrolling_text_push_and_scroll() {
719        let mut log = ScrollingText::new(Vec3::ZERO, 3);
720        log.push("Line 1");
721        log.push("Line 2");
722        log.push("Line 3");
723        log.push("Line 4");
724        assert_eq!(log.lines.len(), 4);
725        assert!(log.scroll_pos >= 1); // should have scrolled to show Line 4
726    }
727
728    #[test]
729    fn marquee_wraps() {
730        let mut m = Marquee::new("ABCDE", Vec3::ZERO, 3.0, 1.0);
731        for _ in 0..300 { m.tick(0.016); }
732        // offset should not grow without bound
733        assert!(m.offset < m.text.len() as f32 * m.char_w + 1.0);
734    }
735}